Files
rustfs/crates/ecstore/src/store/init.rs
T

19906 lines
880 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use crate::core::pools::{
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, load_pool_meta_identity_observing,
local_decommission_queue_prefix, persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
};
use crate::runtime::instance::InstanceContext;
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::object::EcstoreObjectIO;
use rustfs_config::server_config::KVS;
use rustfs_credentials::{RPC_SECRET_REQUIRED_OPERATOR_MESSAGE, try_get_rpc_token};
use std::future::Future;
use tracing::{debug, error, info, warn};
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_STORE_INIT: &str = "store_init";
const EVENT_DECOMMISSION_RESUME_RETRY: &str = "decommission_resume_retry";
const EVENT_STORE_FORMAT_RETRY: &str = "store_format_retry";
const EVENT_ECSTORE_INIT_STATUS: &str = "ecstore_init_status";
const EVENT_STORE_RPC_SECRET_PREFLIGHT_FAILED: &str = "store_rpc_secret_preflight_failed";
fn pool_first_endpoint_is_local(pool: &crate::layout::endpoints::PoolEndpoints) -> bool {
pool.endpoints.as_ref().first().is_some_and(|endpoint| endpoint.is_local)
}
fn startup_pool_drive_counts(endpoint_pools: &EndpointServerPools) -> Vec<usize> {
endpoint_pools.as_ref().iter().map(|pool| pool.drives_per_set).collect()
}
fn resolve_startup_pool_defaults(endpoint_pools: &EndpointServerPools) -> Result<Vec<usize>> {
resolve_startup_pool_defaults_with(endpoint_pools, ECStore::validate_startup_storage_class)
}
fn resolve_startup_pool_defaults_with(
endpoint_pools: &EndpointServerPools,
validate: impl FnOnce(&EndpointServerPools) -> Result<()>,
) -> Result<Vec<usize>> {
validate(endpoint_pools)?;
let drive_counts = startup_pool_drive_counts(endpoint_pools);
drive_counts.into_iter().map(ec_drives_no_config).collect()
}
/// Fail fast when the topology spans remote nodes but no internode RPC secret
/// resolves. Every remote format read would otherwise fail client-side with
/// "No valid auth token" and startup would retry for minutes before dying with
/// a misleading "erasure read quorum" error (issues #4939, #5153).
fn preflight_startup_rpc_secret(endpoint_pools: &EndpointServerPools) -> Result<()> {
preflight_startup_rpc_secret_with(endpoint_pools, try_get_rpc_token)
}
fn preflight_startup_rpc_secret_with(
endpoint_pools: &EndpointServerPools,
resolve_rpc_token: impl FnOnce() -> std::io::Result<String>,
) -> Result<()> {
let has_remote_endpoint = endpoint_pools
.as_ref()
.iter()
.flat_map(|pool| pool.endpoints.as_ref().iter())
.any(|endpoint| !endpoint.is_local);
if !has_remote_endpoint {
return Ok(());
}
match resolve_rpc_token() {
Ok(_) => Ok(()),
Err(err) => {
// The message prefix must stay aligned with the runtime log in
// cluster/rpc/http_auth.rs: the log-analyzer `rpc-secret-resolution`
// rule anchors on it, and this preflight aborts before that log
// would ever be emitted.
error!(
event = EVENT_STORE_RPC_SECRET_PREFLIGHT_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
"RPC auth secret resolution failed: {err}; {RPC_SECRET_REQUIRED_OPERATOR_MESSAGE}"
);
Err(Error::other(format!(
"store init aborted: endpoints include remote nodes but {err}; {RPC_SECRET_REQUIRED_OPERATOR_MESSAGE}"
)))
}
}
}
const LOCAL_DECOMMISSION_INITIAL_RESUME_DELAY: Duration = Duration::from_secs(60 * 3);
const LOCAL_DECOMMISSION_RESUME_RETRY_DELAY: Duration = Duration::from_secs(30);
const LOCAL_DECOMMISSION_WATCHDOG_INTERVAL: Duration = Duration::from_secs(30);
const LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY: Duration = Duration::from_secs(60 * 5);
const REBALANCE_INITIAL_RESUME_DELAY: Duration = Duration::from_secs(10);
const REBALANCE_RESUME_RETRY_DELAY: Duration = Duration::from_secs(10);
fn should_retry_format_load(err: &Error) -> bool {
!matches!(err, Error::CorruptedFormat)
}
fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_resume_required: bool) -> bool {
rebalance_resume_required && !decommission_running
}
fn should_defer_rebalance_auto_start(distributed: bool, fleet_proof_available: bool) -> bool {
distributed && !fleet_proof_available
}
async fn wait_for_local_decommission_resume_delay(rx: &CancellationToken, delay: Duration) -> bool {
tokio::select! {
_ = rx.cancelled() => false,
_ = tokio::time::sleep(delay) => true,
}
}
fn local_decommission_watchdog_retry_delay(consecutive_failures: u32) -> Duration {
let exponent = consecutive_failures.saturating_sub(1).min(4);
LOCAL_DECOMMISSION_RESUME_RETRY_DELAY
.saturating_mul(1_u32 << exponent)
.min(LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY)
}
async fn wait_for_rebalance_resume_delay(rx: &CancellationToken, delay: Duration) -> bool {
tokio::select! {
_ = rx.cancelled() => false,
_ = tokio::time::sleep(delay) => true,
}
}
async fn wait_for_rebalance_resume_retry(rx: &CancellationToken) -> bool {
wait_for_rebalance_resume_delay(rx, REBALANCE_RESUME_RETRY_DELAY).await
}
fn resolve_store_init_stage_result(result: Result<()>, stage: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("store init failed during {stage}: {err}")))
}
async fn load_pool_meta_for_startup<S>(
pools: Vec<Arc<S>>,
write_state: &mut PoolMetaWriteState,
) -> Result<(PoolMeta, PoolMetaReplicaState)>
where
S: EcstoreObjectIO,
{
load_pool_meta_identity_observing(pools.clone(), write_state)
.await
.map_err(|err| Error::other(format!("store init failed during load_pool_meta_identity: {err}")))?;
let mut meta = PoolMeta::default();
let replica_state = meta
.load_no_lock_from_replicas_observing(pools, write_state)
.await
.map_err(|err| Error::other(format!("store init failed during load_pool_meta: {err}")))?;
write_state.observe_replicas(replica_state);
write_state
.ensure_missing_metadata_can_initialize()
.map_err(|err| Error::other(format!("store init failed during classify_pool_meta_absence: {err}")))?;
Ok((meta, replica_state))
}
async fn establish_pool_meta_bootstrap_identity_if_proven<S>(
pools: Vec<Arc<S>>,
write_state: &mut PoolMetaWriteState,
elected_writer: bool,
) -> Result<()>
where
S: EcstoreObjectIO,
{
if elected_writer && write_state.bootstrap_identity_proven() {
persist_pool_meta_identity_for_startup(pools, write_state, false).await?;
}
Ok(())
}
async fn save_validated_pool_meta_for_startup<S>(
meta: &PoolMeta,
pools: Vec<Arc<S>>,
write_state: &mut PoolMetaWriteState,
) -> Result<PoolMeta>
where
S: EcstoreObjectIO,
{
meta.save_for_startup_observing(pools, write_state)
.await
.map_err(|err| Error::other(format!("store init failed during save_validated_pool_meta: {err}")))
}
async fn persist_pool_meta_for_startup_if_safe<S>(
meta: &PoolMeta,
pools: Vec<Arc<S>>,
replica_state: PoolMetaReplicaState,
write_state: &mut PoolMetaWriteState,
topology_update: bool,
elected_writer: bool,
) -> Result<PoolMeta>
where
S: EcstoreObjectIO,
{
if !elected_writer {
return Ok(meta.clone());
}
let should_write = topology_update || (replica_state.needs_repair && replica_state.repair_write_safe);
if topology_update {
replica_state.ensure_write_safe("store init failed during save_validated_pool_meta")?;
}
if should_write || write_state.identity_requires_repair() {
write_state.ensure_write_safe("store init failed during save_validated_pool_meta")?;
}
let mut committed = meta.clone();
if should_write {
if write_state.bootstrap_identity_proven() || write_state.identity_is_pending() {
persist_pool_meta_identity_for_startup(pools.clone(), write_state, false)
.await
.map_err(|err| Error::other(format!("store init failed during prepare_pool_meta_identity: {err}")))?;
}
committed = save_validated_pool_meta_for_startup(meta, pools.clone(), write_state).await?;
}
if should_write || write_state.identity_requires_repair() {
persist_pool_meta_identity_for_startup(pools, write_state, true)
.await
.map_err(|err| Error::other(format!("store init failed during commit_pool_meta_identity: {err}")))?;
}
Ok(committed)
}
async fn run_local_decommission_watchdog<F, Fut>(rx: CancellationToken, mut reconcile: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<()>>,
{
let mut consecutive_failures = 0_u32;
loop {
if rx.is_cancelled() {
return;
}
let delay = match reconcile().await {
Ok(()) => {
consecutive_failures = 0;
LOCAL_DECOMMISSION_WATCHDOG_INTERVAL
}
Err(err) => {
consecutive_failures = consecutive_failures.saturating_add(1);
let retry_delay = local_decommission_watchdog_retry_delay(consecutive_failures);
warn!(
event = EVENT_DECOMMISSION_RESUME_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
consecutive_failures,
retry_delay_secs = retry_delay.as_secs(),
error = %err,
"Retrying decommission worker recovery"
);
retry_delay
}
};
if !wait_for_local_decommission_resume_delay(&rx, delay).await {
return;
}
}
}
async fn supervise_local_decommission_after_init(store: Arc<ECStore>, rx: CancellationToken) {
run_local_decommission_watchdog(rx.clone(), || {
let store = store.clone();
let worker_rx = rx.clone();
async move {
store
.ensure_pool_meta_side_effects_safe("decommission worker recovery blocked while pool metadata requires recovery")
.await?;
if store.has_active_local_decommission_worker().await {
return Ok(());
}
store.refresh_pool_status_meta().await?;
store.spawn_missing_local_decommission_routines_with_token(worker_rx).await
}
})
.await;
}
async fn resume_rebalance_after_init(store: Arc<ECStore>, rx: CancellationToken) {
if !wait_for_rebalance_resume_delay(&rx, REBALANCE_INITIAL_RESUME_DELAY).await {
return;
}
loop {
if rx.is_cancelled() {
return;
}
let resume_required = store
.rebalance_meta
.read()
.await
.as_ref()
.is_some_and(crate::services::rebalance::rebalance_requires_worker_activation);
if !resume_required {
return;
}
if should_defer_rebalance_auto_start(
store.ctx.is_dist_erasure().await,
crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().is_some(),
) {
if !wait_for_rebalance_resume_retry(&rx).await {
return;
}
continue;
}
match store.start_rebalance().await {
Ok(()) => return,
Err(err) if crate::core::pools::is_pool_activation_fleet_proof_error(&err) => {
warn!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
stage = "start_rebalance",
state = "retrying",
reason = "fleet_capability_proof_unavailable",
error = %err,
retry_delay_secs = REBALANCE_RESUME_RETRY_DELAY.as_secs(),
"Retrying deferred rebalance auto-start"
);
if !wait_for_rebalance_resume_retry(&rx).await {
return;
}
}
Err(err) => {
error!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
stage = "start_rebalance",
state = "failed",
reason = "deferred_resume_failed",
error = %err,
"Failed to resume rebalance after store initialization"
);
return;
}
}
}
}
impl ECStore {
/// Validate topology and process storage-class overrides before any disk is opened.
pub fn validate_startup_storage_class(endpoint_pools: &EndpointServerPools) -> Result<()> {
let drive_counts = startup_pool_drive_counts(endpoint_pools);
storageclass::lookup_config_for_pools(&KVS::new(), &drive_counts).map(|_| ())
}
#[allow(clippy::new_ret_no_self)]
#[instrument(level = "debug", skip(endpoint_pools))]
pub async fn new(address: SocketAddr, endpoint_pools: EndpointServerPools, ctx: CancellationToken) -> Result<Arc<Self>> {
Self::new_with_instance_ctx(address, endpoint_pools, ctx, crate::runtime::instance::bootstrap_ctx()).await
}
/// Build a store around an explicit instance context (Phase 5 follow-up,
/// backlog#1052). The legacy [`ECStore::new`] entry adopts the process
/// bootstrap context, keeping single-instance startup byte-for-byte
/// unchanged; a caller that owns its own context (a future second embedded
/// server) passes it here so every construction-time write — pool sets,
/// local-disk registry, deployment id — lands on that context instead of
/// the shared bootstrap one.
#[allow(clippy::new_ret_no_self)]
#[instrument(level = "debug", skip(endpoint_pools, instance_ctx))]
pub async fn new_with_instance_ctx(
address: SocketAddr,
endpoint_pools: EndpointServerPools,
ctx: CancellationToken,
instance_ctx: Arc<InstanceContext>,
) -> Result<Arc<Self>> {
Box::pin(Self::new_with_instance_ctx_inner(address, endpoint_pools, ctx, instance_ctx)).await
}
async fn new_with_instance_ctx_inner(
address: SocketAddr,
endpoint_pools: EndpointServerPools,
ctx: CancellationToken,
instance_ctx: Arc<InstanceContext>,
) -> Result<Arc<Self>> {
instance_ctx.bind_background_cancel_token(ctx.clone());
// let layouts = DisksLayout::from_volumes(endpoints.as_slice())?;
// Validate topology and environment overrides before opening any disk.
// The values stored on SetDisks remain pure per-pool topology defaults;
// payload writes use the runtime storage-class snapshot published later
// from config before the store is marked ready.
let default_pool_parities = resolve_startup_pool_defaults(&endpoint_pools)?;
preflight_startup_rpc_secret(&endpoint_pools)?;
let mut deployment_id = None;
let mut pool_meta_bootstrap_authority = None;
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
let mut pools = Vec::with_capacity(endpoint_pools.as_ref().len());
let mut disk_map = HashMap::with_capacity(endpoint_pools.as_ref().len());
let mut local_disks = Vec::new();
debug!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
address = %address,
"Initializing ECStore address"
);
let mut host = address.ip().to_string();
if host.is_empty() {
host = runtime_sources::rustfs_host().await
}
let mut port = address.port().to_string();
if port.is_empty() {
port = runtime_sources::rustfs_port().to_string()
}
debug!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
host = %host,
port = %port,
"Initializing ECStore host"
);
init_local_peer(&endpoint_pools, &host, &port).await;
// debug!("endpoint_pools: {:?}", endpoint_pools);
for (i, pool_eps) in endpoint_pools.as_ref().iter().enumerate() {
let pool_first_is_local = pool_first_endpoint_is_local(pool_eps);
let parity_drives = default_pool_parities
.get(i)
.copied()
.ok_or_else(|| Error::other(format!("store init failed to resolve default parity for pool {i}")))?;
// validate_parity(parity_count, pool_eps.drives_per_set)?;
// Build disks with health monitoring available, but do not start
// periodic monitoring until format loading succeeds. Startup RPC
// failures can still spawn recovery probes for peers that come up
// after this node.
let (mut disks, errs) = init_format::init_disks(
&pool_eps.endpoints,
&DiskOption {
cleanup: true,
health_check: true,
},
)
.await;
check_disk_fatal_errs(&errs)?;
let loaded_format = {
let mut times = 0;
let mut interval = 1;
loop {
match init_format::connect_load_init_formats_with_instance_ctx(
&instance_ctx,
pool_first_is_local,
&mut disks,
pool_eps.set_count,
pool_eps.drives_per_set,
deployment_id,
)
.await
{
Ok(fm) => break Ok(fm),
Err(e) if !should_retry_format_load(&e) => break Err(e),
// Wrap the final error if we are giving up
Err(e) if times >= 10 => {
break Err(Error::other(format!("store init failed to load formats after {times} retries: {e}")));
}
// Retrying so just drop the error
Err(_) => {}
}
times += 1;
if interval < 16 {
interval *= 2;
}
debug!(
event = EVENT_STORE_FORMAT_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
retry_count = times,
retry_delay_secs = interval,
"Retrying storage format load"
);
select! {
_ = tokio::signal::ctrl_c() => {
info!(
event = EVENT_STORE_FORMAT_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
reason = "ctrl_c",
"Interrupted storage format retry loop"
);
exit(0);
}
_ = sleep(Duration::from_secs(interval)) => {
}
}
// After waiting for peers, clear transient faulty marks so the next attempt can open RPCs again
// (these `DiskStore` handles are reused; `is_faulty()` would otherwise short-circuit).
for disk in disks.iter().flatten() {
disk.reset_health_for_store_init_retry();
}
}
}?;
pool_meta_bootstrap_authority = Some(pool_meta_bootstrap_authority.map_or(
loaded_format.pool_meta_bootstrap_authority,
|authority: PoolMetaBootstrapAuthority| {
authority.combine_across_pools(loaded_format.pool_meta_bootstrap_authority)
},
));
let fm = loaded_format.format;
// Format loading succeeded, enable health monitoring on all disks
for disk in disks.iter().flatten() {
disk.enable_health_check();
}
if deployment_id.is_none() {
deployment_id = Some(fm.id);
}
if deployment_id != Some(fm.id) {
return Err(Error::other("store init failed: deployment IDs do not match across pools"));
}
if deployment_id.is_some_and(|id| id.is_nil()) {
deployment_id = Some(Uuid::new_v4());
}
for disk in disks.iter() {
if disk.is_some() && disk.as_ref().expect("operation should succeed").is_local() {
local_disks.push(disk.as_ref().expect("operation should succeed").clone());
}
}
let sets = Sets::new_with_instance_ctx(disks.clone(), pool_eps, &fm, i, parity_drives, instance_ctx.clone()).await?;
pools.push(sets);
disk_map.insert(i, disks);
}
// Replace the local disk
if !instance_ctx.is_dist_erasure().await {
runtime_sources::record_local_disks(&instance_ctx, local_disks).await;
}
let deployment_id = deployment_id.ok_or_else(|| Error::other("store init failed: deployment id is not initialized"))?;
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
pool_meta.dont_save = true;
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_bootstrap_authority(
deployment_id,
pool_meta_bootstrap_authority.unwrap_or_default(),
);
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
let ec = Arc::new(ECStore {
id: deployment_id,
disk_map,
pools,
peer_sys,
pool_meta: RwLock::new(pool_meta),
rebalance_meta: RwLock::new(None),
decommission_cancelers,
start_gate: Mutex::new(()),
pool_meta_save_gate: Mutex::new(pool_meta_write_state),
// Adopt the caller's context (the process bootstrap one on the
// legacy path) so startup writes (erasure type recorded before
// this point) and later reads share one cell.
ctx: instance_ctx.clone(),
bucket_fence_registry: std::sync::Arc::default(),
});
// Only set it when this instance's deployment ID is not yet configured
if instance_ctx.deployment_id().is_none() {
instance_ctx.set_deployment_id(deployment_id);
}
let wait_sec = 5;
let mut exit_count = 0;
loop {
if let Err(err) = ec.init(ctx.clone()).await {
error!("init err: {}", err);
error!("retry after {} second", wait_sec);
sleep(Duration::from_secs(wait_sec)).await;
if exit_count > 10 {
return Err(Error::other("store init failed: init retry budget exhausted"));
}
exit_count += 1;
continue;
}
break;
}
runtime_sources::publish_object_store(ec.clone()).await;
Ok(ec)
}
#[instrument(level = "debug", skip(self, rx))]
pub async fn init(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
runtime_sources::ensure_boot_time().await;
let should_persist_pool_meta = self
.pools
.first()
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
let (meta, pool_meta_replica_state) = {
let mut write_state = self.pool_meta_save_gate.lock().await;
establish_pool_meta_bootstrap_identity_if_proven(self.pools.clone(), &mut write_state, should_persist_pool_meta)
.await
.map_err(|err| Error::other(format!("store init failed during establish_pool_meta_bootstrap_identity: {err}")))?;
load_pool_meta_for_startup(self.pools.clone(), &mut write_state).await?
};
let update = meta.validate(self.pools.clone())?;
let endpoints = runtime_sources::endpoint_pools_or_default();
let mut installed_pool_meta = if update {
PoolMeta::new(&self.pools, &meta)
} else {
meta.clone()
};
// Only one local node should persist validated pool metadata here; otherwise
// distributed startup can race on the same lock and replay the prior init bug.
{
let mut write_state = self.pool_meta_save_gate.lock().await;
installed_pool_meta = persist_pool_meta_for_startup_if_safe(
&installed_pool_meta,
self.pools.clone(),
pool_meta_replica_state,
&mut write_state,
update,
should_persist_pool_meta,
)
.await?;
}
{
let mut pool_meta = self.pool_meta.write().await;
*pool_meta = installed_pool_meta.clone();
}
resolve_store_init_stage_result(self.load_rebalance_meta().await, "load_rebalance_meta")?;
let rebalance_resume_required = {
let rebalance_meta = self.rebalance_meta.read().await;
rebalance_meta
.as_ref()
.is_some_and(crate::services::rebalance::rebalance_requires_worker_activation)
};
let decommission_running =
pool_meta_has_active_decommission(&installed_pool_meta) || self.is_decommission_running().await;
let distributed = self.ctx.is_dist_erasure().await;
let fleet_proof_available = crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().is_some();
let mut rebalance_auto_start_deferred = false;
if should_auto_start_rebalance_after_init(decommission_running, rebalance_resume_required) {
if should_defer_rebalance_auto_start(distributed, fleet_proof_available) {
rebalance_auto_start_deferred = true;
warn!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
stage = "start_rebalance",
state = "deferred",
reason = "fleet_capability_proof_unavailable",
retry_delay_secs = REBALANCE_RESUME_RETRY_DELAY.as_secs(),
"Deferred rebalance auto-start until a live fleet capability proof is available"
);
} else if let Err(err) = self.start_rebalance().await {
if crate::core::pools::is_pool_activation_fleet_proof_error(&err) {
rebalance_auto_start_deferred = true;
warn!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
stage = "start_rebalance",
state = "deferred",
reason = "fleet_capability_proof_changed",
error = %err,
retry_delay_secs = REBALANCE_RESUME_RETRY_DELAY.as_secs(),
"Deferred rebalance auto-start after the fleet capability proof changed"
);
} else {
return resolve_store_init_stage_result(Err(err), "start_rebalance");
}
}
} else if decommission_running && rebalance_resume_required {
warn!(
event = EVENT_ECSTORE_INIT_STATUS,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
stage = "start_rebalance",
reason = "active_decommission",
"Deferred rebalance auto-start during store init because decommission is active"
);
}
// Initialize the storage-owned scanner publication state only after
// both movement metadata sources have been loaded. SetDisks cache
// writers remain fail-closed until this snapshot is available.
let _ = self.scanner_data_usage_publication_blocked().await;
let pools = installed_pool_meta.return_resumable_pools();
let mut pool_indices = Vec::with_capacity(pools.len());
for p in pools.iter() {
if let Some(idx) = endpoints.get_pool_idx(&p.cmd_line) {
pool_indices.push(idx);
} else {
return Err(Error::other(format!(
"store init failed to resolve resumable decommission pool `{}` from current endpoints",
p.cmd_line
)));
}
}
let local_pool_indices = local_decommission_queue_prefix(&endpoints, &pool_indices)?;
let has_local_decommission_leadership = endpoints.as_ref().iter().any(pool_first_endpoint_is_local);
let pool_meta_write_safe = self
.ensure_pool_meta_side_effects_safe("decommission resume blocked while pool metadata requires recovery")
.await
.is_ok();
if !pool_meta_replica_state.repair_write_safe || !pool_meta_write_safe {
warn!(
event = EVENT_DECOMMISSION_RESUME_RETRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_INIT,
state = "blocked",
pool_indices = ?local_pool_indices,
reason = "pool_meta_write_blocked",
"Decommission watchdog waiting for pool metadata recovery"
);
}
if has_local_decommission_leadership {
let store = self.clone();
let decommission_rx = rx.clone();
tokio::spawn(async move {
if !wait_for_local_decommission_resume_delay(&decommission_rx, LOCAL_DECOMMISSION_INITIAL_RESUME_DELAY).await {
return;
}
supervise_local_decommission_after_init(store, decommission_rx).await;
});
}
runtime_sources::init_bucket_monitor_for_current_endpoints();
crate::bucket::bucket_target_sys::BucketTargetSys::get().start_heartbeat();
init_background_expiry(self.clone()).await;
crate::bucket::lifecycle::bucket_lifecycle_ops::init_background_stale_multipart_upload_cleanup(self.clone());
TransitionState::init(self.clone()).await;
crate::services::tier::tier::try_migrate_tiering_config(self.clone()).await;
if let Err(err) = runtime_sources::init_tier_config_mgr(self.clone()).await {
info!("TierConfigMgr init error: {}", err);
}
if rebalance_auto_start_deferred {
let store = self.clone();
tokio::spawn(resume_rebalance_after_init(store, rx));
}
Ok(())
}
pub fn init_local_disks() {}
pub fn single_pool(&self) -> bool {
self.pools.len() == 1
}
}
#[cfg(test)]
mod tests {
use super::{
LOCAL_DECOMMISSION_RESUME_RETRY_DELAY, LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY, PoolMetaWriteState,
establish_pool_meta_bootstrap_identity_if_proven, load_pool_meta_for_startup, local_decommission_watchdog_retry_delay,
persist_pool_meta_for_startup_if_safe, pool_first_endpoint_is_local, pool_meta_has_active_decommission,
preflight_startup_rpc_secret_with, resolve_startup_pool_defaults_with, resolve_store_init_stage_result,
run_local_decommission_watchdog, save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init,
should_defer_rebalance_auto_start, should_retry_format_load, wait_for_local_decommission_resume_delay,
};
use crate::core::pools::PoolMetaBootstrapAuthority;
#[cfg(feature = "test-util")]
use crate::disk::DiskAPI;
#[cfg(feature = "test-util")]
use crate::{
bucket::lifecycle::{
DurableIlmRecordCheckpoint, ILM_META_PREFIX, ValidatedDurableIlmRecord,
bucket_lifecycle_ops::{ExpiryState, ManualTransitionRunOptions, recover_manual_transition_jobs_once},
lifecycle::{TRANSITION_PENDING, TransitionOptions},
manual_transition_job::{
ManualTransitionJobRecord, ManualTransitionScopeAdmission, ManualTransitionTaskRecord,
ManualTransitionWorkerResult, ManualTransitionWorkerResultRecord, manual_transition_job_record_object_name,
manual_transition_scope_record_object_name, manual_transition_task_object_name,
manual_transition_worker_result_object_name, manual_transition_worker_result_task_key,
},
tier_delete_journal::{
DecommissionCheckpointTargetFailureHook, TIER_DELETE_DISPATCH_MANIFEST_PREFIX, TIER_DELETE_JOURNAL_PREFIX,
TierDeleteChunkTestBarrier, TierDeleteChunkTestStage, TierDeleteDispatchBatchLimitGuard,
TierDeleteDispatchManifestState, TierDeleteDispatchMemberReadTestHook, TierDeleteDispatchMemberReadTestStage,
TierDeleteDispatchRollbackTestHook, complete_tier_delete_dispatch, encode_tier_delete_journal_entry,
install_test_tier_delete_dispatch_fixture, persist_tier_delete_journal_entry, prepare_tier_delete_dispatch,
recover_test_tier_delete_dispatch_manifest, recover_test_tier_delete_dispatch_manifest_with_page_budget,
recover_test_tier_delete_journal_pass_with_budget, recover_tier_delete_dispatch_manifests,
recover_tier_delete_journal_entries, test_tier_delete_dispatch_manifest_checkpoint,
test_tier_delete_dispatch_manifest_state, tier_delete_dispatch_manifest_operation_lock_held_for_test,
tier_delete_dispatch_manifest_recovery_count_for_test, tier_delete_dispatch_manifest_recovery_inflight_for_test,
tier_delete_journal_object_name,
},
tier_free_version_recovery::recover_tier_free_versions,
tier_sweeper::{
Jentry, TierDeleteJournalState, TierDeleteSourceIdentity, transitioned_delete_journal_entry_for_source,
},
transition_transaction::{
TRANSITION_TRANSACTION_RECORD_PREFIX, TransitionCleanupDecision, TransitionCleanupProof, TransitionOperatorError,
TransitionOperatorProbe, TransitionRecoveryClaimBarrier, TransitionRemoteVersion, TransitionSourceIdentity,
TransitionSourceVersionMode, TransitionTransaction, TransitionTransactionInit, TransitionTransactionState,
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
inspect_transition_transaction_for_operator, load_transition_transaction_record,
recover_transition_transaction_records, recover_transition_transaction_records_at,
save_transition_transaction_record, save_transition_transaction_record_if_current,
transition_transaction_record_object_name,
},
validate_durable_ilm_record,
},
bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG},
config::com,
core::pools::DecomBucketInfo,
data_movement::SourceCleanupDeleteBarrier,
disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE},
runtime::{global::set_object_store_resolver, sources as runtime_sources},
services::notification_sys::acquire_tier_delete_journal_fleet_proof,
services::tier::{
test_util::{MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, register_mock_tier},
tier::{
ERR_TIER_BACKEND_IN_USE, ERR_TIER_INVALID_CONFIG, TIER_CONFIG_FILE, TierConfigMgr,
ensure_no_authoritative_tier_references_for_test, tier_config_candidate_digest,
},
tier_config::{TierConfig, TierType, TierWasabi},
tier_mutation_intent::{
TIER_MUTATION_INTENT_RECORD_PREFIX, TierMutationIntent, TierMutationIntentKind, TierMutationIntentState,
TierMutationIntentTarget, advance_tier_mutation_intent_record_idempotent, delete_tier_mutation_intent_record,
list_tier_mutation_intent_records, load_tier_mutation_intent_record, load_tier_mutation_intent_record_with_etag,
save_tier_mutation_intent_record, save_tier_mutation_intent_record_if_current,
},
tier_mutation_peer::{TierMutationPeerError, TierMutationPeerState, handle_tier_mutation_peer_request},
tier_probe_intent::{
TierProbeIntent, TierProbeIntentState, TierProbeOperationIdentity, TierProbeOwnerFence, TierProbeRemoteVersion,
delete_tier_probe_intent_record_if_current, load_tier_probe_intent_record,
save_tier_probe_intent_record_if_absent, save_tier_probe_intent_record_if_current,
},
warm_backend::{TransitionCandidateProbe, WarmBackend},
},
set_disk::SetDiskTransitionUploadedCommitBarrier as TransitionUploadedCommitBarrier,
storage_api_contracts::list::ListOperations as _,
};
#[cfg(feature = "test-util")]
use crate::{bucket::replication::ReplicationObjectBridge, storage_api_contracts::bucket::DeleteBucketOptions};
use crate::{
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
core::pools::{
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
PoolDecommissionInfo, PoolMeta, PoolStatus, pool_meta_identity_initialized_for_test,
pool_meta_v3_commit_state_for_test, set_decommission_capacity_info_overrides_for_test,
},
disk::endpoint::Endpoint,
error::{Error, Result, StorageError},
io_support::rio::{WritePlan, compression_metadata_value},
layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints},
object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader},
services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats},
storage_api_contracts::{
bucket::{BucketOperations as _, MakeBucketOptions},
multipart::MultipartOperations as _,
object::{ObjectIO, ObjectOperations as _, ObjectToDelete},
range::HTTPRangeSpec,
},
};
#[cfg(feature = "test-util")]
use futures::{StreamExt as _, TryStreamExt as _};
use http::HeaderMap;
use rustfs_config::server_config::KVS;
#[cfg(feature = "test-util")]
use rustfs_filemeta::{FileInfo, FileMeta};
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntry};
#[cfg(feature = "test-util")]
use rustfs_protos::{TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase};
use rustfs_rio::{Checksum, ChecksumType};
#[cfg(feature = "test-util")]
use rustfs_s3_client::transition_api::ReaderImpl;
use rustfs_utils::{
CompressionAlgorithm,
http::{SUFFIX_COMPRESSION, insert_str},
};
use std::{
collections::HashMap,
future::Future,
io::Cursor,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::Duration,
};
use time::OffsetDateTime;
use tokio::io::AsyncReadExt;
use tokio_util::sync::CancellationToken;
fn run_large_stack_async_test<C, F>(name: &str, case: C)
where
C: FnOnce() -> F + Send + 'static,
F: Future<Output = ()> + 'static,
{
const STACK_SIZE: usize = if cfg!(debug_assertions) {
8 * rustfs_config::DEFAULT_THREAD_STACK_SIZE
} else if cfg!(target_os = "macos") {
2 * rustfs_config::DEFAULT_THREAD_STACK_SIZE
} else {
rustfs_config::DEFAULT_THREAD_STACK_SIZE
};
std::thread::Builder::new()
.name(name.to_string())
.stack_size(STACK_SIZE)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.thread_stack_size(STACK_SIZE)
.build()
.expect("large-stack store test runtime should build");
runtime.block_on(case());
})
.expect("large-stack store test thread should spawn")
.join()
.expect("large-stack store test thread should complete");
}
use uuid::Uuid;
#[test]
fn new_with_instance_ctx_future_remains_stack_bounded() {
let future = crate::store::ECStore::new_with_instance_ctx(
std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
EndpointServerPools(Vec::new()),
CancellationToken::new(),
Arc::new(crate::runtime::instance::InstanceContext::new()),
);
let future_size = std::mem::size_of_val(&future);
assert!(
future_size <= 4 * 1024,
"ECStore constructor future must remain stack-bounded; measured {future_size} bytes"
);
}
fn startup_pool_meta_payload(meta: &PoolMeta) -> Vec<u8> {
meta.encode_config_data_for_test().expect("pool metadata should encode")
}
#[derive(Debug)]
struct StartupPoolMetaStorage {
read_payload: Vec<u8>,
read_error: bool,
read_without_lock: AtomicBool,
wrote_without_lock: AtomicBool,
wrote_with_max_parity: AtomicBool,
written_payload: Mutex<Option<Vec<u8>>>,
revision: AtomicUsize,
pool_meta_write_attempts: AtomicUsize,
fail_pool_meta_write_at: AtomicUsize,
pool_meta_written_versions: Mutex<Vec<u16>>,
objects: Mutex<HashMap<String, (Vec<u8>, String)>>,
}
impl StartupPoolMetaStorage {
fn new(read_payload: Vec<u8>) -> Self {
let mut objects = HashMap::new();
if !read_payload.is_empty() {
objects.insert(POOL_META_NAME.to_string(), (read_payload.clone(), "startup-pool-meta-0".to_string()));
}
Self {
read_payload,
read_error: false,
read_without_lock: AtomicBool::new(false),
wrote_without_lock: AtomicBool::new(false),
wrote_with_max_parity: AtomicBool::new(false),
written_payload: Mutex::new(None),
revision: AtomicUsize::new(0),
pool_meta_write_attempts: AtomicUsize::new(0),
fail_pool_meta_write_at: AtomicUsize::new(0),
pool_meta_written_versions: Mutex::new(Vec::new()),
objects: Mutex::new(objects),
}
}
fn unreadable() -> Self {
Self {
read_payload: Vec::new(),
read_error: true,
read_without_lock: AtomicBool::new(false),
wrote_without_lock: AtomicBool::new(false),
wrote_with_max_parity: AtomicBool::new(false),
written_payload: Mutex::new(None),
revision: AtomicUsize::new(0),
pool_meta_write_attempts: AtomicUsize::new(0),
fail_pool_meta_write_at: AtomicUsize::new(0),
pool_meta_written_versions: Mutex::new(Vec::new()),
objects: Mutex::new(HashMap::new()),
}
}
fn object_info(&self, bucket: &str, object: &str, size: usize, etag: String) -> ObjectInfo {
ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: size as i64,
actual_size: size as i64,
etag: Some(etag),
..Default::default()
}
}
}
#[async_trait::async_trait]
impl ObjectIO for StartupPoolMetaStorage {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
_range: Option<HTTPRangeSpec>,
_h: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
assert!(opts.no_lock, "store init pool metadata load must not require namespace locks");
self.read_without_lock.store(true, Ordering::SeqCst);
if self.read_error {
return Err(Error::other("pool metadata read quorum unavailable"));
}
let Some((payload, etag)) = self
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(object)
.cloned()
else {
return Err(Error::FileNotFound);
};
Ok(GetObjectReader {
stream: Box::new(Cursor::new(payload.clone())),
object_info: self.object_info(bucket, object, payload.len(), etag),
buffered_body: None,
body_source: Default::default(),
})
}
async fn put_object(
&self,
bucket: &str,
object: &str,
data: &mut PutObjReader,
opts: &ObjectOptions,
) -> Result<ObjectInfo> {
assert!(opts.no_lock, "store init pool metadata save must not require namespace locks");
self.wrote_without_lock.store(true, Ordering::SeqCst);
self.wrote_with_max_parity.store(opts.max_parity, Ordering::SeqCst);
if object == POOL_META_NAME {
let attempt = self.pool_meta_write_attempts.fetch_add(1, Ordering::SeqCst) + 1;
if self.fail_pool_meta_write_at.load(Ordering::SeqCst) == attempt {
return Err(Error::Timeout);
}
}
let current_etag = self
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(object)
.map(|(_, etag)| etag.clone());
if opts
.http_preconditions
.as_ref()
.and_then(|preconditions| preconditions.if_none_match_value())
== Some("*")
&& current_etag.is_some()
{
return Err(Error::PreconditionFailed);
}
if let Some(expected) = opts
.http_preconditions
.as_ref()
.and_then(|preconditions| preconditions.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 size = payload.len();
if object == POOL_META_NAME {
*self.written_payload.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(payload.clone());
if payload.len() >= 4 {
self.pool_meta_written_versions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(u16::from_le_bytes([payload[2], payload[3]]));
}
}
let etag = format!("startup-pool-meta-{}", self.revision.fetch_add(1, Ordering::SeqCst) + 1);
self.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(object.to_string(), (payload, etag.clone()));
Ok(self.object_info(bucket, object, size, etag))
}
}
fn init_test_pool_meta(decommission: Option<PoolDecommissionInfo>) -> PoolMeta {
PoolMeta {
version: POOL_META_VERSION,
pools: vec![PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update: OffsetDateTime::UNIX_EPOCH,
decommission,
}],
dont_save: false,
}
}
#[tokio::test]
async fn test_fresh_bootstrap_pool_meta_save_reaches_v3_cas_without_blocking_itself() {
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut write_state = PoolMetaWriteState::for_startup(Uuid::new_v4(), true);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut write_state, true)
.await
.expect("the elected fresh bootstrap should persist its identity before loading pool metadata");
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
.await
.expect("startup pool metadata load should tolerate missing metadata without locks");
assert!(loaded.pools.is_empty());
assert!(!replica_state.needs_repair);
assert!(replica_state.repair_write_safe);
assert!(storage.read_without_lock.load(Ordering::SeqCst));
let meta = PoolMeta {
version: POOL_META_VERSION,
pools: Vec::new(),
dont_save: false,
};
save_validated_pool_meta_for_startup(&meta, vec![storage.clone()], &mut write_state)
.await
.expect("startup pool metadata save should bypass locks");
assert!(storage.wrote_without_lock.load(Ordering::SeqCst));
assert!(storage.wrote_with_max_parity.load(Ordering::SeqCst));
assert_eq!(
storage.pool_meta_write_attempts.load(Ordering::SeqCst),
2,
"fresh bootstrap should reach both V3 prepare and commit CAS writes"
);
assert_eq!(
*storage
.pool_meta_written_versions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
vec![3, 3]
);
write_state
.ensure_write_safe("fresh bootstrap publication")
.expect("successful startup publication must disarm the transaction guard");
}
#[tokio::test]
async fn test_legacy_adoption_pool_meta_bootstrap_reaches_v3_cas() {
let deployment_id = Uuid::new_v4();
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut write_state =
PoolMetaWriteState::for_startup_with_bootstrap_authority(deployment_id, PoolMetaBootstrapAuthority::LegacyAdoption);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut write_state, true)
.await
.expect("verified legacy adoption should persist a nonce-bound identity before loading pool metadata");
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
.await
.expect("verified legacy adoption should authorize initially missing pool metadata");
assert!(loaded.pools.is_empty());
let committed = persist_pool_meta_for_startup_if_safe(
&init_test_pool_meta(None),
vec![storage.clone()],
replica_state,
&mut write_state,
true,
true,
)
.await
.expect("verified legacy adoption should publish initial pool metadata");
assert_eq!(committed.pools[0].cmd_line, "pool-0");
let objects = storage.objects.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(objects.contains_key(POOL_META_NAME));
let identity = objects
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("legacy adoption should commit the bootstrap identity");
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode committed identity"));
}
#[tokio::test]
async fn test_non_elected_legacy_adoption_cannot_initialize_pool_meta() {
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut write_state =
PoolMetaWriteState::for_startup_with_bootstrap_authority(Uuid::new_v4(), PoolMetaBootstrapAuthority::LegacyAdoption);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut write_state, false)
.await
.expect("a non-elected distributed node must not create legacy adoption authority");
let err = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
.await
.expect_err("legacy adoption still requires the elected writer to create a durable identity");
assert!(err.to_string().contains("no durable bootstrap identity"));
assert!(
!storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(POOL_META_NAME),
"classification must not create pool metadata on non-elected nodes"
);
}
#[tokio::test]
async fn test_unproven_pending_identity_cannot_authorize_all_missing_pool_meta() {
let deployment_id = Uuid::new_v4();
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut proven_bootstrap = PoolMetaWriteState::for_startup(deployment_id, true);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut proven_bootstrap, true)
.await
.expect("fresh topology proof should persist a nonce-bound pending identity");
let identity = storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("pending identity should be durable");
assert!(!pool_meta_identity_initialized_for_test(&identity).expect("decode pending identity"));
let mut unproven_restart = PoolMetaWriteState::for_startup(deployment_id, false);
let err = load_pool_meta_for_startup(vec![storage.clone()], &mut unproven_restart)
.await
.expect_err("a pending identity alone must not authorize an all-missing restart");
assert!(err.to_string().contains("no verified fresh-bootstrap proof"));
unproven_restart
.ensure_write_safe("unproven pending identity restart")
.expect_err("the rejected restart must latch the write gate");
assert!(
!storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(POOL_META_NAME),
"classification must not create pool metadata"
);
}
#[tokio::test]
async fn test_nonfresh_identity_repair_crash_never_persists_pending_bootstrap_authority() {
let deployment_id = Uuid::new_v4();
let initial = init_test_pool_meta(None);
let storage = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&initial)));
let mut write_state = PoolMetaWriteState::for_startup(deployment_id, false);
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
.await
.expect("an existing pool metadata snapshot may repair its missing identity");
storage.fail_pool_meta_write_at.store(1, Ordering::SeqCst);
persist_pool_meta_for_startup_if_safe(&loaded, vec![storage.clone()], replica_state, &mut write_state, true, true)
.await
.expect_err("the injected crash boundary should stop before the pool metadata replacement");
let identity = storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("identity repair should be durable before the failed topology write");
assert!(
pool_meta_identity_initialized_for_test(&identity).expect("decode repaired identity"),
"an existing cluster must never leave pending bootstrap authority at this crash boundary"
);
storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(POOL_META_NAME);
let mut restarted = PoolMetaWriteState::for_startup(deployment_id, false);
let err = load_pool_meta_for_startup(vec![storage], &mut restarted)
.await
.expect_err("wiping pool metadata after the crash must require recovery");
assert!(err.to_string().contains("initialized cluster identity exists"));
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn test_existing_legacy_identity_and_replica_repair_respects_disabled_v3_gates() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V3_WRITE, None::<&str>),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, None::<&str>),
],
async {
let deployment_id = Uuid::new_v4();
let corrupt = Arc::new(StartupPoolMetaStorage::new(vec![0, 1, 2]));
let legacy = init_test_pool_meta(None);
let valid = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&legacy)));
let mut write_state = PoolMetaWriteState::for_startup(deployment_id, false);
let (loaded, replica_state) = load_pool_meta_for_startup(vec![corrupt.clone(), valid.clone()], &mut write_state)
.await
.expect("existing V2 metadata should remain readable while its identity is missing");
assert!(replica_state.needs_repair);
let committed = persist_pool_meta_for_startup_if_safe(
&loaded,
vec![corrupt.clone(), valid.clone()],
replica_state,
&mut write_state,
true,
true,
)
.await
.expect("legacy replica and identity repair should succeed without crossing the V3 gate");
assert_eq!(committed.version, POOL_META_VERSION);
for storage in [corrupt, valid] {
assert_eq!(
*storage
.pool_meta_written_versions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
vec![POOL_META_VERSION],
"legacy repair must write exactly one V2 snapshot"
);
let identity = storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("identity repair should persist on every pool");
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode repaired identity"));
}
},
)
.await;
}
#[tokio::test]
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
let deployment_id = Uuid::new_v4();
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut fresh_state = PoolMetaWriteState::for_startup(deployment_id, true);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut fresh_state, true)
.await
.expect("fresh topology proof should become a durable pending identity");
let (_, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut fresh_state)
.await
.expect("new formats with no identity may initialize pool metadata exactly once");
let committed = persist_pool_meta_for_startup_if_safe(
&init_test_pool_meta(None),
vec![storage.clone()],
replica_state,
&mut fresh_state,
true,
true,
)
.await
.expect("fresh deployment should durably commit identity and pool metadata");
assert_eq!(committed.pools[0].cmd_line, "pool-0");
{
let objects = storage.objects.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(objects.contains_key(POOL_META_NAME));
assert!(objects.contains_key(POOL_META_IDENTITY_NAME));
}
storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(POOL_META_NAME);
let mut wiped_pool_state = PoolMetaWriteState::for_startup(deployment_id, false);
let err = load_pool_meta_for_startup(vec![storage.clone()], &mut wiped_pool_state)
.await
.expect_err("an initialized identity must prevent an empty pool metadata rebuild");
assert!(err.to_string().contains("initialized cluster identity exists"));
storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(POOL_META_IDENTITY_NAME);
let mut wiped_all_state = PoolMetaWriteState::for_startup(deployment_id, false);
let err = load_pool_meta_for_startup(vec![storage], &mut wiped_all_state)
.await
.expect_err("existing formats without identity or pool metadata must require recovery");
assert!(err.to_string().contains("no durable bootstrap identity"));
let distributed_node = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut non_elected_state = PoolMetaWriteState::for_startup(Uuid::new_v4(), true);
establish_pool_meta_bootstrap_identity_if_proven(vec![distributed_node.clone()], &mut non_elected_state, false)
.await
.expect("a non-elected distributed node must not create bootstrap authority");
let err = load_pool_meta_for_startup(vec![distributed_node], &mut non_elected_state)
.await
.expect_err("a distributed node without durable identity or pool metadata must require recovery");
assert!(err.to_string().contains("no durable bootstrap identity"));
}
#[tokio::test]
async fn test_store_init_resumes_pending_v3_bootstrap_without_legacy_overwrite() {
let deployment_id = Uuid::new_v4();
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut first_bootstrap = PoolMetaWriteState::for_startup(deployment_id, true);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut first_bootstrap, true)
.await
.expect("fresh bootstrap should persist a pending identity");
let (_, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut first_bootstrap)
.await
.expect("the durable pending identity should authorize the initial pool metadata write");
storage.fail_pool_meta_write_at.store(2, Ordering::SeqCst);
persist_pool_meta_for_startup_if_safe(
&init_test_pool_meta(None),
vec![storage.clone()],
replica_state,
&mut first_bootstrap,
true,
true,
)
.await
.expect_err("the injected crash boundary should leave only the V3 prepare record");
let (pending_pool, pending_identity) = {
let objects = storage.objects.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
(
objects
.get(POOL_META_NAME)
.map(|(payload, _)| payload.clone())
.expect("the V3 prepare record should be durable"),
objects
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("the bootstrap identity should be durable"),
)
};
assert_eq!(pool_meta_v3_commit_state_for_test(pending_pool).expect("decode pending V3"), (1, false));
assert!(!pool_meta_identity_initialized_for_test(&pending_identity).expect("decode pending identity"));
storage.fail_pool_meta_write_at.store(0, Ordering::SeqCst);
let mut restarted = PoolMetaWriteState::for_startup(deployment_id, false);
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut restarted)
.await
.expect("restart should recover the predecessor embedded in the pending V3 record");
assert!(loaded.pools.is_empty());
assert!(replica_state.needs_repair);
let committed = persist_pool_meta_for_startup_if_safe(
&init_test_pool_meta(None),
vec![storage.clone()],
replica_state,
&mut restarted,
true,
true,
)
.await
.expect("restart should finish generation 1 before promoting the identity");
assert_eq!(committed.version, 3);
let (committed_pool, committed_identity) = {
let objects = storage.objects.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
(
objects
.get(POOL_META_NAME)
.map(|(payload, _)| payload.clone())
.expect("the committed V3 record should be durable"),
objects
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("the committed identity should be durable"),
)
};
assert_eq!(
pool_meta_v3_commit_state_for_test(committed_pool).expect("decode committed V3"),
(1, true)
);
assert!(pool_meta_identity_initialized_for_test(&committed_identity).expect("decode committed identity"));
assert!(
storage
.pool_meta_written_versions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.all(|version| *version == 3),
"bootstrap recovery must never overwrite a pending V3 record with legacy metadata"
);
}
#[tokio::test]
async fn test_store_init_pool_meta_falls_back_from_corrupt_first_replica() {
let corrupt = Arc::new(StartupPoolMetaStorage::new(vec![0, 1, 2]));
let expected = init_test_pool_meta(None);
let backup = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&expected)));
let mut write_state = PoolMetaWriteState::default();
let (loaded, replica_state) = load_pool_meta_for_startup(vec![corrupt.clone(), backup.clone()], &mut write_state)
.await
.expect("startup should select the validated backup replica");
assert!(replica_state.needs_repair);
assert!(replica_state.repair_write_safe);
assert_eq!(loaded.pools.len(), 1);
assert_eq!(loaded.pools[0].cmd_line, expected.pools[0].cmd_line);
assert!(corrupt.read_without_lock.load(Ordering::SeqCst));
assert!(backup.read_without_lock.load(Ordering::SeqCst));
persist_pool_meta_for_startup_if_safe(
&loaded,
vec![corrupt.clone(), backup.clone()],
replica_state,
&mut write_state,
false,
true,
)
.await
.expect("the elected startup writer should repair validated corrupt replicas");
let corrupt_write = corrupt
.written_payload
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.expect("corrupt replica should be repaired");
let backup_write = backup
.written_payload
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.expect("backup replica should receive the same canonical snapshot");
assert_eq!(corrupt_write, backup_write);
assert_ne!(corrupt_write, corrupt.read_payload);
}
#[tokio::test]
async fn test_store_init_pool_meta_does_not_repair_unreadable_replica() {
let valid = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&init_test_pool_meta(None))));
let unreadable = Arc::new(StartupPoolMetaStorage::unreadable());
let mut write_state = PoolMetaWriteState::default();
let (loaded, replica_state) = load_pool_meta_for_startup(vec![valid.clone(), unreadable.clone()], &mut write_state)
.await
.expect("startup should use a validated replica without overwriting an unreadable copy");
assert!(replica_state.needs_repair);
assert!(!replica_state.repair_write_safe);
persist_pool_meta_for_startup_if_safe(
&loaded,
vec![valid.clone(), unreadable.clone()],
replica_state,
&mut write_state,
false,
true,
)
.await
.expect("an unreadable copy should defer repair when no topology write is needed");
assert!(!valid.wrote_without_lock.load(Ordering::SeqCst));
assert!(!unreadable.wrote_without_lock.load(Ordering::SeqCst));
let err = persist_pool_meta_for_startup_if_safe(
&loaded,
vec![valid.clone(), unreadable.clone()],
replica_state,
&mut write_state,
true,
true,
)
.await
.expect_err("a topology update must not overwrite an unreadable replica");
assert!(err.to_string().contains("cannot overwrite an unreadable replica"));
assert!(!valid.wrote_without_lock.load(Ordering::SeqCst));
assert!(!unreadable.wrote_without_lock.load(Ordering::SeqCst));
}
#[tokio::test]
async fn test_store_init_pool_meta_stays_blocked_after_all_replicas_were_unreadable() {
let unreadable_a = Arc::new(StartupPoolMetaStorage::unreadable());
let unreadable_b = Arc::new(StartupPoolMetaStorage::unreadable());
let mut write_state = PoolMetaWriteState::default();
load_pool_meta_for_startup(vec![unreadable_a, unreadable_b], &mut write_state)
.await
.expect_err("startup must fail when no readable pool metadata replica exists");
let repaired = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&init_test_pool_meta(None))));
let (loaded, replica_state) = load_pool_meta_for_startup(vec![repaired.clone()], &mut write_state)
.await
.expect("a later startup retry may read the repaired replica");
assert!(replica_state.repair_write_safe);
let err =
persist_pool_meta_for_startup_if_safe(&loaded, vec![repaired.clone()], replica_state, &mut write_state, true, true)
.await
.expect_err("the same store instance must not write after observing unreadable replicas");
assert!(
err.to_string()
.contains("restart after all replicas are readable and consistent")
);
assert!(!repaired.wrote_without_lock.load(Ordering::SeqCst));
}
#[test]
fn test_pool_first_endpoint_is_local_respects_local_flag() {
let mut local_endpoint = Endpoint::try_from("http://127.0.0.1:9000/data").expect("endpoint should parse");
local_endpoint.is_local = true;
let pool = PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![local_endpoint]),
cmd_line: "pool-0".to_string(),
platform: String::new(),
};
assert!(pool_first_endpoint_is_local(&pool));
}
#[test]
fn test_pool_first_endpoint_is_local_rejects_missing_endpoint() {
let pool = 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(),
};
assert!(!pool_first_endpoint_is_local(&pool));
}
#[test]
fn test_local_decommission_watchdog_retry_delay_is_bounded() {
assert_eq!(local_decommission_watchdog_retry_delay(1), LOCAL_DECOMMISSION_RESUME_RETRY_DELAY);
assert_eq!(
local_decommission_watchdog_retry_delay(u32::MAX),
LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY
);
}
#[tokio::test(start_paused = true)]
async fn test_local_decommission_watchdog_retries_general_failures_until_cancelled() {
let rx = CancellationToken::new();
let attempts = Arc::new(AtomicUsize::new(0));
let task = tokio::spawn(run_local_decommission_watchdog(rx.clone(), {
let attempts = attempts.clone();
let rx = rx.clone();
move || {
let attempts = attempts.clone();
let rx = rx.clone();
async move {
if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
Err(StorageError::SlowDown)
} else {
rx.cancel();
Ok(())
}
}
}
}));
tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
tokio::time::advance(LOCAL_DECOMMISSION_RESUME_RETRY_DELAY).await;
task.await.expect("watchdog task should exit after cancellation");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[tokio::test(start_paused = true)]
async fn test_local_decommission_watchdog_rescans_after_success() {
let rx = CancellationToken::new();
let attempts = Arc::new(AtomicUsize::new(0));
let task = tokio::spawn(run_local_decommission_watchdog(rx.clone(), {
let attempts = attempts.clone();
let rx = rx.clone();
move || {
let attempts = attempts.clone();
let rx = rx.clone();
async move {
if attempts.fetch_add(1, Ordering::SeqCst) == 1 {
rx.cancel();
}
Ok(())
}
}
}));
tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
tokio::time::advance(super::LOCAL_DECOMMISSION_WATCHDOG_INTERVAL).await;
task.await.expect("watchdog task should exit after cancellation");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[test]
fn test_should_retry_format_load_rejects_permanent_corruption() {
assert!(!should_retry_format_load(&StorageError::CorruptedFormat));
assert!(should_retry_format_load(&StorageError::ErasureReadQuorum));
assert!(should_retry_format_load(&StorageError::FirstDiskWait));
}
#[test]
fn test_should_auto_start_rebalance_after_init_allows_active_rebalance_without_decommission() {
assert!(should_auto_start_rebalance_after_init(false, true));
}
#[test]
fn test_should_auto_start_rebalance_after_init_rejects_active_decommission() {
assert!(!should_auto_start_rebalance_after_init(true, true));
}
#[test]
fn test_should_auto_start_rebalance_after_init_rejects_terminal_or_missing_rebalance() {
assert!(!should_auto_start_rebalance_after_init(false, false));
}
#[test]
fn test_should_defer_rebalance_auto_start_only_without_distributed_fleet_proof() {
assert!(should_defer_rebalance_auto_start(true, false));
assert!(!should_defer_rebalance_auto_start(true, true));
assert!(!should_defer_rebalance_auto_start(false, false));
}
#[test]
fn test_store_init_recovery_skips_rebalance_when_decommission_metadata_is_active() {
let pool_meta = init_test_pool_meta(Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::UNIX_EPOCH),
complete: false,
failed: false,
canceled: false,
..Default::default()
}));
let rebalance_meta = Some(RebalanceMeta {
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
}],
..Default::default()
});
assert!(!should_auto_start_rebalance_after_init(
pool_meta_has_active_decommission(&pool_meta),
rebalance_meta
.as_ref()
.is_some_and(crate::services::rebalance::rebalance_requires_worker_activation)
));
}
#[test]
fn test_store_init_recovery_allows_active_rebalance_without_decommission() {
let pool_meta = init_test_pool_meta(None);
let rebalance_meta = Some(RebalanceMeta {
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
}],
..Default::default()
});
assert!(should_auto_start_rebalance_after_init(
pool_meta_has_active_decommission(&pool_meta),
rebalance_meta
.as_ref()
.is_some_and(crate::services::rebalance::rebalance_requires_worker_activation)
));
}
#[test]
fn test_store_init_recovery_skips_completed_rebalance_metadata() {
let pool_meta = init_test_pool_meta(None);
let rebalance_meta = Some(RebalanceMeta {
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Completed,
..Default::default()
},
..Default::default()
}],
..Default::default()
});
assert!(!should_auto_start_rebalance_after_init(
pool_meta_has_active_decommission(&pool_meta),
rebalance_meta
.as_ref()
.is_some_and(crate::services::rebalance::rebalance_requires_worker_activation)
));
}
#[test]
fn test_resolve_store_init_stage_result_passthrough_ok() {
resolve_store_init_stage_result(Ok(()), "load_rebalance_meta").expect("successful stage should pass through");
}
#[test]
fn test_resolve_store_init_stage_result_wraps_error_context() {
let err = resolve_store_init_stage_result(Err(StorageError::SlowDown), "start_rebalance")
.expect_err("failed stage should be wrapped");
let err_message = err.to_string();
assert!(err_message.contains("store init failed during start_rebalance"));
assert!(err_message.contains(&StorageError::SlowDown.to_string()));
}
#[tokio::test]
async fn test_wait_for_local_decommission_resume_delay_returns_true_after_delay() {
let rx = CancellationToken::new();
assert!(wait_for_local_decommission_resume_delay(&rx, Duration::from_millis(1)).await);
}
#[tokio::test]
async fn test_wait_for_local_decommission_resume_delay_returns_false_when_cancelled() {
let rx = CancellationToken::new();
rx.cancel();
assert!(!wait_for_local_decommission_resume_delay(&rx, Duration::from_secs(1)).await);
}
#[test]
fn test_pool_first_endpoint_is_local_uses_pool_scope_for_expansion() {
let mut remote_endpoint = Endpoint::try_from("http://127.0.0.2:9000/data1").expect("remote endpoint should parse");
remote_endpoint.is_local = false;
let mut local_endpoint = Endpoint::try_from("http://127.0.0.1:9000/data1").expect("local endpoint should parse");
local_endpoint.is_local = true;
let endpoints = EndpointServerPools::from(vec![
PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![remote_endpoint]),
cmd_line: "pool-0".to_string(),
platform: String::new(),
},
PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 1,
endpoints: Endpoints::from(vec![local_endpoint]),
cmd_line: "pool-1".to_string(),
platform: String::new(),
},
]);
assert!(!endpoints.first_local(), "cluster first endpoint is intentionally remote");
assert!(
pool_first_endpoint_is_local(endpoints.as_ref().get(1).expect("second pool should exist")),
"the expanded pool should be initialized by its own first local endpoint"
);
}
fn rpc_preflight_pools(pools: Vec<Vec<Endpoint>>) -> EndpointServerPools {
EndpointServerPools::from(
pools
.into_iter()
.enumerate()
.map(|(pool_index, endpoints)| PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: endpoints.len(),
endpoints: Endpoints::from(endpoints),
cmd_line: format!("pool-{pool_index}"),
platform: String::new(),
})
.collect::<Vec<_>>(),
)
}
fn parsed_local_endpoint() -> Endpoint {
let mut endpoint = Endpoint::try_from("http://127.0.0.1:9000/data").expect("local endpoint should parse");
endpoint.is_local = true;
endpoint
}
fn parsed_remote_endpoint() -> Endpoint {
let mut endpoint = Endpoint::try_from("http://10.0.0.2:9000/data").expect("remote endpoint should parse");
endpoint.is_local = false;
endpoint
}
#[test]
fn test_rpc_secret_preflight_aborts_for_remote_endpoints_without_secret() {
// The remote endpoint intentionally sits behind a local one: the
// preflight must scan every endpoint, not just the first.
let pools = rpc_preflight_pools(vec![vec![parsed_local_endpoint(), parsed_remote_endpoint()]]);
let err = preflight_startup_rpc_secret_with(&pools, || {
Err(std::io::Error::other(rustfs_credentials::RPC_SECRET_REQUIRED_MESSAGE))
})
.expect_err("remote endpoints without an RPC secret must abort startup before the format retry loop");
let message = err.to_string();
assert!(message.contains("store init aborted"), "unexpected error: {message}");
assert!(
message.contains(rustfs_credentials::RPC_SECRET_REQUIRED_MESSAGE),
"error must state the resolution failure: {message}"
);
assert!(
message.contains(rustfs_credentials::RPC_SECRET_REQUIRED_OPERATOR_MESSAGE),
"error must carry the operator remediation guidance: {message}"
);
}
#[test]
fn test_rpc_secret_preflight_aborts_for_remote_endpoint_in_later_pool() {
// The remote endpoint hides in a later, otherwise-local pool: a
// first-pool shortcut (the `first_local` shape) must not pass.
let pools = rpc_preflight_pools(vec![vec![parsed_local_endpoint()], vec![parsed_remote_endpoint()]]);
let err = preflight_startup_rpc_secret_with(&pools, || {
Err(std::io::Error::other(rustfs_credentials::RPC_SECRET_REQUIRED_MESSAGE))
})
.expect_err("a remote endpoint in a later pool must abort startup without an RPC secret");
let message = err.to_string();
assert!(message.contains("store init aborted"), "unexpected error: {message}");
}
#[test]
fn test_rpc_secret_preflight_skips_resolution_for_local_only_endpoints() {
preflight_startup_rpc_secret_with(&rpc_preflight_pools(vec![vec![parsed_local_endpoint()]]), || {
panic!("single-node topology must not resolve an RPC secret")
})
.expect("local-only topology must start without an RPC secret");
}
#[test]
fn test_rpc_secret_preflight_accepts_remote_endpoints_with_resolved_secret() {
preflight_startup_rpc_secret_with(&rpc_preflight_pools(vec![vec![parsed_remote_endpoint()]]), || {
Ok("resolved-secret".to_string())
})
.expect("a resolvable RPC secret must not block startup");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn test_new_with_instance_ctx_aborts_before_format_retry_loop_without_rpc_secret() {
// Guard: under a shared-process `cargo test` run another test may have
// already pinned the process-global RPC secret (ensure_test_rpc_secret),
// which would let init proceed past the preflight into remote disk
// init. A failing probe pins nothing, so it cannot poison later tests;
// under nextest's process-per-test model (CI) the guard never fires.
if rustfs_credentials::try_get_rpc_token().is_ok() {
return;
}
let mut remote_endpoint = parsed_remote_endpoint();
remote_endpoint.set_pool_index(0);
remote_endpoint.set_set_index(0);
remote_endpoint.set_disk_index(0);
let err = crate::store::ECStore::new_with_instance_ctx(
"127.0.0.1:0".parse().expect("test address"),
rpc_preflight_pools(vec![vec![remote_endpoint]]),
CancellationToken::new(),
Arc::new(crate::runtime::instance::InstanceContext::new()),
)
.await
.expect_err("distributed init without an RPC secret must abort before the format retry loop");
let message = err.to_string();
assert!(message.contains("store init aborted"), "unexpected error: {message}");
assert!(
message.contains(rustfs_credentials::RPC_SECRET_REQUIRED_OPERATOR_MESSAGE),
"abort must carry the operator remediation guidance: {message}"
);
}
fn endpoint_pools_with_drive_counts(counts: &[usize]) -> EndpointServerPools {
EndpointServerPools::from(
counts
.iter()
.enumerate()
.map(|(pool_index, &drives_per_set)| PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set,
endpoints: Endpoints::from(Vec::new()),
cmd_line: format!("pool-{pool_index}"),
platform: String::new(),
})
.collect::<Vec<_>>(),
)
}
#[test]
fn startup_pool_defaults_are_resolved_per_pool() {
let validate = |pools: &EndpointServerPools| {
let drive_counts: Vec<_> = pools.as_ref().iter().map(|pool| pool.drives_per_set).collect();
crate::config::storageclass::lookup_config_for_pools_without_env(&KVS::new(), &drive_counts).map(|_| ())
};
let defaults = resolve_startup_pool_defaults_with(&endpoint_pools_with_drive_counts(&[4, 2]), validate)
.expect("heterogeneous topology should resolve");
assert_eq!(defaults, vec![2, 1]);
let defaults = resolve_startup_pool_defaults_with(&endpoint_pools_with_drive_counts(&[4, 6]), validate)
.expect("heterogeneous topology should resolve");
assert_eq!(defaults, vec![2, 3]);
}
#[test]
fn startup_pool_defaults_validate_explicit_environment_for_every_pool() {
let validate = |pools: &EndpointServerPools| {
let drive_counts: Vec<_> = pools.as_ref().iter().map(|pool| pool.drives_per_set).collect();
let mut kvs = KVS::new();
kvs.insert(crate::config::storageclass::CLASS_STANDARD.to_string(), "EC:2".to_string());
crate::config::storageclass::lookup_config_for_pools_without_env(&kvs, &drive_counts).map(|_| ())
};
let err = resolve_startup_pool_defaults_with(&endpoint_pools_with_drive_counts(&[4, 2]), validate)
.expect_err("explicit EC:2 must fail before any two-drive pool I/O");
assert!(err.to_string().contains("pool 1") && err.to_string().contains("2 drives"));
}
#[test]
#[serial_test::serial(storage_class_env)]
fn startup_pool_defaults_validate_environment_without_changing_metadata_fallback() {
temp_env::with_vars(
[
(crate::config::storageclass::STANDARD_ENV, Some("EC:1")),
(crate::config::storageclass::RRS_ENV, None),
(crate::config::storageclass::OPTIMIZE_ENV, None),
(crate::config::storageclass::INLINE_BLOCK_ENV, None),
],
|| {
let runtime = crate::config::storageclass::lookup_config_for_pools(&KVS::new(), &[6, 4])
.expect("explicit standard parity must resolve the runtime candidate");
assert_eq!(runtime.parities_for_sc(crate::config::storageclass::STANDARD), Some(vec![1, 1]));
let defaults = super::resolve_startup_pool_defaults(&endpoint_pools_with_drive_counts(&[6, 4]))
.expect("explicit standard parity must validate for every pool");
assert_eq!(defaults, vec![3, 2]);
},
);
}
async fn without_storage_class_env<F: Future>(future: F) -> F::Output {
temp_env::async_with_vars(
[
(crate::config::storageclass::STANDARD_ENV, None::<&str>),
(crate::config::storageclass::RRS_ENV, None::<&str>),
(crate::config::storageclass::OPTIMIZE_ENV, None::<&str>),
(crate::config::storageclass::INLINE_BLOCK_ENV, None::<&str>),
],
future,
)
.await
}
// Build a real local store over a temp dir around a fresh instance context.
async fn build_isolated_test_store(
temp_dir: &std::path::Path,
cmd_line: &str,
pool_drive_counts: &[usize],
) -> (
Arc<crate::runtime::instance::InstanceContext>,
Arc<crate::store::ECStore>,
CancellationToken,
) {
build_isolated_test_store_with_shutdown(temp_dir, cmd_line, pool_drive_counts, CancellationToken::new()).await
}
async fn build_isolated_test_store_with_shutdown(
temp_dir: &std::path::Path,
cmd_line: &str,
pool_drive_counts: &[usize],
shutdown: CancellationToken,
) -> (
Arc<crate::runtime::instance::InstanceContext>,
Arc<crate::store::ECStore>,
CancellationToken,
) {
let pool_layouts = pool_drive_counts
.iter()
.map(|&drives_per_set| (1, drives_per_set))
.collect::<Vec<_>>();
build_isolated_test_store_with_layout(temp_dir, cmd_line, &pool_layouts, shutdown).await
}
async fn build_isolated_test_store_with_layout(
temp_dir: &std::path::Path,
cmd_line: &str,
pool_layouts: &[(usize, usize)],
shutdown: CancellationToken,
) -> (
Arc<crate::runtime::instance::InstanceContext>,
Arc<crate::store::ECStore>,
CancellationToken,
) {
let mut pools = Vec::with_capacity(pool_layouts.len());
for (pool_index, &(set_count, drives_per_set)) in pool_layouts.iter().enumerate() {
let mut endpoints = Vec::with_capacity(set_count * drives_per_set);
for set_index in 0..set_count {
for disk_index in 0..drives_per_set {
let path = temp_dir.join(format!("pool{pool_index}/set{set_index}/disk{disk_index}"));
tokio::fs::create_dir_all(&path).await.expect("create disk dir");
let mut endpoint =
Endpoint::try_from(path.to_str().expect("disk path should be utf-8")).expect("local endpoint");
endpoint.set_pool_index(pool_index);
endpoint.set_set_index(set_index);
endpoint.set_disk_index(disk_index);
endpoints.push(endpoint);
}
}
pools.push(PoolEndpoints {
legacy: false,
set_count,
drives_per_set,
endpoints: Endpoints::from(endpoints),
cmd_line: format!("{cmd_line}-pool-{pool_index}"),
platform: "test".to_string(),
});
}
let endpoint_pools = EndpointServerPools(pools);
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
crate::store::init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
.await
.expect("register local disks into the fresh context");
let store = crate::store::ECStore::new_with_instance_ctx(
"127.0.0.1:0".parse().expect("test address"),
endpoint_pools,
shutdown.clone(),
instance_ctx.clone(),
)
.await
.expect("store should build around the fresh context");
// Capacity admission in these local fixtures must not depend on the
// host volume's statvfs values. Keep enough identical snapshots for
// startup, recovery, and the mutation probes exercised by each test.
let layout = DecommissionErasureLayout { data: 2, parity: 2 };
let snapshot: Vec<DecommissionPoolCapacityInfo> = store
.pools
.iter()
.enumerate()
.map(|(pool_index, _)| DecommissionPoolCapacityInfo::for_test(pool_index, layout, 1 << 40, 1 << 40, 1 << 30))
.collect();
set_decommission_capacity_info_overrides_for_test(store.id, (0..128).map(|_| snapshot.clone()).collect());
(instance_ctx, store, shutdown)
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn cross_key_materialized_copy_does_not_publish_remote_tuple_ownership() {
let temp_dir = tempfile::tempdir().expect("create materialized-copy store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "copy-materialized", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = format!("copy-materialized-{}", Uuid::new_v4());
let source_object = "source-restored.bin";
let target_object = "target-local.bin";
let payload = b"materialized copy must own only its new local bytes".to_vec();
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("copy test bucket should be created");
let tier_name = "COPY-MATERIALIZED-TIER";
register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("mock tier generation should be available");
let backend_identity = lease.backend_identity();
drop(lease);
let mut source_metadata = HashMap::from([
("content-type".to_string(), "application/octet-stream".to_string()),
(
"x-amz-restore".to_string(),
"ongoing-request=\"false\", expiry-date=\"2099-01-01T00:00:00Z\"".to_string(),
),
]);
for (suffix, value) in [
(
rustfs_utils::http::SUFFIX_TRANSITION_STATUS,
crate::bucket::lifecycle::core::TRANSITION_COMPLETE.to_string(),
),
(
rustfs_utils::http::SUFFIX_TRANSITIONED_OBJECTNAME,
"remote/source-restored.bin".to_string(),
),
(rustfs_utils::http::SUFFIX_TRANSITION_TIER, tier_name.to_string()),
(rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID, "remote-source-version".to_string()),
(
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_STATE,
rustfs_filemeta::TransitionVersionState::Exact.as_str().to_string(),
),
(
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
rustfs_utils::crypto::hex(backend_identity),
),
(rustfs_utils::http::SUFFIX_TRANSITION_TRANSACTION_ID, Uuid::new_v4().to_string()),
] {
rustfs_utils::http::metadata_compat::insert_str(&mut source_metadata, suffix, value);
}
let mut source_body = PutObjReader::from_vec(payload.clone());
store
.put_object(
&bucket,
source_object,
&mut source_body,
&ObjectOptions {
user_defined: source_metadata,
..Default::default()
},
)
.await
.expect("restored transitioned source should be written");
let source_opts = ObjectOptions::default();
let mut source_info = store
.get_object_info(&bucket, source_object, &source_opts)
.await
.expect("source metadata should be readable");
assert_eq!(
source_info.transitioned_object.status,
crate::bucket::lifecycle::core::TRANSITION_COMPLETE
);
assert_eq!(source_info.transitioned_object.tier, tier_name);
assert!(source_info.data_dir.is_some(), "restored source must retain local data");
source_info.put_object_reader = Some(PutObjReader::from_vec(payload.clone()));
let copied = store
.copy_object(
&bucket,
source_object,
&bucket,
target_object,
&mut source_info,
&source_opts,
&ObjectOptions::default(),
)
.await
.expect("cross-key materialized copy should succeed");
assert!(copied.transitioned_object.status.is_empty());
assert!(copied.transitioned_object.name.is_empty());
assert!(copied.transitioned_object.version_id.is_empty());
assert!(copied.transitioned_object.tier.is_empty());
assert!(!copied.transitioned_object.free_version);
for suffix in [
rustfs_utils::http::SUFFIX_TRANSITION_STATUS,
rustfs_utils::http::SUFFIX_TRANSITIONED_OBJECTNAME,
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID,
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_STATE,
rustfs_utils::http::SUFFIX_TRANSITION_TIER,
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
rustfs_utils::http::SUFFIX_TRANSITION_TRANSACTION_ID,
] {
assert!(
!rustfs_utils::http::metadata_compat::contains_key_str(copied.user_defined.as_ref(), suffix),
"target retained protected source suffix {suffix}"
);
}
assert_eq!(
copied.user_defined.get("content-type").map(String::as_str),
Some("application/octet-stream")
);
let mut target_reader = store
.get_object_reader(&bucket, target_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("target object should be readable");
let mut target_body = Vec::new();
target_reader
.stream
.read_to_end(&mut target_body)
.await
.expect("target body should stream");
assert_eq!(target_body, payload);
let source_after = store
.get_object_info(&bucket, source_object, &source_opts)
.await
.expect("source metadata should remain readable after copy");
assert_eq!(
source_after.transitioned_object.status,
crate::bucket::lifecycle::core::TRANSITION_COMPLETE
);
assert_eq!(source_after.transitioned_object.tier, tier_name);
assert_eq!(source_after.transitioned_object.name, "remote/source-restored.bin");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn copy_object_immediately_reads_small_completed_multipart_source() {
let temp_dir = tempfile::tempdir().expect("create small multipart copy store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "small-multipart-copy", &[1])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = format!("small-multipart-copy-{}", Uuid::new_v4());
let source_object = "docker/registry/v2/repositories/example/_uploads/upload-id/data";
let target_object = "docker/registry/v2/blobs/sha256/c0/digest/data";
let payload = vec![0xAB; 273];
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket for small multipart copy");
let upload = store
.new_multipart_upload(&bucket, source_object, &ObjectOptions::default())
.await
.expect("create source multipart upload");
let mut part_reader = PutObjReader::from_vec(payload.clone());
let part = store
.put_object_part(&bucket, source_object, &upload.upload_id, 1, &mut part_reader, &ObjectOptions::default())
.await
.expect("stage small multipart source part");
let completed = store
.clone()
.complete_multipart_upload(
&bucket,
source_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 the small multipart source");
assert_eq!(completed.get_actual_size().expect("completed object logical size"), payload.len() as i64);
let source_reader = store
.get_object_reader(&bucket, source_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("completed multipart source should be immediately readable");
let mut copy_info = source_reader.object_info.clone();
let actual_size = copy_info.get_actual_size().expect("copy source logical size should resolve");
assert_eq!(actual_size, payload.len() as i64);
let copy_reader = rustfs_rio::HashReader::from_stream(source_reader.stream, actual_size, actual_size, None, None, false)
.expect("copy source hash reader should build");
copy_info.put_object_reader = Some(PutObjReader::new(copy_reader));
store
.copy_object(
&bucket,
source_object,
&bucket,
target_object,
&mut copy_info,
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect("CopyObject should accept a freshly completed multipart source");
let mut target_reader = store
.get_object_reader(&bucket, target_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("copied target should be readable");
let mut target_body = Vec::new();
target_reader
.stream
.read_to_end(&mut target_body)
.await
.expect("target body should stream");
assert_eq!(target_body, payload);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn complete_multipart_waits_for_tail_rename_before_copy_source_visibility() {
let temp_dir = tempfile::tempdir().expect("create early-ack multipart copy store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "early-ack-multipart-copy", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = format!("early-ack-multipart-copy-{}", Uuid::new_v4());
let source_object = "docker/registry/v2/repositories/example/_uploads/upload-id/data";
let target_object = "docker/registry/v2/blobs/sha256/c0/digest/data";
let payload = vec![0xCD; 273];
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket for early-ack multipart copy");
let upload = store
.new_multipart_upload(&bucket, source_object, &ObjectOptions::default())
.await
.expect("create source multipart upload");
let mut part_reader = PutObjReader::from_vec(payload.clone());
let part = store
.put_object_part(&bucket, source_object, &upload.upload_id, 1, &mut part_reader, &ObjectOptions::default())
.await
.expect("stage small multipart source part");
let completed_parts = vec![crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}];
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
let rename_tasks = crate::set_disk::rename_fanout_barrier::observe_tasks(source_object);
let rename_barrier = crate::set_disk::rename_fanout_barrier::arm(
source_object,
0,
crate::set_disk::rename_fanout_barrier::PHASE_RENAME,
);
let complete_store = Arc::clone(&store);
let complete_bucket = bucket.clone();
let complete_upload_id = upload.upload_id.clone();
let mut complete = tokio::spawn(async move {
complete_store
.complete_multipart_upload(
&complete_bucket,
source_object,
&complete_upload_id,
completed_parts,
&ObjectOptions::default(),
)
.await
});
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
.await
.expect("multipart completion should pause one tail disk during rename");
assert!(
rename_tasks.running() >= 1,
"the paused multipart tail disk must remain in flight before completion returns"
);
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut complete).await.is_err(),
"CompleteMultipartUpload must not return while a copy source rename tail is still pending"
);
rename_barrier.release();
complete
.await
.expect("multipart completion task should join")
.expect("multipart completion should return after every rename tail finishes");
let source_reader = store
.get_object_reader(&bucket, source_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("completed multipart source should be immediately readable after success");
let mut copy_info = source_reader.object_info.clone();
let actual_size = copy_info.get_actual_size().expect("copy source logical size should resolve");
assert_eq!(actual_size, payload.len() as i64);
let copy_reader =
rustfs_rio::HashReader::from_stream(source_reader.stream, actual_size, actual_size, None, None, false)
.expect("copy source hash reader should build");
copy_info.put_object_reader = Some(PutObjReader::new(copy_reader));
store
.copy_object(
&bucket,
source_object,
&bucket,
target_object,
&mut copy_info,
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect("CopyObject should accept a freshly completed multipart source");
})
.await;
let mut target_reader = store
.get_object_reader(&bucket, target_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("copied target should be readable after tail release");
let mut target_body = Vec::new();
target_reader
.stream
.read_to_end(&mut target_body)
.await
.expect("target body should stream");
assert_eq!(target_body, payload);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn data_movement_multipart_part_staging_holds_no_publication_lock_or_tier_lease() {
run_large_stack_async_test(
"multipart-part-staging-publication-fence",
data_movement_multipart_part_staging_holds_no_publication_lock_or_tier_lease_case,
);
}
#[cfg(feature = "test-util")]
async fn data_movement_multipart_part_staging_holds_no_publication_lock_or_tier_lease_case() {
let temp_dir = tempfile::tempdir().expect("create multipart staging-fence store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "mpu-staging-publication", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = format!("mpu-staging-publication-{}", Uuid::new_v4());
let object = "remote-owned.bin";
let payload = b"multipart part staging must not publish an object".to_vec();
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("multipart staging test bucket should be created");
let tier_name = "MPU-STAGING-TIER";
register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("mock tier generation should be available");
let backend_identity = lease.backend_identity();
drop(lease);
let mut source_metadata = HashMap::from([(
"x-amz-restore".to_string(),
"ongoing-request=\"false\", expiry-date=\"2099-01-01T00:00:00Z\"".to_string(),
)]);
for (suffix, value) in [
(
rustfs_utils::http::SUFFIX_TRANSITION_STATUS,
crate::bucket::lifecycle::core::TRANSITION_COMPLETE.to_string(),
),
(rustfs_utils::http::SUFFIX_TRANSITIONED_OBJECTNAME, "remote/mpu.bin".to_string()),
(rustfs_utils::http::SUFFIX_TRANSITION_TIER, tier_name.to_string()),
(rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_ID, "remote-mpu-version".to_string()),
(
rustfs_utils::http::SUFFIX_TRANSITIONED_VERSION_STATE,
rustfs_filemeta::TransitionVersionState::Exact.as_str().to_string(),
),
(
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
rustfs_utils::crypto::hex(backend_identity),
),
] {
rustfs_utils::http::metadata_compat::insert_str(&mut source_metadata, suffix, value);
}
let mut source_body = PutObjReader::from_vec(payload.clone());
store.pools[0]
.put_object(
&bucket,
object,
&mut source_body,
&ObjectOptions {
user_defined: source_metadata,
..Default::default()
},
)
.await
.expect("remote-owned source should be written to the source pool");
let source = store.pools[0]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("remote-owned source metadata should be readable");
let publication = store
.acquire_remote_tuple_publication_fence(&bucket, 0, &source, true)
.await
.expect("multipart migration should capture its publication capability");
assert_eq!(
TierConfigMgr::active_operation_lease_count(&ctx.tier_config_mgr(), tier_name).await,
0,
"capturing the commit-late capability must not pin the tier generation"
);
let bucket_incarnation_id = store
.bucket_incarnation_id(&bucket)
.await
.expect("bucket incarnation should resolve");
mark_test_pool_decommissioning(&store, 0).await;
let capacity_owner = test_decommission_capacity_owner(store.as_ref(), 0)
.await
.with_mutation_id(Uuid::new_v4());
let upload_metadata = source.user_defined.as_ref().clone();
let mut staging_opts = ObjectOptions {
data_movement: true,
src_pool_idx: 0,
versioned: source.version_id.is_some(),
version_id: source.version_id.map(|version_id| version_id.to_string()),
mod_time: source.mod_time,
user_defined: upload_metadata,
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
..Default::default()
};
let upload_identity = crate::data_movement::data_movement_upload_identity_from_options(&staging_opts);
rustfs_utils::http::insert_str(
&mut staging_opts.user_defined,
rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD,
upload_identity,
);
capacity_owner.apply_to(&mut staging_opts);
let (upload, target_pool_idx, staged_incarnation_id) = store
.handle_new_multipart_upload_with_pool_idx(&bucket, object, &staging_opts, None)
.await
.expect("target multipart upload should be staged");
assert_eq!(target_pool_idx, 1, "the active pool must receive the staged upload");
assert_eq!(staged_incarnation_id, Some(bucket_incarnation_id));
let barrier = crate::set_disk::MultipartCommitBarrier::install(
&bucket,
object,
crate::set_disk::MultipartCommitPause::PutPartBeforeLockLost,
);
let part_store = Arc::clone(&store);
let part_bucket = bucket.clone();
let upload_id = upload.upload_id.clone();
let part_payload = payload.clone();
let part = tokio::spawn(async move {
let mut reader = PutObjReader::from_vec(part_payload);
let mut part_opts = ObjectOptions {
data_movement: true,
src_pool_idx: 0,
part_number: Some(1),
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
..Default::default()
};
capacity_owner.apply_to(&mut part_opts);
part_store
.put_object_part_for_data_movement(1, &part_bucket, object, &upload_id, &mut reader, &part_opts)
.await
});
barrier.wait_until_paused().await;
assert_eq!(
TierConfigMgr::active_operation_lease_count(&ctx.tier_config_mgr(), tier_name).await,
0,
"UploadPart staging must not pin the tier generation"
);
let encoded = rustfs_utils::path::encode_dir_object(object);
let mut proof_opts = ObjectOptions::default();
let proof = tokio::time::timeout(
Duration::from_millis(250),
store.acquire_all_physical_object_read_locks("tier_delete_journal_recovery", &bucket, &encoded, &mut proof_opts),
)
.await
.expect("UploadPart staging must not hold any object publication write lock")
.expect("the all-physical recovery proof should acquire every object namespace");
let source_head = store.pools[0]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("source HEAD should remain available while the part commit is paused");
assert_eq!(source_head.etag, source.etag);
assert!(
store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.is_err(),
"UploadPart staging must not publish target object metadata"
);
drop(proof);
barrier.release();
drop(barrier);
part.await
.expect("UploadPart staging task should join")
.expect("UploadPart staging should commit after the observation releases");
let mut abort_opts = ObjectOptions {
data_movement: true,
src_pool_idx: 0,
versioned: source.version_id.is_some(),
version_id: source.version_id.map(|version_id| version_id.to_string()),
mod_time: source.mod_time,
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
..Default::default()
};
capacity_owner.apply_to(&mut abort_opts);
store
.abort_multipart_upload_for_data_movement(1, &bucket, object, &upload.upload_id, &abort_opts)
.await
.expect("staged target upload should be removable without publishing");
drop(publication);
assert_eq!(TierConfigMgr::active_operation_lease_count(&ctx.tier_config_mgr(), tier_name).await, 0);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
struct OfflineTestDisks {
disks: Vec<crate::disk::DiskStore>,
}
#[cfg(feature = "test-util")]
impl Drop for OfflineTestDisks {
fn drop(&mut self) {
for disk in &self.disks {
disk.reset_health_for_store_init_retry();
}
}
}
#[cfg(feature = "test-util")]
async fn force_set_disks_offline_for_test(set: &Arc<crate::set_disk::SetDisks>) -> OfflineTestDisks {
let disks = set
.disks
.read()
.await
.iter()
.map(|disk| disk.clone().expect("fault-injection set should start fully online"))
.collect::<Vec<_>>();
for disk in &disks {
disk.close().await.expect("fault injection should stop per-disk monitoring");
disk.force_runtime_state_for_test(crate::disk::health_state::RuntimeDriveHealthState::Offline);
}
// Sets has an independent endpoint monitor that renews missing slots.
// Keep each slot populated by the original, deliberately faulty handle
// and prove an explicit reconnect pass cannot heal this test fault.
set.connect_disks().await;
let current = set.disks.read().await.clone();
assert_eq!(current.len(), disks.len());
for (slot, expected) in current.iter().zip(&disks) {
let disk = slot.as_ref().expect("offline test slot must remain populated");
assert!(Arc::ptr_eq(disk, expected), "endpoint monitor must not replace an offline test handle");
assert_eq!(disk.runtime_state(), crate::disk::health_state::RuntimeDriveHealthState::Offline);
assert!(
disk.is_online().await,
"the valid disk identity must prevent endpoint renewal while the IO health gate remains offline"
);
}
OfflineTestDisks { disks }
}
fn active_rebalance_meta_for_pool(pool_count: usize, active_pool_idx: usize) -> RebalanceMeta {
let now = OffsetDateTime::now_utc();
let mut pool_stats = vec![RebalanceStats::default(); pool_count];
pool_stats[active_pool_idx] = RebalanceStats {
participating: true,
info: RebalanceInfo {
start_time: Some(now),
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
};
RebalanceMeta {
id: uuid::Uuid::new_v4().to_string(),
pool_stats,
..Default::default()
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn quota_object_fence_ignores_an_unrelated_offline_pool() {
let temp_dir = tempfile::tempdir().expect("create quota fence store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "quota-object-fence", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("quota-object-fence-{}", uuid::Uuid::new_v4());
let object = "object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create quota fence bucket");
store.pools[1].disk_set[0].disks.write().await.fill(None);
crate::bucket::quota::reservation::fence_namespace_mutations_for_test(&store, &bucket, object, Some((0, 0)))
.await
.expect("the selected pool fence should ignore an unrelated offline pool");
let err = crate::bucket::quota::reservation::fence_namespace_mutations_for_test(&store, &bucket, object, None)
.await
.expect_err("legacy reservations must conservatively fence every pool");
assert!(matches!(err, StorageError::ErasureWriteQuorum));
shutdown.cancel();
}
async fn migrate_versioned_decommission_test_object(
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
payload: &[u8],
op_label: &'static str,
) -> (uuid::Uuid, FileInfoVersions) {
let mut source = PutObjReader::from_vec(payload.to_vec());
let source_info = store.pools[0]
.put_object(
bucket,
object,
&mut source,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write versioned source to the pool being decommissioned");
let source_version = source_info.version_id.expect("versioned source must have a version ID");
let expected_source_versions = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("source versions should be readable before migration")
.expect("source versions should exist before migration");
{
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
}
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
bucket,
object,
crate::set_disk::PutObjectCommitPause::AfterNamespace,
);
let migration_store = Arc::clone(store);
let migration_bucket = bucket.to_string();
let migration_object = object.to_string();
let migration = tokio::spawn(async move {
let source_reader = migration_store.pools[0]
.get_object_reader(
&migration_bucket,
&migration_object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(source_version.to_string()),
no_lock: true,
data_movement: true,
raw_data_movement_read: true,
..Default::default()
},
)
.await?;
crate::data_movement::migrate_decommission_object(
migration_store,
0,
migration_bucket,
source_reader,
None,
op_label,
None,
)
.await
});
barrier.wait_until_paused().await;
barrier.release();
migration
.await
.expect("versioned decommission migration task should join")
.expect("versioned decommission migration should commit");
(source_version, expected_source_versions)
}
fn set_test_decommission_capacity_override(store: &Arc<crate::store::ECStore>, pool_idx: usize) {
let layout = DecommissionErasureLayout { data: 2, parity: 2 };
let source_physical_bytes = 1024 * 1024 * 1024;
let target_physical_bytes = source_physical_bytes * 8;
let capacity: Vec<DecommissionPoolCapacityInfo> = store
.pools
.iter()
.enumerate()
.map(|(index, _)| {
if index == pool_idx {
DecommissionPoolCapacityInfo::for_test(index, layout, 0, source_physical_bytes, source_physical_bytes)
} else {
DecommissionPoolCapacityInfo::for_test(index, layout, target_physical_bytes, target_physical_bytes, 0)
}
})
.collect();
set_decommission_capacity_info_overrides_for_test(store.id, (0..128).map(|_| capacity.clone()).collect());
}
async fn mark_test_pool_decommissioning(store: &Arc<crate::store::ECStore>, pool_idx: usize) {
set_test_decommission_capacity_override(store, pool_idx);
store
.save_current_pool_meta_for_decommission_start(&[pool_idx], Vec::new())
.await
.expect("test decommission capacity reservation should activate");
}
#[cfg(feature = "test-util")]
async fn mark_test_pool_decommissioning_with_split_targets(store: &Arc<crate::store::ECStore>, pool_idx: usize) {
let layout = DecommissionErasureLayout { data: 2, parity: 2 };
let source_physical_bytes = 1024 * 1024 * 1024;
let capacity = store
.pools
.iter()
.enumerate()
.map(|(index, _)| {
if index == pool_idx {
DecommissionPoolCapacityInfo::for_test(index, layout, 0, source_physical_bytes, source_physical_bytes)
} else {
// The reservation peak is twice the predicted target size.
// Give each target only half so the real allocator must
// authorize both receipt-bearing pools.
DecommissionPoolCapacityInfo::for_test(index, layout, source_physical_bytes, source_physical_bytes, 0)
}
})
.collect::<Vec<_>>();
set_decommission_capacity_info_overrides_for_test(store.id, (0..128).map(|_| capacity.clone()).collect());
store
.save_current_pool_meta_for_decommission_start(&[pool_idx], Vec::new())
.await
.expect("split-target decommission capacity reservation should activate");
}
#[cfg(feature = "test-util")]
async fn test_decommission_capacity_owner(
store: &crate::store::ECStore,
source_pool_index: usize,
) -> crate::core::pools::DecommissionCapacityOwner {
let pool_meta = store.pool_meta.read().await;
let reservation = pool_meta.pools[source_pool_index]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("test decommission capacity reservation should exist");
crate::core::pools::DecommissionCapacityOwner {
source_pool_index,
operation_id: reservation.operation_id,
generation: reservation.generation,
owner_nonce: reservation.owner_nonce,
mutation_id: None,
}
}
const DECOMMISSION_TEST_FAULT_STAGE_DELETE_MARKER: &str = "delete_marker_copy";
const DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT: &str = "migrate_object";
#[cfg(feature = "test-util")]
const DECOMMISSION_TEST_FAULT_STAGE_TIERED: &str = "decommission_tiered_object";
fn decommission_retry_fault_hook(
bucket: &str,
object: &str,
faults: Arc<AtomicUsize>,
) -> crate::core::pools::DecommissionTestFaultDecision {
let target_bucket = bucket.to_string();
let target_object = object.to_string();
Arc::new(move |stage, bucket, object, _attempt, succeeded| {
if !succeeded
|| stage != DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT
|| bucket != target_bucket
|| object != target_object
{
return false;
}
// Entry retries reset the local attempt; real copy errors can skip
// successful attempts. Only injected faults spend this global budget.
faults
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1))
.then_some(faults.saturating_add(1))
})
.is_ok()
})
}
async fn seed_decommission_source(
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
body: Vec<u8>,
opts: &ObjectOptions,
) {
let mut reader = PutObjReader::from_vec(body);
store.pools[0]
.put_object(bucket, object, &mut reader, opts)
.await
.expect("seed decommission source object");
}
async fn run_decommission_entry_retry_test(
store: &Arc<crate::store::ECStore>,
rx: CancellationToken,
bucket: &str,
object: &str,
expected_bucket_incarnation_id: Option<uuid::Uuid>,
source_changed_exhaustions: Arc<AtomicUsize>,
) -> crate::error::Result<()> {
let source_set = store.pools[0].get_disks_by_key(object);
store
.decommission_entry_with_retry_state_for_test(
rx,
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.to_string(),
source_set,
expected_bucket_incarnation_id,
source_changed_exhaustions,
)
.await
}
async fn read_decommission_target_body(
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
opts: &ObjectOptions,
) -> Vec<u8> {
let mut reader = store.pools[1]
.get_object_reader(bucket, object, None, HeaderMap::new(), opts)
.await
.expect("read decommission target object");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("drain decommission target body");
body
}
async fn assert_decommission_source_absent(
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
opts: &ObjectOptions,
) {
let err = store.pools[0]
.get_object_info(bucket, object, opts)
.await
.expect_err("decommission source must be retained until target commit, then removed");
assert!(
matches!(err, StorageError::ObjectNotFound(_, _) | StorageError::VersionNotFound(_, _, _)),
"unexpected decommission source result: {err:?}"
);
}
#[cfg(feature = "test-util")]
async fn seed_transitioned_free_version(
ctx: &Arc<crate::runtime::instance::InstanceContext>,
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
) -> (uuid::Uuid, uuid::Uuid) {
let tier_name = format!("DECOMFREE{}", uuid::Uuid::new_v4().simple());
register_mock_tier(&ctx.tier_config_mgr(), &tier_name).await;
let mut reader = PutObjReader::from_vec(b"transitioned source bytes".to_vec());
let source = store.pools[0]
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write transitioned decommission source");
let source_version = source.version_id.expect("transitioned source must be versioned");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(source_version.to_string()),
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: source.etag.clone().expect("transitioned source must have an ETag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("transition source before decommission");
store.pools[0]
.delete_object(
bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(source_version.to_string()),
..Default::default()
},
)
.await
.expect("delete transitioned source version");
let versions = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("source versions should decode after transition delete")
.expect("source free version should remain after transition delete");
let free_version = versions
.versions
.iter()
.find(|version| version.tier_free_version())
.and_then(|version| version.version_id)
.expect("transition delete should create a free version");
(source_version, free_version)
}
async fn write_decommission_test_multipart_source(
store: &Arc<crate::store::ECStore>,
pool_idx: usize,
bucket: &str,
object: &str,
) {
let pool = &store.pools[pool_idx];
let upload = pool
.new_multipart_upload(bucket, object, &ObjectOptions::default())
.await
.expect("create decommission multipart source upload");
let first_part = vec![b'm'; 5 * 1024 * 1024];
let second_part = b"decommission multipart tail".to_vec();
let mut completed_parts = Vec::with_capacity(2);
for (part_number, body) in [(1, first_part), (2, second_part)] {
let mut reader = PutObjReader::from_vec(body);
let part = pool
.put_object_part(bucket, object, &upload.upload_id, part_number, &mut reader, &ObjectOptions::default())
.await
.expect("write decommission multipart source part");
completed_parts.push(crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
});
}
pool.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, completed_parts, &ObjectOptions::default())
.await
.expect("complete decommission multipart source object");
}
struct DataMovementSourceSeed {
pool_idx: usize,
version_id: Option<Uuid>,
body: Vec<u8>,
multipart_split: Option<usize>,
mod_time: OffsetDateTime,
user_defined: HashMap<String, String>,
}
async fn seed_data_movement_source_reader(
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
seed: DataMovementSourceSeed,
) -> GetObjectReader {
let DataMovementSourceSeed {
pool_idx,
version_id,
body,
multipart_split,
mod_time,
user_defined,
} = seed;
let pool = &store.pools[pool_idx];
let write_opts = ObjectOptions {
versioned: version_id.is_some(),
version_id: version_id.map(|version_id| version_id.to_string()),
mod_time: Some(mod_time),
user_defined,
..Default::default()
};
if let Some(split) = multipart_split {
assert!(split > 0 && split < body.len(), "multipart source split must create two non-empty parts");
let upload = pool
.new_multipart_upload(bucket, object, &write_opts)
.await
.expect("create physical data-movement source upload");
let mut completed_parts = Vec::with_capacity(2);
for (part_number, bytes) in [(1, &body[..split]), (2, &body[split..])] {
let mut reader = PutObjReader::from_vec(bytes.to_vec());
let part = pool
.put_object_part(bucket, object, &upload.upload_id, part_number, &mut reader, &ObjectOptions::default())
.await
.expect("write physical data-movement source part");
completed_parts.push(crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
});
}
pool.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, completed_parts, &write_opts)
.await
.expect("complete physical data-movement source object");
} else {
let mut reader = PutObjReader::from_vec(body);
pool.put_object(bucket, object, &mut reader, &write_opts)
.await
.expect("write physical data-movement source object");
}
pool.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: version_id.is_some(),
version_id: version_id.map(|version_id| version_id.to_string()),
raw_data_movement_read: true,
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("read the physical data-movement source snapshot")
}
async fn assert_pool_object_present(pool: &Arc<crate::core::sets::Sets>, bucket: &str, object: &str) {
pool.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("expected object generation must remain present");
}
async fn assert_pool_object_absent(pool: &Arc<crate::core::sets::Sets>, bucket: &str, object: &str) {
let err = pool
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect_err("fenced decommission target must remain absent");
assert!(
matches!(err, StorageError::ObjectNotFound(_, _) | StorageError::VersionNotFound(_, _, _)),
"unexpected fenced target result: {err:?}"
);
}
async fn write_suspended_decommission_source(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
let mut reader = PutObjReader::from_vec(b"suspended source generation".to_vec());
let source = store.pools[0]
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
version_suspended: true,
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND),
..Default::default()
},
)
.await
.expect("write suspended null source version");
assert!(
source.version_id.is_none_or(|version_id| version_id.is_nil()),
"suspended source must use the null version identity"
);
}
async fn assert_suspended_null_source_present(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
let versions = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("suspended source versions should be readable")
.expect("suspended source must exist before worker convergence");
assert!(
versions
.versions
.iter()
.any(|version| !version.deleted && version.version_id.is_none_or(|version_id| version_id.is_nil())),
"the source pool must retain its null data version while DELETE owns the fixed fence"
);
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tag_updates_skip_active_rebalance_source_pool() {
let temp_dir = tempfile::tempdir().expect("create writer-fencing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "writer-fencing-tags", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("writer-fencing-tags-{}", uuid::Uuid::new_v4());
let object = "tagged-object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create writer fencing bucket");
let old_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed timestamp should be valid");
let newer_time = old_time + time::Duration::seconds(10);
let mut source_reader = PutObjReader::from_vec(b"source-body".to_vec());
store.pools[0]
.put_object(
&bucket,
object,
&mut source_reader,
&ObjectOptions {
mod_time: Some(newer_time),
..Default::default()
},
)
.await
.expect("write newer source object");
let mut target_reader = PutObjReader::from_vec(b"target-body".to_vec());
store.pools[1]
.put_object(
&bucket,
object,
&mut target_reader,
&ObjectOptions {
mod_time: Some(old_time),
..Default::default()
},
)
.await
.expect("write older target object");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let tags = "rebalance=target";
assert_ne!(
store.pools[0]
.get_object_tags(&bucket, object, &ObjectOptions::default())
.await
.expect("source object tags should be readable before update"),
tags,
"source object must start without the target tag"
);
assert_ne!(
store.pools[1]
.get_object_tags(&bucket, object, &ObjectOptions::default())
.await
.expect("target object tags should be readable before update"),
tags,
"target object must start without the target tag"
);
let selected_pool = store
.get_pool_idx_existing_with_opts(
&bucket,
object,
&ObjectOptions {
no_lock: true,
metadata_chg: true,
skip_decommissioned: true,
skip_rebalancing: true,
..Default::default()
},
)
.await
.expect("writer lookup should select an existing non-rebalancing pool");
assert_eq!(selected_pool, 1, "writer lookup must skip active rebalance pool 0");
let updated = store
.put_object_tags(&bucket, object, tags, &ObjectOptions::default())
.await
.expect("tag update should use the non-rebalancing target pool");
assert_eq!(
updated.mod_time,
Some(old_time),
"tag update must return the non-rebalancing pool object rather than the newer active source"
);
let target_tags = store.pools[1]
.get_object_tags(&bucket, object, &ObjectOptions::default())
.await
.expect("target object tags should be readable");
assert_eq!(target_tags, tags, "non-rebalancing pool must receive writer tag updates");
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multipart_listing_skips_active_rebalance_source_pool() {
let temp_dir = tempfile::tempdir().expect("create multipart writer-fencing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "writer-fencing-multipart", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("writer-fencing-multipart-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create multipart writer fencing bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("read bucket incarnation");
let lifecycle_guard = store
.acquire_bucket_lifecycle_read_lock(&bucket)
.await
.expect("acquire multipart test lifecycle fence");
let mut upload_opts = ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
upload_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
let source_upload = store.pools[0]
.new_multipart_upload(&bucket, "source-only.bin", &upload_opts)
.await
.expect("create source upload");
let target_upload = store.pools[1]
.new_multipart_upload(&bucket, "target-visible.bin", &upload_opts)
.await
.expect("create target upload");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let listed = store
.list_multipart_uploads(&bucket, "", None, None, None, 100)
.await
.expect("list multipart uploads");
let listed_uploads: Vec<(&str, &str)> = listed
.uploads
.iter()
.map(|upload| (upload.object.as_str(), upload.upload_id.as_str()))
.collect();
assert!(
!listed_uploads.contains(&("source-only.bin", source_upload.upload_id.as_str())),
"active source pool upload must be hidden from multipart listing"
);
assert!(
listed_uploads.contains(&("target-visible.bin", target_upload.upload_id.as_str())),
"non-rebalancing pool upload must remain visible"
);
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn suspended_decommission_source_multipart_remains_operable_until_drained() {
let temp_dir = tempfile::tempdir().expect("create decommission multipart drain store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-multipart-drain", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decommission-mp-drain-{}", uuid::Uuid::new_v4());
let complete_object = "complete.bin";
let abort_object = "abort.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create decommission multipart drain bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("read bucket incarnation");
let lifecycle_guard = store
.acquire_bucket_lifecycle_read_lock(&bucket)
.await
.expect("acquire multipart creation lifecycle fence");
let mut upload_opts = ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
upload_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
let complete_upload = store.pools[0]
.new_multipart_upload(&bucket, complete_object, &upload_opts)
.await
.expect("create source upload to complete");
let abort_upload = store.pools[0]
.new_multipart_upload(&bucket, abort_object, &upload_opts)
.await
.expect("create source upload to abort");
drop(lifecycle_guard);
mark_test_pool_decommissioning(&store, 0).await;
let err = store
.ensure_decommission_multipart_uploads_drained_for_test(0)
.await
.expect_err("an unresolved source multipart upload must block final decommission");
let drain_error = err.to_string();
assert!(
drain_error.contains("still contains multipart upload") && drain_error.contains(&bucket),
"the drain error must identify both the upload path and user bucket: {drain_error}"
);
let listed = store
.list_multipart_uploads(&bucket, "", None, None, None, 100)
.await
.expect("list uploads from suspended decommission source");
assert!(
listed
.uploads
.iter()
.any(|upload| upload.upload_id.as_str() == complete_upload.upload_id.as_str()),
"the upload selected before suspension must remain visible"
);
store
.get_multipart_info(&bucket, complete_object, &complete_upload.upload_id, &ObjectOptions::default())
.await
.expect("read upload metadata from suspended decommission source");
let mut part_reader = PutObjReader::from_vec(b"multipart body".to_vec());
let part = store
.put_object_part(
&bucket,
complete_object,
&complete_upload.upload_id,
1,
&mut part_reader,
&ObjectOptions::default(),
)
.await
.expect("write part to suspended decommission source");
let parts = store
.list_object_parts(&bucket, complete_object, &complete_upload.upload_id, None, 100, &ObjectOptions::default())
.await
.expect("list parts from suspended decommission source");
assert_eq!(parts.parts.len(), 1);
assert_eq!(parts.parts[0].etag.as_deref(), part.etag.as_deref());
store
.clone()
.complete_multipart_upload(
&bucket,
complete_object,
&complete_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 upload on suspended decommission source");
store
.abort_multipart_upload(&bucket, abort_object, &abort_upload.upload_id, &ObjectOptions::default())
.await
.expect("abort upload on suspended decommission source");
tokio::time::timeout(Duration::from_secs(5), async {
loop {
match store.ensure_decommission_multipart_uploads_drained_for_test(0).await {
Ok(()) => break,
Err(err) if err.to_string().contains("still contains multipart upload") => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(err) => panic!("unexpected final decommission drain error: {err:?}"),
}
}
})
.await
.expect("final decommission gate should open after upload cleanup converges");
assert_pool_object_present(&store.pools[0], &bucket, complete_object).await;
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn active_multipart_upload_routes_before_faulted_suspended_source() {
use sha2::Digest;
let temp_dir = tempfile::tempdir().expect("create active-first multipart routing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "active-first-multipart-routing", &[4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("active-first-mp-route-{}", uuid::Uuid::new_v4());
let object = "target-upload.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create active-first multipart routing bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("read bucket incarnation");
let lifecycle_guard = store
.acquire_bucket_lifecycle_read_lock(&bucket)
.await
.expect("acquire multipart creation lifecycle fence");
let mut upload_opts = ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
upload_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
let upload = store.pools[1]
.new_multipart_upload(&bucket, object, &upload_opts)
.await
.expect("create upload in active target pool");
drop(lifecycle_guard);
mark_test_pool_decommissioning(&store, 0).await;
// Corrupt only the source pool's entry for this UploadID. Taking the
// whole source set offline would also make the bucket-incarnation
// sidecar unreadable before multipart routing is reached.
let upload_sha =
hex_simd::encode_to_string(sha2::Sha256::digest(format!("{bucket}/{object}").as_bytes()), hex_simd::AsciiCase::Lower);
let upload_uuid = crate::runtime::sources::upload_uuid_suffix(&upload.upload_id);
for disk_index in 0..4 {
let metadata_path = temp_dir
.path()
.join(format!("pool0/set0/disk{disk_index}"))
.join(crate::disk::RUSTFS_META_MULTIPART_BUCKET)
.join(&upload_sha)
.join(&upload_uuid)
.join(crate::disk::STORAGE_FORMAT_FILE);
tokio::fs::create_dir_all(metadata_path.parent().expect("multipart metadata path should have a parent"))
.await
.expect("create corrupt source upload directory");
tokio::fs::write(metadata_path, b"not-xl-meta")
.await
.expect("inject source upload metadata read failure");
}
let source_result = store.pools[0]
.get_multipart_info(&bucket, object, &upload.upload_id, &ObjectOptions::default())
.await;
let routed_result = store
.get_multipart_info(&bucket, object, &upload.upload_id, &ObjectOptions::default())
.await;
assert!(
source_result
.as_ref()
.is_err_and(|err| !crate::error::is_err_invalid_upload_id(err)),
"the suspended source must expose the injected hard read failure: {source_result:?}"
);
let routed = routed_result.expect("the active target UploadID must be resolved before the faulted suspended source");
assert_eq!(routed.upload_id, upload.upload_id);
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_objects_skips_active_rebalance_source_pool() {
let temp_dir = tempfile::tempdir().expect("create batch-delete writer-fencing store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-delete-rebalance", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("batch-delete-rebalance-{}", uuid::Uuid::new_v4());
let object = "delete-me.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create batch delete rebalance bucket");
let mut source_reader = PutObjReader::from_vec(b"source-body".to_vec());
store.pools[0]
.put_object(&bucket, object, &mut source_reader, &ObjectOptions::default())
.await
.expect("write object on active source pool");
let mut target_reader = PutObjReader::from_vec(b"target-body".to_vec());
store.pools[1]
.put_object(&bucket, object, &mut target_reader, &ObjectOptions::default())
.await
.expect("write object on non-rebalancing target pool");
let mut pool_stats = vec![RebalanceStats::default(); store.pools.len()];
pool_stats[0] = RebalanceStats {
participating: true,
info: RebalanceInfo {
start_time: Some(OffsetDateTime::now_utc()),
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
};
*store.rebalance_meta.write().await = Some(RebalanceMeta {
id: uuid::Uuid::new_v4().to_string(),
pool_stats,
..Default::default()
});
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let (deleted, errs) = store
.delete_objects(
&bucket,
vec![crate::storage_api_contracts::object::ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(matches!(errs.as_slice(), [None]), "batch delete must not fail: {errs:?}");
assert!(
matches!(deleted.as_slice(), [deleted] if deleted.found && deleted.object_name == object),
"batch delete must report the non-rebalancing pool deletion"
);
store.pools[0]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("active source pool object must not be deleted by DeleteObjects");
let target_err = store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("non-rebalancing target pool object must be deleted");
assert!(
matches!(target_err, StorageError::ObjectNotFound(_, _)),
"target pool should report object not found after DeleteObjects, got {target_err:?}"
);
shutdown.cancel();
}
#[test]
#[serial_test::serial(storage_class_env)]
fn data_movement_conflicts_and_multipart_retries_converge_safely() {
// Run this large async scenario on a larger dedicated stack so debug
// test threads do not overflow before the regression assertions.
std::thread::Builder::new()
.stack_size(16 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("build data movement test runtime");
runtime.block_on(async move {
let temp_dir = tempfile::tempdir().expect("create data movement store dir");
let (_ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"data-movement-conflict-convergence",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("data-movement-conflict-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create data movement bucket");
let source_mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
let target_mod_time = source_mod_time + time::Duration::SECOND;
let object = "single-object";
let target_body = b"newer client body".to_vec();
let mut target_reader = PutObjReader::from_vec(target_body.clone());
store.pools[1]
.put_object(
&bucket,
object,
&mut target_reader,
&ObjectOptions {
mod_time: Some(target_mod_time),
..Default::default()
},
)
.await
.expect("write newer single-part target");
let source_body = b"stale migration body".to_vec();
let source_reader = seed_data_movement_source_reader(
&store,
&bucket,
object,
DataMovementSourceSeed {
pool_idx: 0,
version_id: None,
body: source_body,
multipart_split: None,
mod_time: source_mod_time,
user_defined: HashMap::new(),
},
)
.await;
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader,
None,
"test_data_movement",
)
.await
.expect("newer single-part target should converge migration");
let mut reader = store.pools[1]
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read converged single-part target");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("drain single-part target");
assert_eq!(body, target_body);
let multipart_object = "multipart-object";
let multipart_target_body = b"newer multipart client body".to_vec();
let mut multipart_target_reader = PutObjReader::from_vec(multipart_target_body.clone());
store.pools[1]
.put_object(
&bucket,
multipart_object,
&mut multipart_target_reader,
&ObjectOptions {
mod_time: Some(target_mod_time),
..Default::default()
},
)
.await
.expect("write newer multipart target");
let first_part_size = 5 * 1024 * 1024;
let mut multipart_source_body = vec![b'a'; first_part_size];
multipart_source_body.push(b'b');
let multipart_source_reader = seed_data_movement_source_reader(
&store,
&bucket,
multipart_object,
DataMovementSourceSeed {
pool_idx: 0,
version_id: None,
body: multipart_source_body,
multipart_split: Some(first_part_size),
mod_time: source_mod_time,
user_defined: HashMap::new(),
},
)
.await;
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
multipart_source_reader,
None,
"test_data_movement",
)
.await
.expect("newer multipart target should converge migration");
let uploads = store.pools[1]
.list_multipart_uploads(&bucket, multipart_object, None, None, None, 100)
.await
.expect("list target pool multipart uploads");
assert!(uploads.uploads.is_empty(), "superseded migration staging must be aborted");
let mut reader = store.pools[1]
.get_object_reader(&bucket, multipart_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read converged multipart target");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("drain multipart target");
assert_eq!(body, multipart_target_body);
let retry_object = "multipart-retry-object";
let retry_object_etag = "0123456789abcdef0123456789abcdef".to_string();
let retry_first_part_size = 5 * 1024 * 1024;
let retry_object_mod_time =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed retry timestamp should be valid");
let mut retry_source_body = vec![b'c'; retry_first_part_size];
retry_source_body.push(b'd');
let retry_first_checksum =
Checksum::new_from_data(ChecksumType::CRC32C, &retry_source_body[..retry_first_part_size])
.expect("first source part checksum should be created");
let retry_second_checksum =
Checksum::new_from_data(ChecksumType::CRC32C, &retry_source_body[retry_first_part_size..])
.expect("second source part checksum should be created");
let mut retry_checksum_parts = retry_first_checksum.raw.clone();
retry_checksum_parts.extend_from_slice(&retry_second_checksum.raw);
let mut retry_checksum_type = ChecksumType::CRC32C;
retry_checksum_type
.merge(ChecksumType::MULTIPART)
.merge(ChecksumType::INCLUDES_MULTIPART);
let retry_object_checksum = Checksum::new_from_data(retry_checksum_type, &retry_checksum_parts)
.expect("source multipart checksum should be created");
let retry_object_checksum_bytes = retry_object_checksum.to_bytes(&retry_checksum_parts);
let mut retry_metadata = HashMap::from([("x-amz-meta-retry".to_string(), "stable".to_string())]);
insert_str(
&mut retry_metadata,
SUFFIX_COMPRESSION,
compression_metadata_value(CompressionAlgorithm::default()),
);
let retry_upload = store.pools[0]
.new_multipart_upload(
&bucket,
retry_object,
&ObjectOptions {
user_defined: retry_metadata,
want_checksum: Some(retry_object_checksum.clone()),
..Default::default()
},
)
.await
.expect("create source multipart upload");
let mut retry_parts = Vec::with_capacity(2);
for (part_number, plaintext, checksum) in [
(1, &retry_source_body[..retry_first_part_size], &retry_first_checksum),
(2, &retry_source_body[retry_first_part_size..], &retry_second_checksum),
] {
let part_size = i64::try_from(plaintext.len()).expect("source part size should fit i64");
let mut plaintext_reader = rustfs_rio::HashReader::from_stream(
Cursor::new(plaintext.to_vec()),
part_size,
part_size,
None,
None,
false,
)
.expect("create source part plaintext reader");
plaintext_reader
.add_non_trailing_checksum(Some(checksum.clone()), false)
.expect("set source part checksum");
let compressed = WritePlan::new()
.with_compression(CompressionAlgorithm::default())
.apply(plaintext_reader, part_size)
.expect("compress source part");
let mut reader = PutObjReader::new(compressed);
let part = store.pools[0]
.put_object_part(
&bucket,
retry_object,
&retry_upload.upload_id,
part_number,
&mut reader,
&ObjectOptions::default(),
)
.await
.expect("write source multipart part");
retry_parts.push(crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
checksum_crc32c: Some(checksum.encoded.clone()),
..Default::default()
});
}
store.pools[0]
.clone()
.complete_multipart_upload(
&bucket,
retry_object,
&retry_upload.upload_id,
retry_parts,
&ObjectOptions {
mod_time: Some(retry_object_mod_time),
want_checksum: Some(retry_object_checksum),
preserve_etag: Some(retry_object_etag.clone()),
..Default::default()
},
)
.await
.expect("complete source multipart object");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
assert!(store.is_pool_rebalancing(0).await, "pool 0 must be marked as an active rebalance source");
let mut retry_reader = store.pools[0]
.get_object_reader(
&bucket,
retry_object,
None,
HeaderMap::new(),
&ObjectOptions {
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read source multipart object for first migration");
let mut retry_source_info = retry_reader.object_info.clone();
let mut retry_source_parts = retry_source_info.parts.as_ref().clone();
for (part, checksum) in retry_source_parts
.iter_mut()
.zip([&retry_first_checksum, &retry_second_checksum])
{
part.mod_time = Some(OffsetDateTime::UNIX_EPOCH);
part.checksums = Some(HashMap::from([(ChecksumType::CRC32C.to_string(), checksum.encoded.clone())]));
}
retry_source_info.parts = Arc::new(retry_source_parts);
assert_eq!(retry_source_info.etag.as_deref(), Some(retry_object_etag.as_str()));
assert!(!retry_source_info.is_multipart());
assert!(retry_source_info.parts.iter().all(|part| part.checksums.is_some()));
assert_eq!(retry_source_info.checksum.as_deref(), Some(retry_object_checksum_bytes.as_ref()));
assert!(
!retry_source_info
.user_defined
.contains_key(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM)
);
assert!(
!retry_source_info
.user_defined
.contains_key(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE)
);
retry_reader.object_info = retry_source_info.clone();
temp_env::async_with_vars(
[
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, None::<&str>),
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, None::<&str>),
],
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
retry_reader,
None,
"test_data_movement_compatible_retry",
),
)
.await
.expect("default multipart migration should not require the checksum sidecar capability");
let compatible_target = store.pools[1]
.get_object_info(
&bucket,
retry_object,
&ObjectOptions {
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("read compatible multipart target metadata");
assert_eq!(compatible_target.checksum, retry_source_info.checksum);
assert!(compatible_target.parts.iter().all(|part| part.checksums.is_none()));
assert_eq!(
rustfs_utils::http::get_consistent_str(
&compatible_target.user_defined,
rustfs_utils::http::SUFFIX_DATA_MOVED
),
Some("true")
);
assert!(!rustfs_utils::http::contains_key_str(
&compatible_target.user_defined,
rustfs_utils::http::SUFFIX_PART_CHECKSUMS
));
let mut compatible_reader = store.pools[1]
.get_object_reader(&bucket, retry_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read compatible multipart target body");
let mut compatible_body = Vec::new();
compatible_reader
.stream
.read_to_end(&mut compatible_body)
.await
.expect("drain compatible multipart target body");
assert_eq!(compatible_body, retry_source_body);
let mut compatible_retry_reader = store.pools[0]
.get_object_reader(
&bucket,
retry_object,
None,
HeaderMap::new(),
&ObjectOptions {
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read source multipart object for compatible retry");
compatible_retry_reader.object_info = retry_source_info.clone();
temp_env::async_with_vars(
[
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, Some("true")),
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, Some("true")),
],
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
compatible_retry_reader,
None,
"test_data_movement_compatible_retry",
),
)
.await
.expect("pre-gate multipart target should converge after enabling checksum persistence");
let compatible_target_after_retry = store.pools[1]
.get_object_info(
&bucket,
retry_object,
&ObjectOptions {
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("read pre-gate target after fleet-confirmed retry");
assert_eq!(compatible_target_after_retry.data_dir, compatible_target.data_dir);
assert_eq!(compatible_target_after_retry.parts, compatible_target.parts);
let compatible_uploads = store.pools[1]
.list_multipart_uploads(&bucket, retry_object, None, None, None, 100)
.await
.expect("list compatible target multipart uploads");
assert!(compatible_uploads.uploads.is_empty(), "compatible retry staging must be aborted");
store.pools[1]
.delete_object(&bucket, retry_object, ObjectOptions::default())
.await
.expect("remove compatible target before fleet-confirmed migration");
let mut retry_reader = store.pools[0]
.get_object_reader(
&bucket,
retry_object,
None,
HeaderMap::new(),
&ObjectOptions {
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read source multipart object for fleet-confirmed migration");
retry_reader.object_info = retry_source_info.clone();
temp_env::async_with_vars(
[
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, Some("true")),
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, Some("true")),
],
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
retry_reader,
None,
"test_data_movement_retry",
),
)
.await
.expect("first multipart migration should succeed");
let target_before_retry = store.pools[1]
.get_object_info(
&bucket,
retry_object,
&ObjectOptions {
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("read target metadata before retry");
assert_eq!(target_before_retry.checksum, retry_source_info.checksum);
assert_eq!(
target_before_retry
.parts
.iter()
.map(|part| (&part.number, &part.checksums))
.collect::<Vec<_>>(),
retry_source_info
.parts
.iter()
.map(|part| (&part.number, &part.checksums))
.collect::<Vec<_>>()
);
assert_eq!(retry_source_info.mod_time, target_before_retry.mod_time);
assert!(
retry_source_info.parts.iter().any(|source_part| {
target_before_retry
.parts
.iter()
.find(|target_part| target_part.number == source_part.number)
.is_some_and(|target_part| target_part.mod_time != source_part.mod_time)
}),
"multipart migration must rewrite at least one target part timestamp"
);
let mut retry_reader = store.pools[0]
.get_object_reader(
&bucket,
retry_object,
None,
HeaderMap::new(),
&ObjectOptions {
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read source multipart object for retry");
retry_reader.object_info = retry_source_info;
temp_env::async_with_vars(
[
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, Some("true")),
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, Some("true")),
],
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
retry_reader,
None,
"test_data_movement_retry",
),
)
.await
.expect("equivalent multipart migration retry should converge");
let target_after_retry = store.pools[1]
.get_object_info(&bucket, retry_object, &ObjectOptions::default())
.await
.expect("read target metadata after retry");
assert_eq!(target_after_retry.data_dir, target_before_retry.data_dir);
assert_eq!(target_after_retry.etag, target_before_retry.etag);
assert_eq!(target_after_retry.checksum, target_before_retry.checksum);
assert_eq!(target_after_retry.mod_time, target_before_retry.mod_time);
assert!(!rustfs_utils::http::contains_key_str(
&target_after_retry.user_defined,
rustfs_utils::http::SUFFIX_PART_CHECKSUMS
));
assert!(target_after_retry.parts.iter().all(|part| part.checksums.is_none()));
let cached_target_after_retry = store.pools[1]
.get_object_info(&bucket, retry_object, &ObjectOptions::default())
.await
.expect("read target metadata from the ordinary cache path");
assert!(!rustfs_utils::http::contains_key_str(
&cached_target_after_retry.user_defined,
rustfs_utils::http::SUFFIX_PART_CHECKSUMS
));
assert!(cached_target_after_retry.parts.iter().all(|part| part.checksums.is_none()));
let hydrated_target_after_retry = store.pools[1]
.get_object_info(
&bucket,
retry_object,
&ObjectOptions {
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("hydrate target metadata after ordinary cache reads");
assert_eq!(hydrated_target_after_retry.user_defined, target_before_retry.user_defined);
assert_eq!(hydrated_target_after_retry.parts, target_before_retry.parts);
let mut target_reader = store.pools[1]
.get_object_reader(&bucket, retry_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read target multipart body after retry");
let mut target_body = Vec::new();
target_reader
.stream
.read_to_end(&mut target_body)
.await
.expect("drain target multipart body after retry");
assert_eq!(target_body, retry_source_body);
let uploads = store.pools[1]
.list_multipart_uploads(&bucket, retry_object, None, None, None, 100)
.await
.expect("list retry target multipart uploads");
assert!(uploads.uploads.is_empty(), "retry migration staging must be aborted");
Box::pin(async {
store.pools[0]
.delete_object(&bucket, retry_object, ObjectOptions::default())
.await
.expect("remove first-hop source before second migration");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 1));
let second_hop_reader = store.pools[1]
.get_object_reader(
&bucket,
retry_object,
None,
HeaderMap::new(),
&ObjectOptions {
data_movement: true,
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read first target as the second migration source");
assert!(
second_hop_reader
.object_info
.parts
.iter()
.all(|part| part.checksums.is_some()),
"data-movement source reads must hydrate persisted part checksums"
);
temp_env::async_with_vars(
[
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, Some("true")),
(rustfs_config::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED, Some("true")),
],
crate::data_movement::migrate_object(
store.clone(),
1,
bucket.clone(),
second_hop_reader,
None,
"test_data_movement_second_hop",
),
)
.await
.expect("second multipart migration should preserve part checksums");
let second_hop_target = store.pools[0]
.get_object_info(
&bucket,
retry_object,
&ObjectOptions {
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("read second-hop target metadata");
assert_eq!(second_hop_target.checksum, target_before_retry.checksum);
assert_eq!(
second_hop_target
.parts
.iter()
.map(|part| (&part.number, &part.checksums))
.collect::<Vec<_>>(),
target_before_retry
.parts
.iter()
.map(|part| (&part.number, &part.checksums))
.collect::<Vec<_>>()
);
})
.await;
});
})
.expect("spawn data movement test thread")
.join()
.expect("join data movement test thread");
}
#[test]
#[serial_test::serial(storage_class_env)]
fn data_movement_multipart_replaces_only_unlocked_owned_generation() {
std::thread::Builder::new()
.stack_size(16 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("build data movement replacement test runtime");
runtime.block_on(async move {
let temp_dir = tempfile::tempdir().expect("create data movement replacement store dir");
let (_ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"data-movement-stale-replacement",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("dm-stale-replacement-{}", uuid::Uuid::new_v4());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create data movement replacement bucket");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
let version_id = uuid::Uuid::new_v4();
let old_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
let new_time = old_time + time::Duration::SECOND;
let first_part_size = 5 * 1024 * 1024;
let mut old_body = vec![b'a'; first_part_size];
old_body.push(b'b');
let mut new_body = vec![b'c'; first_part_size];
new_body.push(b'd');
let source_reader = |name: &'static str, body: Vec<u8>, mod_time, metadata: HashMap<String, String>| {
seed_data_movement_source_reader(
&store,
&bucket,
name,
DataMovementSourceSeed {
pool_idx: 0,
version_id: Some(version_id),
body,
multipart_split: Some(first_part_size),
mod_time,
user_defined: metadata,
},
)
};
let replaceable = "replaceable.bin";
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(
replaceable,
old_body.clone(),
old_time,
HashMap::from([("x-amz-meta-generation".to_string(), "old".to_string())]),
)
.await,
None,
"test_stale_target_seed",
)
.await
.expect("seed old migrated target");
let seeded_target = store.pools[1]
.get_object_info(
&bucket,
replaceable,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
},
)
.await
.expect("read old migrated target");
let replacement_opts = ObjectOptions {
data_movement: true,
versioned: true,
version_id: Some(version_id.to_string()),
mod_time: Some(new_time),
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
..Default::default()
};
assert!(
crate::data_movement::can_replace_stale_data_movement_target(&seeded_target, &replacement_opts),
"seeded target should be replaceable: {seeded_target:?}"
);
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(
replaceable,
new_body.clone(),
new_time,
HashMap::from([("x-amz-meta-generation".to_string(), "new".to_string())]),
)
.await,
None,
"test_stale_target_replace",
)
.await
.expect("newer source generation should replace the old migrated target");
let replacement = store.pools[1]
.get_object_info(
&bucket,
replaceable,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
},
)
.await
.expect("read replaced target metadata");
assert_eq!(replacement.mod_time, Some(new_time));
assert_eq!(replacement.user_defined.get("x-amz-meta-generation").map(String::as_str), Some("new"));
let mut replacement_reader = store.pools[1]
.get_object_reader(
&bucket,
replaceable,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
},
)
.await
.expect("read replaced target body");
let mut replacement_body = Vec::new();
replacement_reader
.stream
.read_to_end(&mut replacement_body)
.await
.expect("drain replaced target body");
assert_eq!(replacement_body, new_body);
let client_target = "client-target.bin";
let client_body = b"client-owned exact version".to_vec();
let mut client_reader = PutObjReader::from_vec(client_body.clone());
store.pools[1]
.put_object(
&bucket,
client_target,
&mut client_reader,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
mod_time: Some(old_time),
user_defined: HashMap::from([("x-amz-meta-owner".to_string(), "client".to_string())]),
..Default::default()
},
)
.await
.expect("seed client-owned exact version");
let err = crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(client_target, new_body.clone(), new_time, HashMap::new()).await,
None,
"test_client_target_reject",
)
.await
.expect_err("data movement must not replace a client-owned exact version");
assert!(err.to_string().contains("complete_multipart_upload"), "unexpected migration error: {err}");
let mut preserved_reader = store.pools[1]
.get_object_reader(
&bucket,
client_target,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
},
)
.await
.expect("read preserved client target");
let mut preserved_body = Vec::new();
preserved_reader
.stream
.read_to_end(&mut preserved_body)
.await
.expect("drain preserved client target");
assert_eq!(preserved_body, client_body);
let acknowledged_target = "acknowledged-target.bin";
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(
acknowledged_target,
old_body.clone(),
old_time,
HashMap::from([("x-amz-meta-generation".to_string(), "old".to_string())]),
)
.await,
None,
"test_acknowledged_target_seed",
)
.await
.expect("seed migrated target before tag acknowledgement");
let target_version_opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
store
.put_object_tags(&bucket, acknowledged_target, "acknowledged=true", &target_version_opts)
.await
.expect("tag update should acknowledge the migrated target");
let acknowledged = store.pools[1]
.get_object_info(&bucket, acknowledged_target, &target_version_opts)
.await
.expect("read acknowledged migrated target");
let rustfs_data_moved_key = rustfs_utils::http::internal_key_rustfs(rustfs_utils::http::SUFFIX_DATA_MOVED);
let minio_data_moved_key =
format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, rustfs_utils::http::SUFFIX_DATA_MOVED);
assert_eq!(acknowledged.user_defined.get(&rustfs_data_moved_key).map(String::as_str), Some(""));
assert_eq!(acknowledged.user_defined.get(&minio_data_moved_key).map(String::as_str), Some(""));
assert_eq!(
rustfs_utils::http::get_consistent_str(&acknowledged.user_defined, rustfs_utils::http::SUFFIX_DATA_MOVED),
None
);
let err = crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(
acknowledged_target,
new_body.clone(),
new_time,
HashMap::from([("x-amz-meta-generation".to_string(), "new".to_string())]),
)
.await,
None,
"test_acknowledged_target_reject",
)
.await
.expect_err("data movement must not replace a target acknowledged by a tag update");
assert!(err.to_string().contains("complete_multipart_upload"), "unexpected migration error: {err}");
let acknowledged_tags = store.pools[1]
.get_object_tags(&bucket, acknowledged_target, &target_version_opts)
.await
.expect("read preserved acknowledged target tags");
assert_eq!(acknowledged_tags, "acknowledged=true");
let mut acknowledged_reader = store.pools[1]
.get_object_reader(&bucket, acknowledged_target, None, HeaderMap::new(), &target_version_opts)
.await
.expect("read preserved acknowledged target body");
let mut acknowledged_body = Vec::new();
acknowledged_reader
.stream
.read_to_end(&mut acknowledged_body)
.await
.expect("drain preserved acknowledged target body");
assert_eq!(acknowledged_body, old_body);
let metadata_acknowledged_target = "metadata-acknowledged-target.bin";
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(
metadata_acknowledged_target,
old_body.clone(),
old_time,
HashMap::from([("x-amz-meta-generation".to_string(), "old".to_string())]),
)
.await,
None,
"test_metadata_acknowledged_target_seed",
)
.await
.expect("seed migrated target before metadata acknowledgement");
store
.put_object_metadata(
&bucket,
metadata_acknowledged_target,
&ObjectOptions {
eval_metadata: Some(HashMap::from([(
s3s::header::X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(),
s3s::dto::ObjectLockLegalHoldStatus::OFF.to_string(),
)])),
..target_version_opts.clone()
},
)
.await
.expect("legal hold metadata update should acknowledge the migrated target");
let metadata_acknowledged = store.pools[1]
.get_object_info(&bucket, metadata_acknowledged_target, &target_version_opts)
.await
.expect("read metadata-acknowledged migrated target");
assert_eq!(
metadata_acknowledged
.user_defined
.get(&rustfs_data_moved_key)
.map(String::as_str),
Some("")
);
assert_eq!(
metadata_acknowledged
.user_defined
.get(&minio_data_moved_key)
.map(String::as_str),
Some("")
);
assert_eq!(
metadata_acknowledged
.user_defined
.get(s3s::header::X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str())
.map(String::as_str),
Some("OFF")
);
let err = crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(
metadata_acknowledged_target,
new_body.clone(),
new_time,
HashMap::from([("x-amz-meta-generation".to_string(), "new".to_string())]),
)
.await,
None,
"test_metadata_acknowledged_target_reject",
)
.await
.expect_err("data movement must not replace a target acknowledged by a metadata update");
assert!(err.to_string().contains("complete_multipart_upload"), "unexpected migration error: {err}");
let retain_until = (OffsetDateTime::now_utc() + time::Duration::days(1))
.format(&time::format_description::well_known::Rfc3339)
.expect("retain-until date should format");
for (object, mode) in [
("compliance-target.bin", s3s::dto::ObjectLockRetentionMode::COMPLIANCE),
("governance-target.bin", s3s::dto::ObjectLockRetentionMode::GOVERNANCE),
] {
let retained_metadata = HashMap::from([
(s3s::header::X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string()),
(
s3s::header::X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
retain_until.clone(),
),
]);
crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(object, old_body.clone(), old_time, retained_metadata.clone()).await,
None,
"test_retained_target_seed",
)
.await
.expect("seed retained migrated target");
let err = crate::data_movement::migrate_object(
store.clone(),
0,
bucket.clone(),
source_reader(object, new_body.clone(), new_time, retained_metadata).await,
None,
"test_retained_target_replace",
)
.await
.expect_err("active retention must block stale target replacement");
assert!(err.to_string().contains("complete_multipart_upload"), "unexpected retention error: {err}");
let mut retained_reader = store.pools[1]
.get_object_reader(
&bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
},
)
.await
.expect("read retained target");
let mut retained_body = Vec::new();
retained_reader
.stream
.read_to_end(&mut retained_body)
.await
.expect("drain retained target");
assert_eq!(retained_body, old_body);
}
for object in [
replaceable,
client_target,
acknowledged_target,
metadata_acknowledged_target,
"compliance-target.bin",
"governance-target.bin",
] {
let uploads = store.pools[1]
.list_multipart_uploads(&bucket, object, None, None, None, 100)
.await
.expect("list target multipart staging");
assert!(uploads.uploads.is_empty(), "data movement staging must be cleaned for {object}");
}
});
})
.expect("spawn data movement replacement test thread")
.join()
.expect("join data movement replacement test thread");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn data_movement_delete_marker_retries_converge_safely() {
let temp_dir = tempfile::tempdir().expect("create delete-marker data movement store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "data-movement-delete-marker", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("dm-delete-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create delete-marker data movement bucket");
let object = "delete-marker-retry";
let version = uuid::Uuid::new_v4();
let mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
let replica_timestamp = mod_time + time::Duration::SECOND;
let replication_timestamp = mod_time;
let replica_timestamp_string = replica_timestamp
.format(&time::format_description::well_known::Rfc3339)
.expect("replica timestamp should format as RFC3339");
let replication_timestamp_string = replication_timestamp
.format(&time::format_description::well_known::Rfc3339)
.expect("replication timestamp should format as RFC3339");
let replication_status = "arn:minio:replication::TenantA:bucket=COMPLETED;";
store.pools[0]
.delete_object(
&bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
delete_marker: true,
mod_time: Some(mod_time),
delete_replication: Some(ReplicationState {
replica_status: ReplicationStatusType::Replica,
replica_timestamp: Some(replica_timestamp),
replication_status_internal: Some(replication_status.to_string()),
replication_timestamp: Some(replication_timestamp),
targets: replication_statuses_map(replication_status),
delete_marker: true,
..Default::default()
}),
..Default::default()
},
)
.await
.expect("write source delete marker");
let source = store.pools[0]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
..Default::default()
},
)
.await
.expect("read source delete marker replication metadata");
assert_eq!(
rustfs_utils::http::get_str(&source.user_defined, rustfs_utils::http::SUFFIX_REPLICA_STATUS).as_deref(),
Some("REPLICA"),
"source metadata: {:?}",
source.user_defined
);
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
let missing_object = "missing-delete-marker";
let missing_version = uuid::Uuid::new_v4();
let missing_opts = ObjectOptions {
versioned: true,
version_id: Some(missing_version.to_string()),
delete_marker: true,
mod_time: Some(mod_time),
src_pool_idx: 0,
data_movement: true,
skip_decommissioned: true,
..Default::default()
};
let missing_source_err = store
.delete_object(&bucket, missing_object, missing_opts.clone())
.await
.expect_err("data movement must not recreate a delete marker missing from the source");
assert!(matches!(missing_source_err, StorageError::DataMovementOverwriteErr(_, _, _)));
let missing_target_err = store.pools[1]
.get_object_info(&bucket, missing_object, &missing_opts)
.await
.expect_err("source-missing preflight must not write a target delete marker");
assert!(matches!(
missing_target_err,
StorageError::ObjectNotFound(_, _) | StorageError::VersionNotFound(_, _, _)
));
let movement_opts = ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
delete_marker: true,
mod_time: Some(mod_time),
src_pool_idx: 0,
data_movement: true,
skip_decommissioned: true,
..Default::default()
};
for attempt in 1..=2 {
let moved = store
.delete_object(&bucket, object, movement_opts.clone())
.await
.unwrap_or_else(|err| panic!("delete marker migration attempt {attempt} should converge: {err}"));
assert!(moved.delete_marker);
assert_eq!(moved.version_id, Some(version));
}
let target = store.pools[1]
.get_object_info(&bucket, object, &movement_opts)
.await
.expect("retry must retain the target delete marker");
assert!(target.delete_marker);
assert_eq!(target.mod_time, Some(mod_time));
assert_eq!(
rustfs_utils::http::get_str(&target.user_defined, rustfs_utils::http::SUFFIX_REPLICA_STATUS).as_deref(),
Some("REPLICA")
);
assert_eq!(
rustfs_utils::http::get_str(&target.user_defined, rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP).as_deref(),
Some(replica_timestamp_string.as_str())
);
assert_eq!(
rustfs_utils::http::get_str(&target.user_defined, rustfs_utils::http::SUFFIX_REPLICATION_STATUS).as_deref(),
Some(replication_status)
);
assert_eq!(
rustfs_utils::http::get_str(&target.user_defined, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP).as_deref(),
Some(replication_timestamp_string.as_str())
);
let null_object = "null-delete-marker-retry";
let null_mod_time = mod_time + time::Duration::seconds(3);
let null_marker = store.pools[0]
.delete_object(
&bucket,
null_object,
ObjectOptions {
version_suspended: true,
version_id: Some(uuid::Uuid::nil().to_string()),
data_movement: true,
delete_marker: true,
mod_time: Some(null_mod_time),
..Default::default()
},
)
.await
.expect("write source null delete marker");
assert_eq!(null_marker.version_id, Some(uuid::Uuid::nil()));
let null_opts = ObjectOptions {
version_suspended: true,
version_id: Some(uuid::Uuid::nil().to_string()),
delete_marker: true,
mod_time: Some(null_mod_time),
src_pool_idx: 0,
data_movement: true,
skip_decommissioned: true,
..Default::default()
};
for attempt in 1..=2 {
let moved = store
.delete_object(&bucket, null_object, null_opts.clone())
.await
.unwrap_or_else(|err| panic!("null delete marker migration attempt {attempt} should converge: {err}"));
assert!(moved.delete_marker);
assert_eq!(moved.version_id, Some(uuid::Uuid::nil()));
}
let target_null_marker = store.pools[1]
.get_object_info(&bucket, null_object, &null_opts)
.await
.expect("target must retain the null delete marker identity");
assert!(target_null_marker.delete_marker);
assert_eq!(target_null_marker.version_id, Some(uuid::Uuid::nil()));
let explicit_object = "suspended-explicit-version-delete";
let explicit_version = uuid::Uuid::new_v4();
let mut explicit_reader = PutObjReader::from_vec(b"explicit version".to_vec());
store.pools[0]
.put_object(
&bucket,
explicit_object,
&mut explicit_reader,
&ObjectOptions {
versioned: true,
version_id: Some(explicit_version.to_string()),
..Default::default()
},
)
.await
.expect("write explicit version before suspended delete");
store.pools[0]
.delete_object(
&bucket,
explicit_object,
ObjectOptions {
version_suspended: true,
version_id: Some(explicit_version.to_string()),
..Default::default()
},
)
.await
.expect("delete explicit version from suspended bucket");
store.pools[0]
.get_object_info(
&bucket,
explicit_object,
&ObjectOptions {
versioned: true,
version_id: Some(explicit_version.to_string()),
..Default::default()
},
)
.await
.expect_err("suspended delete must remove the requested UUID version");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_carries_migration_and_cleanup_mutation_fences() {
let temp_dir = tempfile::tempdir().expect("create decommission delete-fence store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"decommission-delete-fence",
&[(2, 4), (1, 4)],
CancellationToken::new(),
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-delete-fence-{}", uuid::Uuid::new_v4());
let object = (0..128)
.map(|index| format!("object-{index}.bin"))
.find(|candidate| store.pools[0].get_disks_by_key(candidate).set_index == 1)
.expect("the deterministic object search should select source set 1");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create decommission delete-fence bucket");
let mut source = PutObjReader::from_vec(b"source generation".to_vec());
store.pools[0]
.put_object(&bucket, &object, &mut source, &ObjectOptions::default())
.await
.expect("write source object to the pool being decommissioned");
mark_test_pool_decommissioning(&store, 0).await;
assert!(store.is_suspended(0).await, "pool 0 must be a suspended decommission source");
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
&bucket,
&object,
crate::set_disk::PutObjectCommitPause::AfterNamespace,
);
let cleanup_barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(&bucket, &object);
let source_set = store.pools[0].get_disks_by_key(&object);
assert_eq!(source_set.set_index, 1, "the source entry must exercise the non-fixed set cleanup lock");
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
let worker_object = object.clone();
let worker = tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: worker_object,
..Default::default()
},
worker_bucket,
source_set,
)
.await
});
barrier.wait_until_paused().await;
let delete_barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete_object = object.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object(&delete_bucket, &delete_object, ObjectOptions::default())
.await
});
delete_barrier.wait_until_paused().await;
delete_barrier.release_and_wait_until_namespace_pending().await;
assert!(
!delete_barrier.namespace_acquired() && !delete.is_finished(),
"DELETE must remain before namespace acquisition behind the decommission worker's target-commit mutation fence"
);
barrier.release();
cleanup_barrier.wait_until_paused().await;
drop(barrier);
let fixed_set = Arc::clone(&store.pools[0].disk_set[0]);
let fixed_mutation_barrier = crate::set_disk::PutObjectCommitBarrier::install(
&bucket,
&object,
crate::set_disk::PutObjectCommitPause::BeforeNamespace,
);
let mutation_bucket = bucket.clone();
let mutation_object = object.clone();
let fixed_mutation = tokio::spawn(async move {
let mut reader = PutObjReader::from_vec(b"fixed-domain replacement".to_vec());
fixed_set
.put_object(&mutation_bucket, &mutation_object, &mut reader, &ObjectOptions::default())
.await
});
fixed_mutation_barrier.wait_until_paused().await;
fixed_mutation_barrier.release_and_wait_until_namespace_pending().await;
assert!(
!fixed_mutation_barrier.namespace_acquired() && !fixed_mutation.is_finished(),
"the source cleanup must retain the fixed mutation fence before the set-0 mutation acquires its namespace"
);
fixed_mutation.abort();
assert!(
fixed_mutation
.await
.expect_err("the fixed-domain mutation should be canceled")
.is_cancelled(),
"the competing fixed-domain mutation must remain cancelable while blocked"
);
drop(fixed_mutation_barrier);
cleanup_barrier.release();
worker
.await
.expect("decommission entry worker should join")
.expect("decommission entry should migrate and clean its source");
delete
.await
.expect("DELETE task should join")
.expect("DELETE should remove the source and migrated target generations");
for pool in &store.pools {
let err = pool
.get_object_info(&bucket, &object, &ObjectOptions::default())
.await
.expect_err("DELETE must remove the source and migrated target copies");
assert!(
matches!(err, StorageError::ObjectNotFound(_, _)),
"unexpected post-delete pool result: {err:?}"
);
}
let err = store
.get_object_info(&bucket, &object, &ObjectOptions::default())
.await
.expect_err("the deleted generation must not become visible again");
assert!(
matches!(err, StorageError::ObjectNotFound(_, _)),
"unexpected post-delete store result: {err:?}"
);
shutdown.cancel();
}
#[test]
fn decommission_retry_fault_budget_counts_successes_across_attempt_changes() {
for attempts in [[1, 2, 3], [1, 1, 2], [1, 3, 3]] {
let faults = Arc::new(AtomicUsize::new(0));
let hook = decommission_retry_fault_hook("bucket", "object", Arc::clone(&faults));
for (stage, bucket, object, succeeded) in [
("other-stage", "bucket", "object", true),
(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "other-bucket", "object", true),
(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "other-object", true),
(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "object", false),
] {
assert!(!hook(stage, bucket, object, 1, succeeded));
}
assert_eq!(faults.load(Ordering::SeqCst), 0, "unrelated or failed copies must not consume faults");
for (index, attempt) in attempts.into_iter().enumerate() {
assert_eq!(
hook(DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT, "bucket", "object", attempt, true),
index < 2,
"attempts={attempts:?}, index={index}"
);
}
assert_eq!(faults.load(Ordering::SeqCst), 2, "attempts={attempts:?}");
}
}
#[test]
#[serial_test::serial(storage_class_env)]
fn decommission_entry_retries_source_changed_without_canceling_other_bucket() {
let handle = std::thread::Builder::new()
.name("decommission_entry_retries_source_changed_without_canceling_other_bucket".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("test runtime should build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("create decommission retry store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-entry-retry",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let changed_bucket = format!("decom-retry-a-{}", uuid::Uuid::new_v4());
let other_bucket = format!("decom-retry-b-{}", uuid::Uuid::new_v4());
for bucket in [&changed_bucket, &other_bucket] {
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create decommission retry bucket");
}
let changed_object = "changed.bin";
let first_version = uuid::Uuid::new_v4();
let second_version = uuid::Uuid::new_v4();
let base_time = OffsetDateTime::now_utc();
seed_decommission_source(
&store,
&changed_bucket,
changed_object,
b"first generation".to_vec(),
&ObjectOptions {
versioned: true,
version_id: Some(first_version.to_string()),
mod_time: Some(base_time),
..Default::default()
},
)
.await;
let other_object = "other.bin";
seed_decommission_source(
&store,
&other_bucket,
other_object,
b"other bucket generation".to_vec(),
&ObjectOptions::default(),
)
.await;
mark_test_pool_decommissioning(&store, 0).await;
let mutation_calls = Arc::new(AtomicUsize::new(0));
let mutation_calls_for_hook = Arc::clone(&mutation_calls);
let mutation_store = Arc::clone(&store);
let mutation_bucket = changed_bucket.clone();
let _mutation_guard = crate::core::pools::DecommissionCleanupMutationGuard::install(Arc::new(
move |bucket, object, attempt| {
let is_target = bucket == mutation_bucket.as_str() && object == changed_object;
let calls = Arc::clone(&mutation_calls_for_hook);
let store = Arc::clone(&mutation_store);
let bucket = mutation_bucket.clone();
Box::pin(async move {
if !is_target {
return;
}
calls.fetch_add(1, Ordering::SeqCst);
if attempt == 1 {
seed_decommission_source(
&store,
&bucket,
changed_object,
b"second generation".to_vec(),
&ObjectOptions {
versioned: true,
version_id: Some(second_version.to_string()),
mod_time: Some(base_time + time::Duration::seconds(1)),
..Default::default()
},
)
.await;
}
})
},
));
let ordinary_faults = Arc::new(AtomicUsize::new(0));
let fault_hook = decommission_retry_fault_hook(&other_bucket, other_object, Arc::clone(&ordinary_faults));
let _fault_guard = crate::core::pools::DecommissionTestFaultGuard::install(fault_hook);
let rx = CancellationToken::new();
let source_changed_exhaustions = Arc::new(AtomicUsize::new(0));
let changed_incarnation = Some(
store
.bucket_incarnation_id(&changed_bucket)
.await
.expect("changed bucket incarnation"),
);
let other_incarnation = Some(
store
.bucket_incarnation_id(&other_bucket)
.await
.expect("other bucket incarnation"),
);
let (changed_result, other_result) = tokio::join!(
run_decommission_entry_retry_test(
&store,
rx.clone(),
&changed_bucket,
changed_object,
changed_incarnation,
Arc::clone(&source_changed_exhaustions),
),
run_decommission_entry_retry_test(
&store,
rx.clone(),
&other_bucket,
other_object,
other_incarnation,
Arc::clone(&source_changed_exhaustions),
)
);
changed_result.expect("SourceChanged entry retry must converge");
other_result.expect("other bucket entry must continue through ordinary copy retries");
assert!(!rx.is_cancelled(), "entry-level SourceChanged must not cancel the shared worker token");
assert_eq!(mutation_calls.load(Ordering::SeqCst), 2, "entry must be re-listed after SourceChanged");
assert_eq!(ordinary_faults.load(Ordering::SeqCst), 2, "ordinary copy must consume the retry budget");
assert_eq!(source_changed_exhaustions.load(Ordering::SeqCst), 0);
for (version_id, expected_body) in [
(first_version, b"first generation".as_slice()),
(second_version, b"second generation".as_slice()),
] {
let opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
assert_decommission_source_absent(&store, &changed_bucket, changed_object, &opts).await;
assert_eq!(
read_decommission_target_body(&store, &changed_bucket, changed_object, &opts).await,
expected_body
);
}
assert_decommission_source_absent(&store, &other_bucket, other_object, &ObjectOptions::default()).await;
assert_eq!(
read_decommission_target_body(&store, &other_bucket, other_object, &ObjectOptions::default()).await,
b"other bucket generation"
);
shutdown.cancel();
});
})
.expect("spawn decommission retry test thread");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[test]
#[serial_test::serial(storage_class_env)]
fn decommission_entry_exhausted_source_changed_retains_source_and_records_failure() {
let handle = std::thread::Builder::new()
.name("decommission_entry_exhausted_source_changed_retains_source_and_records_failure".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("test runtime should build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("create decommission exhaustion store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-entry-exhaustion",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-exhausted-{}", uuid::Uuid::new_v4());
let object = "exhausted.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create decommission exhaustion bucket");
let original_version = uuid::Uuid::new_v4();
let base_time = OffsetDateTime::now_utc();
seed_decommission_source(
&store,
&bucket,
object,
b"original generation".to_vec(),
&ObjectOptions {
versioned: true,
version_id: Some(original_version.to_string()),
mod_time: Some(base_time),
..Default::default()
},
)
.await;
mark_test_pool_decommissioning(&store, 0).await;
let mutation_calls = Arc::new(AtomicUsize::new(0));
let mutation_calls_for_hook = Arc::clone(&mutation_calls);
let mutation_store = Arc::clone(&store);
let mutation_bucket = bucket.clone();
let _mutation_guard = crate::core::pools::DecommissionCleanupMutationGuard::install(Arc::new(
move |called_bucket, called_object, _attempt| {
let is_target = called_bucket == mutation_bucket.as_str() && called_object == object;
let calls = Arc::clone(&mutation_calls_for_hook);
let store = Arc::clone(&mutation_store);
let bucket = mutation_bucket.clone();
Box::pin(async move {
if !is_target {
return;
}
let call = calls.fetch_add(1, Ordering::SeqCst) + 1;
let offset = i64::try_from(call).expect("entry retry count should fit i64");
seed_decommission_source(
&store,
&bucket,
object,
format!("concurrent generation {call}").into_bytes(),
&ObjectOptions {
versioned: true,
version_id: Some(uuid::Uuid::new_v4().to_string()),
mod_time: Some(base_time + time::Duration::seconds(offset)),
..Default::default()
},
)
.await;
})
},
));
let rx = CancellationToken::new();
let source_changed_exhaustions = Arc::new(AtomicUsize::new(0));
let incarnation = Some(store.bucket_incarnation_id(&bucket).await.expect("bucket incarnation"));
run_decommission_entry_retry_test(
&store,
rx.clone(),
&bucket,
object,
incarnation,
Arc::clone(&source_changed_exhaustions),
)
.await
.expect("entry-level exhaustion must stay local below the pool threshold");
assert!(!rx.is_cancelled(), "one exhausted entry must not cancel other bucket workers");
assert_eq!(mutation_calls.load(Ordering::SeqCst), crate::core::pools::DECOMMISSION_ENTRY_MAX_ATTEMPTS);
assert_eq!(source_changed_exhaustions.load(Ordering::SeqCst), 1);
store.pools[0]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(original_version.to_string()),
..Default::default()
},
)
.await
.expect("retry exhaustion must retain the original source version");
let pool_meta = store.pool_meta.read().await;
let info = pool_meta.pools[0]
.decommission
.as_ref()
.expect("decommission progress must be initialized");
assert_eq!(info.items_decommission_failed, 1, "exhausted entry must be visible as failed");
drop(pool_meta);
shutdown.cancel();
});
})
.expect("spawn decommission retry test thread");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[test]
#[serial_test::serial(storage_class_env)]
fn decommission_entry_delete_marker_copy_retries_real_path() {
let handle = std::thread::Builder::new()
.name("decommission_entry_delete_marker_copy_retries_real_path".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("test runtime should build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("create delete marker retry store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-delete-marker-retry",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-marker-{}", uuid::Uuid::new_v4());
let object = "marker.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create delete marker retry bucket");
let data_version = uuid::Uuid::new_v4();
let marker_version = uuid::Uuid::new_v4();
let base_time = OffsetDateTime::now_utc();
seed_decommission_source(
&store,
&bucket,
object,
b"delete marker data".to_vec(),
&ObjectOptions {
versioned: true,
version_id: Some(data_version.to_string()),
mod_time: Some(base_time),
..Default::default()
},
)
.await;
store.pools[0]
.delete_object(
&bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(marker_version.to_string()),
delete_marker: true,
mod_time: Some(base_time + time::Duration::seconds(1)),
..Default::default()
},
)
.await
.expect("seed source delete marker");
mark_test_pool_decommissioning(&store, 0).await;
let fault_calls = Arc::new(AtomicUsize::new(0));
let fault_calls_for_hook = Arc::clone(&fault_calls);
let fault_bucket = bucket.clone();
let _fault_guard = crate::core::pools::DecommissionTestFaultGuard::install(Arc::new(
move |stage, called_bucket, called_object, attempt, succeeded| {
let injected = succeeded
&& stage == DECOMMISSION_TEST_FAULT_STAGE_DELETE_MARKER
&& called_bucket == fault_bucket.as_str()
&& called_object == object
&& attempt < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS;
if injected {
fault_calls_for_hook.fetch_add(1, Ordering::SeqCst);
}
injected
},
));
let incarnation = Some(store.bucket_incarnation_id(&bucket).await.expect("bucket incarnation"));
run_decommission_entry_retry_test(
&store,
CancellationToken::new(),
&bucket,
object,
incarnation,
Arc::new(AtomicUsize::new(0)),
)
.await
.expect("delete marker copy retries must converge");
assert_eq!(
fault_calls.load(Ordering::SeqCst),
crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS - 1
);
let marker_opts = ObjectOptions {
versioned: true,
version_id: Some(marker_version.to_string()),
..Default::default()
};
let target_marker = store.pools[1]
.get_object_info(&bucket, object, &marker_opts)
.await
.expect("target delete marker must exist");
assert!(target_marker.delete_marker);
assert_decommission_source_absent(&store, &bucket, object, &marker_opts).await;
let data_opts = ObjectOptions {
versioned: true,
version_id: Some(data_version.to_string()),
..Default::default()
};
assert_eq!(
read_decommission_target_body(&store, &bucket, object, &data_opts).await,
b"delete marker data"
);
assert_decommission_source_absent(&store, &bucket, object, &data_opts).await;
shutdown.cancel();
});
})
.expect("spawn decommission retry test thread");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn decommission_entry_tiered_copy_retries_real_path() {
let handle = std::thread::Builder::new()
.name("decommission_entry_tiered_copy_retries_real_path".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("test runtime should build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("create tiered retry store dir");
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-tiered-retry",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-tiered-{}", uuid::Uuid::new_v4());
let object = "tiered.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create tiered retry bucket");
let mut reader = PutObjReader::from_vec(b"tiered generation".to_vec());
let original = store.pools[0]
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("seed tiered source object");
let tier_name = format!("DECOM{}", &uuid::Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
register_mock_tier(&ctx.tier_config_mgr(), &tier_name).await;
store.pools[0]
.transition_object(
&bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().expect("tiered source ETag"),
..Default::default()
},
version_id: original.version_id.map(|version_id| version_id.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("transition source object to mock tier");
mark_test_pool_decommissioning(&store, 0).await;
let fault_calls = Arc::new(AtomicUsize::new(0));
let fault_calls_for_hook = Arc::clone(&fault_calls);
let fault_bucket = bucket.clone();
let _fault_guard = crate::core::pools::DecommissionTestFaultGuard::install(Arc::new(
move |stage, called_bucket, called_object, attempt, succeeded| {
let injected = succeeded
&& stage == DECOMMISSION_TEST_FAULT_STAGE_TIERED
&& called_bucket == fault_bucket.as_str()
&& called_object == object
&& attempt < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS;
if injected {
fault_calls_for_hook.fetch_add(1, Ordering::SeqCst);
}
injected
},
));
let incarnation = Some(store.bucket_incarnation_id(&bucket).await.expect("bucket incarnation"));
run_decommission_entry_retry_test(
&store,
CancellationToken::new(),
&bucket,
object,
incarnation,
Arc::new(AtomicUsize::new(0)),
)
.await
.expect("tiered copy retries must converge");
assert_eq!(
fault_calls.load(Ordering::SeqCst),
crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS - 1
);
let target = store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("tiered target metadata must exist");
assert_eq!(target.transitioned_object.status, rustfs_filemeta::TRANSITION_COMPLETE);
assert_decommission_source_absent(&store, &bucket, object, &ObjectOptions::default()).await;
shutdown.cancel();
});
})
.expect("spawn decommission retry test thread");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_outer_fence_loss_blocks_target_put_commit() {
let temp_dir = tempfile::tempdir().expect("create decommission PUT fence-loss store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-put-fence-loss", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-put-fence-loss-{}", uuid::Uuid::new_v4());
let object = "ordinary.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create decommission PUT fence-loss bucket");
let mut source = PutObjReader::from_vec(b"source generation".to_vec());
store.pools[0]
.put_object(&bucket, object, &mut source, &ObjectOptions::default())
.await
.expect("write decommission PUT source");
mark_test_pool_decommissioning(&store, 0).await;
let loss_hook = crate::store::object::DecommissionMutationFenceLossHook::install(
&bucket,
object,
crate::store::object::DecommissionMutationFenceTestPhase::Migration,
);
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
&bucket,
object,
crate::set_disk::PutObjectCommitPause::BeforeQuotaRename,
);
let source_set = store.pools[0].get_disks_by_key(object);
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
let worker = tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
worker_bucket,
source_set,
)
.await
});
barrier.wait_until_paused().await;
loss_hook.mark_lost();
barrier.release();
drop(barrier);
worker
.await
.expect("decommission PUT fence-loss worker should join")
.expect("a fenced migration failure should remain retryable at entry scope");
assert_pool_object_absent(&store.pools[1], &bucket, object).await;
assert_pool_object_present(&store.pools[0], &bucket, object).await;
shutdown.cancel();
}
#[test]
#[serial_test::serial(storage_class_env)]
fn decommission_outer_fence_loss_blocks_multipart_commits() {
run_large_stack_async_test(
"decommission-multipart-outer-fence-loss",
decommission_outer_fence_loss_blocks_multipart_commits_case,
);
}
async fn decommission_outer_fence_loss_blocks_multipart_commits_case() {
for (object, pause) in [
("complete.bin", crate::set_disk::MultipartCommitPause::BeforeLockLost),
("new-upload.bin", crate::set_disk::MultipartCommitPause::NewUploadBeforeLockLost),
] {
// Each injected commit failure intentionally leaves a capacity intent for
// reconciliation. Isolate the scenarios so one failure cannot turn the
// next scenario into a capacity-recovery test before its barrier is hit.
let temp_dir = tempfile::tempdir().expect("create decommission multipart fence-loss store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-multipart-fence-loss",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-mpu-fence-loss-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create decommission multipart fence-loss bucket");
write_decommission_test_multipart_source(&store, 0, &bucket, object).await;
mark_test_pool_decommissioning(&store, 0).await;
let loss_hook = crate::store::object::DecommissionMutationFenceLossHook::install(
&bucket,
object,
crate::store::object::DecommissionMutationFenceTestPhase::Migration,
);
let commit_observation = (pause == crate::set_disk::MultipartCommitPause::NewUploadBeforeLockLost)
.then(|| crate::set_disk::NewMultipartUploadCommitObservation::install(&bucket, object));
let barrier = crate::set_disk::MultipartCommitBarrier::install(&bucket, object, pause);
let source_set = store.pools[0].get_disks_by_key(object);
let recovery_source_set = Arc::clone(&source_set);
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
let mut worker = tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
worker_bucket,
source_set,
)
.await
});
tokio::select! {
_ = barrier.wait_until_paused() => {}
result = &mut worker => {
panic!("decommission multipart worker finished before the requested {pause:?} commit barrier: {result:?}");
}
}
loss_hook.mark_lost();
barrier.release();
drop(barrier);
worker
.await
.expect("decommission multipart fence-loss worker should join")
.expect("a fenced multipart migration failure should remain retryable at entry scope");
if let Some(commit_observation) = commit_observation {
assert!(
!commit_observation.committed(),
"NewMultipart must reject a lost outer decommission/capacity fence even though it does not acquire the final remote-tuple publication fence"
);
}
assert_pool_object_absent(&store.pools[1], &bucket, object).await;
assert_pool_object_present(&store.pools[0], &bucket, object).await;
let uploads = store.pools[1]
.list_multipart_uploads(&bucket, object, None, None, None, 100)
.await
.expect("list target multipart uploads after fenced migration");
assert!(uploads.uploads.is_empty(), "fenced multipart migration must not retain target staging");
drop(loss_hook);
store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.clone(),
recovery_source_set,
)
.await
.expect("same-mutation retry should recover the durable capacity intent");
shutdown.cancel();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_outer_fence_loss_blocks_source_cleanup_delete_commit() {
let temp_dir = tempfile::tempdir().expect("create decommission cleanup fence-loss store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-cleanup-fence-loss", &[4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-cleanup-fence-loss-{}", uuid::Uuid::new_v4());
let object = "cleanup.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create decommission cleanup fence-loss bucket");
let mut source = PutObjReader::from_vec(b"source generation".to_vec());
store.pools[0]
.put_object(&bucket, object, &mut source, &ObjectOptions::default())
.await
.expect("write decommission cleanup source");
mark_test_pool_decommissioning(&store, 0).await;
let loss_hook = crate::store::object::DecommissionMutationFenceLossHook::install(
&bucket,
object,
crate::store::object::DecommissionMutationFenceTestPhase::SourceCleanup,
);
let barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(&bucket, object);
let source_set = store.pools[0].get_disks_by_key(object);
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
let worker = tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
worker_bucket,
source_set,
)
.await
});
barrier.wait_until_paused().await;
loss_hook.mark_lost();
barrier.release();
drop(barrier);
let err = worker
.await
.expect("decommission cleanup fence-loss worker should join")
.expect_err("source cleanup must fail after its outer fence is lost");
assert!(
err.to_string().contains("delete_object_commit"),
"cleanup failure must come from the delete commit fence: {err:?}"
);
assert_pool_object_present(&store.pools[0], &bucket, object).await;
assert_pool_object_present(&store.pools[1], &bucket, object).await;
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn reverse_decommission_reuses_fixed_target_fence_for_put_and_multipart() {
let temp_dir = tempfile::tempdir().expect("create reverse decommission store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"reverse-decommission-fixed-target",
&[(1, 4), (1, 4)],
CancellationToken::new(),
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("reverse-decom-fixed-target-{}", uuid::Uuid::new_v4());
let object = "ordinary.bin";
let object_body = b"reverse ordinary generation".to_vec();
let self_copy_object = "source-only-self-copy.bin";
let self_copy_body = b"source only copy generation".to_vec();
let multipart_object = "multipart.bin";
let first_part = vec![b'm'; 5 * 1024 * 1024];
let second_part = b"reverse multipart tail".to_vec();
let mut multipart_body = first_part.clone();
multipart_body.extend_from_slice(&second_part);
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create reverse decommission bucket");
let mut source = PutObjReader::from_vec(object_body.clone());
store.pools[1]
.put_object(&bucket, object, &mut source, &ObjectOptions::default())
.await
.expect("write ordinary source object to pool 1");
let mut self_copy_source = PutObjReader::from_vec(self_copy_body.clone());
store.pools[1]
.put_object(&bucket, self_copy_object, &mut self_copy_source, &ObjectOptions::default())
.await
.expect("write self-copy source object to pool 1");
let upload = store.pools[1]
.new_multipart_upload(&bucket, multipart_object, &ObjectOptions::default())
.await
.expect("create source multipart upload in pool 1");
let mut completed_parts = Vec::with_capacity(2);
for (part_number, bytes) in [(1, first_part.as_slice()), (2, second_part.as_slice())] {
let mut reader = PutObjReader::from_vec(bytes.to_vec());
let part = store.pools[1]
.put_object_part(
&bucket,
multipart_object,
&upload.upload_id,
part_number,
&mut reader,
&ObjectOptions::default(),
)
.await
.expect("write source multipart part");
completed_parts.push(crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
});
}
store.pools[1]
.clone()
.complete_multipart_upload(&bucket, multipart_object, &upload.upload_id, completed_parts, &ObjectOptions::default())
.await
.expect("complete source multipart object in pool 1");
{
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[1].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
}
assert!(store.is_suspended(1).await, "pool 1 must be the reverse decommission source");
let self_copy_opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let mut self_copy_reader = store
.get_object_reader(&bucket, self_copy_object, None, HeaderMap::new(), &self_copy_opts)
.await
.expect("read the suspended source before its target is committed");
let mut self_copy_info = self_copy_reader.object_info.clone();
let mut self_copy_source_body = Vec::new();
self_copy_reader
.stream
.read_to_end(&mut self_copy_source_body)
.await
.expect("drain the suspended self-copy source");
assert_eq!(self_copy_source_body, self_copy_body);
self_copy_info.metadata_only = true;
self_copy_info.put_object_reader = Some(PutObjReader::from_vec(self_copy_source_body));
store
.copy_object(
&bucket,
self_copy_object,
&bucket,
self_copy_object,
&mut self_copy_info,
&self_copy_opts,
&self_copy_opts,
)
.await
.expect("self-copy should read the suspended source and commit to an active pool");
assert_pool_object_present(&store.pools[0], &bucket, self_copy_object).await;
assert_pool_object_present(&store.pools[1], &bucket, self_copy_object).await;
let mut active_copy_reader = store
.get_object_reader(&bucket, self_copy_object, None, HeaderMap::new(), &self_copy_opts)
.await
.expect("read the active self-copy target while the source remains");
let active_copy_data_dir = active_copy_reader.object_info.data_dir;
let mut active_copy_info = active_copy_reader.object_info.clone();
let mut active_copy_body = Vec::new();
active_copy_reader
.stream
.read_to_end(&mut active_copy_body)
.await
.expect("drain the active self-copy target");
assert_eq!(active_copy_body, self_copy_body);
active_copy_info.metadata_only = true;
active_copy_info.put_object_reader = Some(PutObjReader::from_vec(active_copy_body));
let active_copy_result = store
.copy_object(
&bucket,
self_copy_object,
&bucket,
self_copy_object,
&mut active_copy_info,
&self_copy_opts,
&self_copy_opts,
)
.await
.expect("self-copy should keep using the committed active target");
assert_eq!(active_copy_result.data_dir, active_copy_data_dir);
{
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[1].decommission = None;
}
mark_test_pool_decommissioning(&store, 1).await;
let cleanup_barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(&bucket, object);
let commit_barrier = crate::set_disk::PutObjectCommitBarrier::install(
&bucket,
object,
crate::set_disk::PutObjectCommitPause::AfterNamespace,
);
let source_set = store.pools[1].get_disks_by_key(object);
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
let worker = tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
1,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
worker_bucket,
source_set,
)
.await
});
commit_barrier.wait_until_paused().await;
let delete_barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object(&delete_bucket, object, ObjectOptions::default())
.await
});
delete_barrier.wait_until_paused().await;
delete_barrier.release_and_wait_until_namespace_pending().await;
assert!(
!delete_barrier.namespace_acquired() && !delete.is_finished(),
"the reverse target commit must keep DELETE behind the fixed read fence"
);
delete.abort();
assert!(
delete
.await
.expect_err("the blocked DELETE should be canceled")
.is_cancelled(),
"canceling the blocked DELETE must not mutate either pool"
);
drop(delete_barrier);
commit_barrier.release();
cleanup_barrier.wait_until_paused().await;
drop(commit_barrier);
let read_opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let target_info = store.pools[0]
.get_object_info(&bucket, object, &read_opts)
.await
.expect("read the committed active target before source cleanup");
store.pools[1]
.get_object_info(&bucket, object, &read_opts)
.await
.expect("the source must remain present until cleanup is released");
let routed_info = store
.get_object_info(&bucket, object, &read_opts)
.await
.expect("HEAD routing should prefer the active target before source cleanup");
assert_eq!(routed_info.data_dir, target_info.data_dir);
let mut ranged_reader = store
.get_object_reader(
&bucket,
object,
Some(HTTPRangeSpec {
is_suffix_length: false,
start: 8,
end: 15,
}),
HeaderMap::new(),
&read_opts,
)
.await
.expect("ranged GET routing should prefer the active target before source cleanup");
assert_eq!(ranged_reader.object_info.data_dir, target_info.data_dir);
let mut ranged_body = Vec::new();
ranged_reader
.stream
.read_to_end(&mut ranged_body)
.await
.expect("drain the routed target range");
assert_eq!(ranged_body, object_body[8..=15]);
cleanup_barrier.release();
drop(cleanup_barrier);
tokio::time::timeout(Duration::from_secs(60), worker)
.await
.expect("reverse ordinary decommission must not self-deadlock on the fixed target set")
.expect("reverse ordinary decommission worker should join")
.expect("reverse ordinary decommission should complete");
let mut ordinary_reader = store.pools[0]
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read the ordinary object from the fixed target set");
let mut ordinary_target_body = Vec::new();
ordinary_reader
.stream
.read_to_end(&mut ordinary_target_body)
.await
.expect("drain the ordinary target body");
assert_eq!(ordinary_target_body, object_body, "ordinary migration must preserve the full body");
let ordinary_source_err = store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("ordinary source generation must be cleaned after migration");
assert!(matches!(ordinary_source_err, StorageError::ObjectNotFound(_, _)));
let multipart_source_set = store.pools[1].get_disks_by_key(multipart_object);
let multipart_store = Arc::clone(&store);
let multipart_bucket = bucket.clone();
let multipart_worker = tokio::spawn(async move {
multipart_store
.decommission_entry_for_test(
1,
MetaCacheEntry {
name: multipart_object.to_string(),
..Default::default()
},
multipart_bucket,
multipart_source_set,
)
.await
});
tokio::time::timeout(Duration::from_secs(60), multipart_worker)
.await
.expect("reverse multipart decommission must not self-deadlock on new or complete")
.expect("reverse multipart decommission worker should join")
.expect("reverse multipart decommission should complete");
let target_info = store.pools[0]
.get_object_info(&bucket, multipart_object, &ObjectOptions::default())
.await
.expect("read migrated multipart metadata from the fixed target set");
assert!(target_info.is_multipart(), "migration must retain multipart identity");
let mut multipart_reader = store.pools[0]
.get_object_reader(&bucket, multipart_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read migrated multipart object from the fixed target set");
let mut multipart_target_body = Vec::new();
multipart_reader
.stream
.read_to_end(&mut multipart_target_body)
.await
.expect("drain the multipart target body");
assert_eq!(multipart_target_body, multipart_body, "multipart migration must preserve the full body");
let multipart_source_err = store.pools[1]
.get_object_info(&bucket, multipart_object, &ObjectOptions::default())
.await
.expect_err("multipart source generation must be cleaned after migration");
assert!(matches!(multipart_source_err, StorageError::ObjectNotFound(_, _)));
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn batch_delete_real_path_preserves_source_pool_errors_in_any_pool_order() {
let temp_dir = tempfile::tempdir().expect("create batch delete pool-error store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-delete-pool-errors", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
for source_pool_idx in [0, 1] {
{
let mut pool_meta = store.pool_meta.write().await;
for pool in &mut pool_meta.pools {
pool.decommission = None;
}
}
let bucket = format!("batch-del-pool-error-{source_pool_idx}-{}", uuid::Uuid::new_v4());
let object_names = vec![
format!("third-{source_pool_idx}.bin"),
format!("first-{source_pool_idx}.bin"),
format!("second-{source_pool_idx}.bin"),
];
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create batch delete pool-error bucket");
for pool in &store.pools {
for object_name in &object_names {
let mut reader = PutObjReader::from_vec(format!("pool {} {object_name}", pool.pool_idx).into_bytes());
pool.put_object(&bucket, object_name, &mut reader, &ObjectOptions::default())
.await
.expect("seed each object in both the source and active pools");
}
}
{
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[source_pool_idx].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
}
assert!(
store.is_suspended(source_pool_idx).await,
"the injected error pool must be the decommission source"
);
let expected_errors = [
StorageError::ErasureWriteQuorum,
StorageError::NamespaceLockQuorumUnavailable {
mode: "delete_objects_commit",
bucket: bucket.clone(),
object: object_names[1].clone(),
required: 3,
achieved: 2,
},
StorageError::ErasureWriteQuorum,
];
let injection = crate::store::object::BatchDeletePoolErrorInjection::install(
&bucket,
source_pool_idx,
object_names.iter().cloned().zip(expected_errors.iter().cloned()).collect(),
);
let requests = object_names
.iter()
.map(|object_name| ObjectToDelete {
object_name: object_name.clone(),
..Default::default()
})
.collect();
let (deleted, errors) = store.delete_objects(&bucket, requests, ObjectOptions::default()).await;
assert_eq!(
injection.observed(),
object_names.len(),
"the source pool must first complete every real delete"
);
assert_eq!(
errors,
expected_errors.iter().cloned().map(Some).collect::<Vec<_>>(),
"a successful pool must not clear a source pool failure at any request index"
);
assert_eq!(
deleted.iter().map(|object| object.object_name.as_str()).collect::<Vec<_>>(),
object_names.iter().map(String::as_str).collect::<Vec<_>>(),
"DeleteObjects must preserve request index mapping while aggregating pool failures"
);
assert!(
deleted.iter().all(|object| object.found),
"the injected source results must retain real delete success data"
);
for pool in &store.pools {
for object_name in &object_names {
let error = pool
.get_object_info(&bucket, object_name, &ObjectOptions::default())
.await
.expect_err("both the active and source pool delete calls must execute");
assert!(
matches!(error, StorageError::ObjectNotFound(_, _)),
"unexpected residual object: {error:?}"
);
}
}
drop(injection);
}
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_source_cleanup_holds_hashed_set_lock_across_preflight() {
let temp_dir = tempfile::tempdir().expect("create multi-set decommission cleanup store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"multi-set-decommission-source-cleanup",
&[(2, 4)],
CancellationToken::new(),
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-source-cleanup-lock-{}", uuid::Uuid::new_v4());
let object = (0..128)
.map(|index| format!("object-{index}.bin"))
.find(|candidate| store.pools[0].get_disks_by_key(candidate).set_index == 1)
.expect("the deterministic object search should select source set 1");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create multi-set decommission cleanup bucket");
let mut source = PutObjReader::from_vec(b"source generation".to_vec());
store.pools[0]
.put_object(&bucket, &object, &mut source, &ObjectOptions::default())
.await
.expect("write the source generation to set 1");
let source_set = store.pools[0].get_disks_by_key(&object);
assert_eq!(source_set.set_index, 1, "the source must not share the fixed set-0 namespace");
let expected_source_versions = source_set
.load_file_info_versions_exact(&bucket, &object)
.await
.expect("source versions should be readable")
.expect("the source generation should exist");
let cleanup_barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(&bucket, &object);
let cleanup_store = Arc::clone(&store);
let cleanup_bucket = bucket.clone();
let cleanup_object = object.clone();
let cleanup = tokio::spawn(async move {
let mutation_fence = cleanup_store
.acquire_decommission_source_cleanup_fence(&cleanup_bucket, &cleanup_object, source_set.as_ref())
.await?;
crate::data_movement::cleanup_source_entry_if_unchanged(
source_set,
&cleanup_bucket,
&cleanup_object,
&expected_source_versions,
&[],
crate::data_movement::SourceCleanupBucketFence {
object_mutation_fence: Some(&mutation_fence),
..Default::default()
},
"test_multi_set_decommission_source_cleanup",
)
.await
});
cleanup_barrier.wait_until_paused().await;
let put_barrier = crate::set_disk::PutObjectCommitBarrier::install(
&bucket,
&object,
crate::set_disk::PutObjectCommitPause::BeforeNamespace,
);
let mutation_pool = Arc::clone(&store.pools[0]);
let mutation_bucket = bucket.clone();
let mutation_object = object.clone();
let replacement = b"replacement generation".to_vec();
let expected_replacement = replacement.clone();
let mutation = tokio::spawn(async move {
let mut reader = PutObjReader::from_vec(replacement);
mutation_pool
.put_object(&mutation_bucket, &mutation_object, &mut reader, &ObjectOptions::default())
.await
});
put_barrier.wait_until_paused().await;
put_barrier.release_and_wait_until_namespace_pending().await;
assert!(!mutation.is_finished(), "a source mutation must wait behind cleanup's set-1 write lock");
cleanup_barrier.release();
cleanup
.await
.expect("source cleanup task should join")
.expect("source cleanup should remove only the preflight generation");
mutation
.await
.expect("source mutation task should join")
.expect("source mutation should commit after cleanup releases the set lock");
let mut reader = store.pools[0]
.get_object_reader(&bucket, &object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the replacement generation must remain readable");
let mut actual = Vec::new();
reader
.stream
.read_to_end(&mut actual)
.await
.expect("read the replacement generation");
assert_eq!(actual, expected_replacement, "cleanup must not delete the replacement generation");
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn versioned_delete_marker_survives_decommission_source_cleanup() {
let temp_dir = tempfile::tempdir().expect("create versioned decommission delete-fence store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "versioned-decommission-delete-fence", &[4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("versioned-decom-delete-{}", uuid::Uuid::new_v4());
let object = "object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create versioned decommission delete-fence bucket");
let (source_version, expected_source_versions) = migrate_versioned_decommission_test_object(
&store,
&bucket,
object,
b"source generation",
"test_versioned_decommission_delete_fence",
)
.await;
let delete_barrier = crate::store::object::VersionedDeleteMarkerCommitBarrier::install(&bucket, object);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object(
&delete_bucket,
object,
ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
});
delete_barrier.wait_until_paused().await;
let cleanup_set = store.pools[0].get_disks_by_key(object);
crate::data_movement::ensure_source_cleanup_versions_unchanged(
Arc::clone(&cleanup_set),
&bucket,
object,
&expected_source_versions,
&[],
"test_versioned_decommission_delete_fence",
)
.await
.expect("the committed delete marker must not be published to the suspended source pool");
let cleanup_delete_barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(&bucket, object);
let cleanup_store = Arc::clone(&store);
let cleanup_bucket = bucket.clone();
let cleanup = tokio::spawn(async move {
let mutation_fence = cleanup_store
.acquire_decommission_source_cleanup_fence(&cleanup_bucket, object, cleanup_set.as_ref())
.await?;
crate::data_movement::cleanup_source_entry_if_unchanged(
cleanup_set,
&cleanup_bucket,
object,
&expected_source_versions,
&[],
crate::data_movement::SourceCleanupBucketFence {
object_mutation_fence: Some(&mutation_fence),
..Default::default()
},
"test_versioned_decommission_delete_fence",
)
.await
});
cleanup_delete_barrier.wait_until_fence_pending().await;
assert!(
!cleanup_delete_barrier.is_paused(),
"source cleanup must wait for the versioned DELETE mutation fence"
);
delete_barrier.release();
let marker = delete
.await
.expect("versioned DELETE task should join")
.expect("versioned DELETE should publish a delete marker after migration");
assert!(marker.delete_marker, "versioned DELETE must publish a delete marker");
assert!(
marker.version_id.is_some_and(|version_id| !version_id.is_nil()),
"the delete marker must have a non-nil version ID"
);
cleanup_delete_barrier.wait_until_paused().await;
cleanup_delete_barrier.release();
cleanup
.await
.expect("source cleanup task should join")
.expect("source cleanup should preserve the active-pool delete marker");
let err = store
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect_err("the post-migration delete marker must hide the migrated version");
assert!(
matches!(err, StorageError::ObjectNotFound(_, _)),
"unexpected latest-version result: {err:?}"
);
store
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(source_version.to_string()),
..Default::default()
},
)
.await
.expect("the migrated source version must remain addressable below the delete marker");
store.pools[0]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(source_version.to_string()),
..Default::default()
},
)
.await
.expect_err("source cleanup must remove the decommissioned source versions");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_skips_cleanup_only_marker_when_free_version_is_present() {
let temp_dir = tempfile::tempdir().expect("create free-version decommission store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-free-marker", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-free-marker-{}", uuid::Uuid::new_v4());
let object = "free-marker-object";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create free-version decommission bucket");
let (_, free_version) = seed_transitioned_free_version(&ctx, &store, &bucket, object).await;
let source_free = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("source free-version metadata should decode")
.and_then(|versions| {
versions
.versions
.into_iter()
.find(|version| version.version_id == Some(free_version) && version.tier_free_version())
})
.expect("source free-version identity should be present before decommission");
let mut target_reader = PutObjReader::from_vec(b"target ordinary bytes".to_vec());
let target_w = store.pools[1]
.put_object(
&bucket,
object,
&mut target_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write unrelated target version");
let target_w_version = target_w.version_id.expect("target version should have an id");
let marker = store.pools[0]
.delete_object(
&bucket,
object,
ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write cleanup-only delete marker");
assert!(marker.delete_marker);
mark_test_pool_decommissioning(&store, 0).await;
let source_set = store.pools[0].get_disks_by_key(object);
store
.decommission_entry_for_test_with_bucket_incarnation(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.clone(),
source_set.clone(),
)
.await
.expect("real decommission entry should migrate the free version");
let target_versions = store.pools[1]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("target versions should decode")
.expect("target free version should be present");
assert!(
target_versions
.versions
.iter()
.any(|version| { version.version_id == Some(free_version) && version.tier_free_version() })
);
let migrated_free = target_versions
.versions
.iter()
.find(|version| version.version_id == Some(free_version) && version.tier_free_version())
.expect("migrated free-version identity should remain readable from target disks");
assert!(
crate::store::tiered_data_movement_source_matches(&source_free, migrated_free)
.expect("migrated free-version identity should decode")
);
let retained_w = target_versions
.versions
.iter()
.find(|version| version.version_id == Some(target_w_version))
.expect("unrelated target version should remain");
assert!(!retained_w.deleted && !retained_w.tier_free_version());
assert_eq!(retained_w.size, target_w.size);
assert_eq!(retained_w.get_etag(), target_w.etag);
assert!(
target_versions
.versions
.iter()
.all(|version| { version.tier_free_version() || !version.deleted })
);
assert!(
source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("source versions should be readable after cleanup")
.is_none(),
"successful free-version migration should permit source cleanup"
);
let (heal_versions, _, _) = store
.heal_walk_versions_page(1, 0, &bucket, "", None, 2, 16, true)
.await
.expect("heal walk should decode the migrated free version");
let free_version_string = free_version.to_string();
let healed_free = heal_versions
.iter()
.find(|version| version.version_id.as_deref() == Some(free_version_string.as_str()))
.expect("heal walk should surface the migrated free version");
let healed_info = healed_free
.lifecycle_object_info
.as_ref()
.expect("heal walk should retain lifecycle identity for the migrated free version");
assert!(healed_info.transitioned_object.free_version);
assert_eq!(healed_info.transitioned_object.tier, source_free.transition_tier);
assert_eq!(healed_info.transitioned_object.name, source_free.transitioned_objname);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_allows_free_version_consumed_before_source_lock() {
let temp_dir = tempfile::tempdir().expect("create consumed free-version store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-free-consumed", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-free-consumed-{}", uuid::Uuid::new_v4());
let object = "free-consumed-object";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create consumed free-version bucket");
let (_, free_version) = seed_transitioned_free_version(&ctx, &store, &bucket, object).await;
mark_test_pool_decommissioning(&store, 0).await;
let source_set = store.pools[0].get_disks_by_key(object);
let barrier = crate::store::object::DecommissionFreeVersionSourceRaceBarrier::install(&bucket, object);
let decommission = tokio::spawn({
let store = store.clone();
let bucket = bucket.clone();
let source_set = source_set.clone();
async move {
store
.decommission_entry_for_test_with_bucket_incarnation(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket,
source_set,
)
.await
}
});
barrier.wait_until_paused().await;
store.pools[0]
.delete_object(
&bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(free_version.to_string()),
incl_free_versions: true,
..Default::default()
},
)
.await
.expect("lifecycle should consume the source free version before decommission locks it");
assert!(
source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("consumed source metadata should remain readable")
.is_none(),
"the lifecycle delete should remove the source free version"
);
barrier.release();
decommission
.await
.expect("decommission task should join")
.expect("a concurrently consumed free version should not fail source cleanup");
assert!(
store.pools[1]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("target metadata should remain readable")
.is_none(),
"an already consumed free version should not be recreated on the target"
);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_rejects_subquorum_free_version_conflict_and_retains_source() {
let temp_dir = tempfile::tempdir().expect("create sub-quorum free-version store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-free-conflict", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("decom-free-conflict-{}", uuid::Uuid::new_v4());
let object = "free-conflict-object";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create sub-quorum conflict bucket");
let (_, free_version) = seed_transitioned_free_version(&ctx, &store, &bucket, object).await;
let source_free = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("source free version should decode before crash replay setup")
.and_then(|versions| {
versions
.versions
.into_iter()
.find(|version| version.version_id == Some(free_version))
})
.expect("source free version should be available for crash replay setup");
let mut target_reader = PutObjReader::from_vec(b"target ordinary bytes".to_vec());
let target = store.pools[1]
.put_object(
&bucket,
object,
&mut target_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("seed ordinary target version");
let target_version = target.version_id.expect("target version must have an ID");
// A regular PUT may acknowledge write quorum while its rename tail still owns the
// target namespace lock. Drain that tail through a locked read before bypassing
// the namespace API with the per-disk crash-replay fixture writes below.
store.pools[1]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(target_version.to_string()),
..Default::default()
},
)
.await
.expect("target PUT tail should drain before per-disk fixture mutation");
let target_disks = store.pools[1].get_disks_by_key(object).disks.read().await.clone();
for disk in target_disks.iter().skip(1) {
disk.as_ref()
.expect("target crash replay quorum disk should be online")
.write_metadata("", &bucket, object, source_free.clone())
.await
.expect("seed an equivalent free version on the target quorum");
}
let conflict_path = temp_dir
.path()
.join(format!("pool1/set0/disk0/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let encoded = tokio::fs::read(&conflict_path)
.await
.expect("target metadata should be readable");
let mut metadata = FileMeta::load(&encoded).expect("target metadata should decode");
let target_index = metadata
.versions
.iter()
.position(|version| version.header.version_id == Some(target_version))
.expect("target version should be present on the conflict disk");
let mut target_meta = metadata.versions[target_index]
.parse_version_meta()
.expect("target version metadata should decode");
target_meta
.object
.as_mut()
.expect("target conflict must remain an ordinary object")
.version_id = Some(free_version);
metadata.versions[target_index] = target_meta.try_into().expect("conflict metadata should encode");
let expected_conflict_meta = metadata.versions[target_index].meta.clone();
let expected_conflict = metadata.versions[target_index]
.into_fileinfo(&bucket, object, true)
.expect("conflict metadata should decode as an ordinary object");
let duplicate_free: rustfs_filemeta::FileMetaShallowVersion = rustfs_filemeta::FileMetaVersion::from(source_free.clone())
.try_into()
.expect("duplicate free metadata should encode");
metadata.versions.insert(target_index, duplicate_free);
tokio::fs::write(&conflict_path, metadata.marshal_msg().expect("conflict metadata should encode"))
.await
.expect("write sub-quorum conflict metadata");
let mut expected_target_metadata = Vec::with_capacity(4);
for disk_index in 0..4 {
let target_path = temp_dir
.path()
.join(format!("pool1/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let target_encoded = tokio::fs::read(&target_path)
.await
.expect("target fixture metadata should be readable before decommission");
let target_meta = FileMeta::load(&target_encoded).expect("target fixture metadata should decode");
let same_id = target_meta
.versions
.iter()
.filter(|version| version.header.version_id == Some(free_version))
.collect::<Vec<_>>();
if disk_index == 0 {
assert_eq!(same_id.len(), 2, "conflict fixture should contain both same-ID records");
assert!(same_id[0].header.free_version());
assert!(!same_id[1].header.free_version());
} else {
assert_eq!(same_id.len(), 1, "target fixture disk {disk_index} should contain one free version");
assert!(same_id[0].header.free_version());
}
expected_target_metadata.push(target_encoded);
}
mark_test_pool_decommissioning(&store, 0).await;
let source_set = store.pools[0].get_disks_by_key(object);
store
.decommission_entry_for_test_with_bucket_incarnation(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.clone(),
source_set.clone(),
)
.await
.expect("conflicted decommission entry should retain the source and retry later");
let source_versions = source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("retained source versions should decode")
.expect("source free version should be retained after conflict");
assert!(
source_versions
.versions
.iter()
.any(|version| { version.version_id == Some(free_version) && version.tier_free_version() })
);
let post_encoded = tokio::fs::read(&conflict_path)
.await
.expect("conflict metadata should remain readable");
let post_metadata = FileMeta::load(&post_encoded).expect("post-conflict metadata should decode");
let same_id = post_metadata
.versions
.iter()
.filter(|version| version.header.version_id == Some(free_version))
.collect::<Vec<_>>();
assert_eq!(same_id.len(), 2, "conflict metadata should retain both same-ID records");
assert_eq!(same_id.iter().filter(|version| version.header.free_version()).count(), 1);
assert_eq!(same_id.iter().filter(|version| !version.header.free_version()).count(), 1);
let post_conflict = same_id
.into_iter()
.find(|version| !version.header.free_version())
.expect("ordinary conflict version must remain addressable by the source ID");
let post_conflict_info = post_conflict
.into_fileinfo(&bucket, object, true)
.expect("post-conflict ordinary metadata should decode");
assert!(!post_conflict_info.deleted && !post_conflict_info.tier_free_version());
assert_eq!(post_conflict.meta, expected_conflict_meta);
assert_eq!(post_conflict_info.size, expected_conflict.size);
assert_eq!(post_conflict_info.data_dir, expected_conflict.data_dir);
assert_eq!(post_conflict_info.metadata, expected_conflict.metadata);
assert_eq!(post_conflict_info.get_etag(), expected_conflict.get_etag());
for (disk_index, expected_target_encoded) in expected_target_metadata.iter().enumerate() {
let target_path = temp_dir
.path()
.join(format!("pool1/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let target_encoded = tokio::fs::read(&target_path)
.await
.expect("target metadata should remain readable");
assert_eq!(
&target_encoded, expected_target_encoded,
"conflicted decommission must not mutate target disk {disk_index}"
);
let target_meta = FileMeta::load(&target_encoded).expect("target metadata should decode");
let same_id = target_meta
.versions
.iter()
.filter(|version| version.header.version_id == Some(free_version))
.collect::<Vec<_>>();
if disk_index == 0 {
assert_eq!(same_id.len(), 2);
assert!(same_id[0].header.free_version());
assert!(!same_id[1].header.free_version());
} else {
assert_eq!(same_id.len(), 1);
assert!(same_id[0].header.free_version());
}
}
let sweep_err = store
.check_after_decommission_for_test(0)
.await
.expect_err("final sweep must report the retained free version");
assert!(
sweep_err.to_string().contains("version(s) were found"),
"unexpected final sweep error: {sweep_err}"
);
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn versioned_batch_delete_marker_skips_decommission_source() {
let temp_dir = tempfile::tempdir().expect("create versioned batch decommission store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"versioned-batch-decommission-delete-fence",
&[4, 4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("vbatch-decom-delete-{}", uuid::Uuid::new_v4());
let object = "batch-object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create versioned batch decommission bucket");
let (_source_version, expected_source_versions) = migrate_versioned_decommission_test_object(
&store,
&bucket,
object,
b"batch source generation",
"test_versioned_batch_decommission_delete_fence",
)
.await;
let delete_config_snapshot =
Arc::new(crate::bucket::replication::DeleteReplicationConfigSnapshot::from_configs_for_test(
s3s::dto::VersioningConfiguration {
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
..Default::default()
},
None,
));
let delete_barrier = crate::store::object::VersionedDeleteMarkerCommitBarrier::install(&bucket, object);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_objects(
&delete_bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions {
delete_replication_config_snapshot: Some(delete_config_snapshot),
..Default::default()
},
)
.await
});
delete_barrier.wait_until_paused().await;
let source_set = store.pools[0].get_disks_by_key(object);
crate::data_movement::ensure_source_cleanup_versions_unchanged(
source_set,
&bucket,
object,
&expected_source_versions,
&[],
"test_versioned_batch_decommission_delete_fence",
)
.await
.expect("batch DELETE must not publish a marker to the suspended source");
delete_barrier.release();
let (deleted, errors) = delete.await.expect("versioned batch DELETE task should join");
assert!(errors.iter().all(Option::is_none), "versioned batch DELETE should succeed: {errors:?}");
assert_eq!(deleted.len(), 1);
assert!(deleted[0].delete_marker, "versioned batch DELETE must return a marker");
assert!(
deleted[0]
.delete_marker_version_id
.is_some_and(|version_id| !version_id.is_nil()),
"versioned batch DELETE marker must have a non-nil version ID"
);
let mut active_marker_count = 0;
for pool in store.pools.iter().skip(1) {
let Some(versions) = pool
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("active-pool versions should be readable")
else {
continue;
};
active_marker_count += versions.versions.iter().filter(|version| version.deleted).count();
}
assert_eq!(active_marker_count, 1, "batch DELETE must publish exactly one active-pool marker");
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn suspended_delete_marker_then_decommission_worker_converges_null_source() {
let temp_dir = tempfile::tempdir().expect("create suspended decommission DELETE store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"suspended-decommission-delete-convergence",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("suspended-decom-delete-{}", uuid::Uuid::new_v4());
let object = "single.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create suspended decommission DELETE bucket");
write_suspended_decommission_source(&store, &bucket, object).await;
mark_test_pool_decommissioning(&store, 0).await;
let delete_err = store
.delete_object(
&bucket,
object,
ObjectOptions {
version_suspended: true,
..Default::default()
},
)
.await
.expect_err("capacity-reserved target must reject a concurrent suspended DELETE");
assert!(
matches!(delete_err, Error::SlowDown),
"unexpected suspended DELETE result: {delete_err:?}"
);
assert_suspended_null_source_present(&store, &bucket, object).await;
let source_set = store.pools[0].get_disks_by_key(object);
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
worker_bucket,
source_set,
)
.await
})
.await
.expect("suspended decommission worker should join")
.expect("worker must migrate the fenced suspended source");
assert_decommission_source_absent(
&store,
&bucket,
object,
&ObjectOptions {
version_suspended: true,
..Default::default()
},
)
.await;
assert_eq!(
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
b"suspended source generation"
);
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn suspended_batch_delete_marker_then_decommission_worker_converges_null_source() {
let temp_dir = tempfile::tempdir().expect("create suspended batch decommission DELETE store dir");
let (_ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"suspended-batch-decommission-delete-convergence",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("susp-batch-decom-delete-{}", uuid::Uuid::new_v4());
let object = "batch.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create suspended batch decommission DELETE bucket");
write_suspended_decommission_source(&store, &bucket, object).await;
mark_test_pool_decommissioning(&store, 0).await;
let delete_config_snapshot =
Arc::new(crate::bucket::replication::DeleteReplicationConfigSnapshot::from_configs_for_test(
s3s::dto::VersioningConfiguration {
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::SUSPENDED)),
..Default::default()
},
None,
));
let (_deleted, errors) = store
.delete_objects(
&bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions {
delete_replication_config_snapshot: Some(delete_config_snapshot),
..Default::default()
},
)
.await;
assert!(
matches!(errors.as_slice(), [Some(Error::SlowDown)]),
"unexpected suspended batch DELETE result: {errors:?}"
);
assert_suspended_null_source_present(&store, &bucket, object).await;
let source_set = store.pools[0].get_disks_by_key(object);
let worker_store = Arc::clone(&store);
let worker_bucket = bucket.clone();
tokio::spawn(async move {
worker_store
.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
worker_bucket,
source_set,
)
.await
})
.await
.expect("suspended batch decommission worker should join")
.expect("worker must migrate the batch-fenced suspended source");
assert_decommission_source_absent(
&store,
&bucket,
object,
&ObjectOptions {
version_suspended: true,
..Default::default()
},
)
.await;
assert_eq!(
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
b"suspended source generation"
);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tiered_data_movement_rejects_a_stale_source_snapshot_before_target_write() {
let temp_dir = tempfile::tempdir().expect("create stale-source data movement store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "stale-tier-source", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "stale-tier-source-bucket";
let object = "stale-tier-source-object";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
register_mock_tier(&ctx.tier_config_mgr(), "STALE-TIER").await;
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should exist");
let version_id = uuid::Uuid::new_v4();
let stale = FileInfo {
volume: bucket.to_string(),
name: object.to_string(),
version_id: Some(version_id),
transition_status: rustfs_filemeta::TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/stale-source".to_string(),
transition_tier: "STALE-TIER".to_string(),
transition_version_id: Some(uuid::Uuid::new_v4()),
transition_version_state: rustfs_filemeta::TransitionVersionState::Exact,
data_dir: Some(uuid::Uuid::new_v4()),
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
size: 1,
metadata: HashMap::new(),
..Default::default()
};
let err = store
.decommission_tiered_object(
bucket,
object,
&stale,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
src_pool_idx: 0,
data_movement: true,
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
},
)
.await
.expect_err("a source removed after queue capture must fail closed");
assert!(matches!(err, Error::ObjectNotFound(_, _) | Error::FileNotFound));
let target_err = store.pools[1]
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect_err("stale source rejection must not write target metadata");
assert!(matches!(
target_err,
StorageError::ObjectNotFound(_, _) | StorageError::VersionNotFound(_, _, _)
));
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn data_movement_put_conflict_validates_only_selected_target_pool() {
let temp_dir = tempfile::tempdir().expect("create three-pool data movement store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "data-movement-selected-target", &[4, 4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("dm-selected-target-{}", uuid::Uuid::new_v4());
let object = "object.bin";
let source_body = b"source-and-equivalent".to_vec();
let conflicting_body = b"newer-conflicting-target".to_vec();
let source_mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
let version = uuid::Uuid::new_v4();
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create selected-target bucket");
for (pool_idx, body, mod_time) in [
(0, source_body.clone(), source_mod_time),
(1, conflicting_body.clone(), source_mod_time + time::Duration::SECOND),
(2, source_body.clone(), source_mod_time),
] {
let mut reader = PutObjReader::from_vec(body);
store.pools[pool_idx]
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed data movement pool");
}
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
let source_reader = store.pools[0]
.get_object_reader(
&bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read source for selected-target migration");
crate::data_movement::migrate_object(store.clone(), 0, bucket.clone(), source_reader, None, "test_selected_target")
.await
.expect_err("an equivalent object in another pool must not mask the selected target conflict");
let mut selected_target = store.pools[1]
.get_object_reader(
&bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
..Default::default()
},
)
.await
.expect("read selected conflicting target");
let mut selected_body = Vec::new();
selected_target
.stream
.read_to_end(&mut selected_body)
.await
.expect("drain selected conflicting target");
assert_eq!(selected_body, conflicting_body);
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn data_movement_target_remains_readable_before_source_cleanup() {
let temp_dir = tempfile::tempdir().expect("create data movement read-window store dir");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "data-movement-read-window", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("dm-read-window-{}", uuid::Uuid::new_v4());
let object = "object.bin";
let version = uuid::Uuid::new_v4();
let mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create data movement read-window bucket");
let mut reader = PutObjReader::from_vec(b"source body".to_vec());
store.pools[0]
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed data movement source");
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
let source_reader = store.pools[0]
.get_object_reader(
&bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read data movement source");
let source_data_dir = source_reader.object_info.data_dir;
crate::data_movement::migrate_object(store.clone(), 0, bucket.clone(), source_reader, None, "test_read_window")
.await
.expect("commit data movement target");
let target = store.pools[1]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
..Default::default()
},
)
.await
.expect("read committed data movement target");
assert_ne!(source_data_dir, target.data_dir, "the target must use its own data directory");
assert_eq!(
rustfs_utils::http::get_consistent_str(&target.user_defined, rustfs_utils::http::SUFFIX_DATA_MOVED),
Some("true")
);
let resolved = store
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
..Default::default()
},
)
.await
.expect("the equal-time migration window must remain readable");
assert_eq!(resolved.data_dir, target.data_dir);
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn data_movement_multipart_conflict_validates_exact_version_target_pool() {
let temp_dir = tempfile::tempdir().expect("create three-pool multipart data movement store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "data-movement-multipart-target", &[4, 4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("dm-multipart-target-{}", uuid::Uuid::new_v4());
let object = "object.bin";
let source_version = uuid::Uuid::new_v4();
let other_version = uuid::Uuid::new_v4();
let source_mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create multipart selected-target bucket");
let first_part_size = 5 * 1024 * 1024;
let mut source_body = vec![b'a'; first_part_size];
source_body.push(b'b');
for (pool_idx, version_id, body, mod_time) in [
(
1,
other_version,
b"newer different version".to_vec(),
source_mod_time + time::Duration::seconds(2),
),
(2, source_version, b"conflicting exact version".to_vec(), source_mod_time),
] {
let mut reader = PutObjReader::from_vec(body);
store.pools[pool_idx]
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed multipart data movement pool");
}
let source_reader = seed_data_movement_source_reader(
&store,
&bucket,
object,
DataMovementSourceSeed {
pool_idx: 0,
version_id: Some(source_version),
body: source_body,
multipart_split: Some(first_part_size),
mod_time: source_mod_time,
user_defined: HashMap::new(),
},
)
.await;
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(store.pools.len(), 0));
let completion_barrier = crate::store::multipart::DataMovementMultipartCompletionBarrier::install(&bucket);
let migration_store = store.clone();
let migration_bucket = bucket.clone();
let migration = tokio::spawn(async move {
crate::data_movement::migrate_object(
migration_store,
0,
migration_bucket,
source_reader,
None,
"test_multipart_selected_target",
)
.await
});
completion_barrier.wait_until_paused().await;
let unrelated_set = store.pools[1].get_disks_by_key(object);
let original_disks = {
let mut disks = unrelated_set.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(3) {
*disk = None;
}
original
};
drop(completion_barrier);
let err = migration
.await
.expect("multipart migration task should join")
.expect_err("the exact-version target conflict must not be bypassed by a newer different version");
*unrelated_set.disks.write().await = original_disks;
let rendered = err.to_string();
assert!(
rendered.contains("complete_multipart_upload"),
"the selected target should be reached despite an unrelated degraded pool: {rendered}"
);
assert!(
!rendered.contains("put_object_part failed"),
"part upload must not scan the unrelated degraded pool: {rendered}"
);
let unexpected_version = store.pools[1]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(source_version.to_string()),
..Default::default()
},
)
.await
.expect_err("multipart migration must not duplicate the source version into the latest-version pool");
assert!(matches!(
unexpected_version,
StorageError::ObjectNotFound(_, _) | StorageError::VersionNotFound(_, _, _)
));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-target-read-error", &[4, 4, 4]))
.await;
let job_id = uuid::Uuid::new_v4();
let job =
ManualTransitionJobRecord::new(job_id, "manual-target-read-error", &ManualTransitionRunOptions::default(), "owner");
let path = manual_transition_job_record_object_name(job_id).expect("manual job path should build");
let data = job.encode().expect("manual job should encode");
for pool in &store.pools {
com::save_config(pool.clone(), &path, data.clone())
.await
.expect("manual job fixture should persist in every pool");
}
store.pool_meta.write().await.pools[0].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
let failing_target = store.pools[2].get_disks_by_key(&path);
let original_disks = {
let mut disks = failing_target.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(3) {
*disk = None;
}
original
};
let error = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(0, store.pools[0].get_disks_by_key(&path), &path)
.await
.expect_err("one target read-quorum error must fail closed despite another target success")
.to_string();
*failing_target.disks.write().await = original_disks;
assert!(error.contains(&path));
assert!(error.contains("pool 2"));
assert_eq!(
com::read_config(store.pools[0].clone(), &path)
.await
.expect("target read error must retain the source"),
data
);
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("failed target verification should not create a receipt"),
0
);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-terminal-receipt", &[4, 4])).await;
let tier_name = "DECOMMISSION-RECEIPT";
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let entry = Jentry {
persisted_version: 0,
obj_name: "receipt-recovery-object".to_string(),
version_id: "receipt-recovery-version".to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
let path = tier_delete_journal_object_name(&entry);
let data = encode_tier_delete_journal_entry(&entry).expect("tier journal should encode");
com::save_config(store.pools[0].clone(), &path, data.clone())
.await
.expect("source tier journal should persist");
com::save_config(store.pools[1].clone(), &path, data.clone())
.await
.expect("target tier journal should persist");
let active_pool_meta = {
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
pool_meta.clone()
};
active_pool_meta
.save(store.pools.clone())
.await
.expect("active decommission run identity should persist");
let source_set = store.pools[0].get_disks_by_key(&path);
let barrier = SourceCleanupDeleteBarrier::install(RUSTFS_META_BUCKET, &path);
let cleanup_store = store.clone();
let cleanup_set = source_set.clone();
let cleanup_path = path.clone();
let cleanup = tokio::spawn(async move {
cleanup_store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(0, cleanup_set, &cleanup_path)
.await
});
barrier.wait_until_paused().await;
let original_source_disks = {
let mut disks = source_set.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(3) {
*disk = None;
}
original
};
barrier.release();
let cleanup_error = cleanup
.await
.expect("source cleanup task should not panic")
.expect_err("injected source delete quorum failure must fail cleanup")
.to_string();
*source_set.disks.write().await = original_source_disks;
drop(barrier);
assert!(cleanup_error.contains("source durable ILM cleanup failed"));
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("receipt should persist before source cleanup"),
1
);
assert_eq!(
com::read_config(store.pools[0].clone(), &path)
.await
.expect("failed cleanup must retain the source"),
data
);
let mut restarted_pool_meta = PoolMeta::default();
restarted_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("decommission run identity should reload after restart");
*store.pool_meta.write().await = restarted_pool_meta;
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("target recovery should commit terminal proof and delete the target");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 1, 0));
assert!(matches!(
com::read_config(store.pools[1].clone(), &path).await,
Err(Error::ConfigNotFound)
));
assert_eq!(
com::read_config(store.pools[0].clone(), &path)
.await
.expect("target recovery must not delete the decommission source"),
data
);
store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(0, source_set, &path)
.await
.expect("terminal receipt should authorize cleanup after target deletion");
assert!(matches!(
com::read_config(store.pools[0].clone(), &path).await,
Err(Error::ConfigNotFound)
));
assert!(backend.remove_versions().await.contains(&(entry.obj_name, entry.version_id)));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_final_sweep_blocks_cancel_until_source_cleanup_finishes() {
let temp_dir = tempfile::tempdir().expect("create final sweep gate store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-final-sweep-gate", &[4, 4])).await;
let job_id = uuid::Uuid::new_v4();
let job = ManualTransitionJobRecord::new(job_id, "final-sweep-gate", &ManualTransitionRunOptions::default(), "owner");
let path = manual_transition_job_record_object_name(job_id).expect("manual job path should build");
let data = job.encode().expect("manual job should encode");
for pool in &store.pools {
com::save_config(pool.clone(), &path, data.clone())
.await
.expect("manual job fixture should persist in both pools");
}
let active_pool_meta = {
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
pool_meta.clone()
};
active_pool_meta
.save(store.pools.clone())
.await
.expect("active decommission run identity should persist");
let barrier = SourceCleanupDeleteBarrier::install(RUSTFS_META_BUCKET, &path);
let final_sweep = tokio::spawn({
let store = store.clone();
async move { store.check_after_decommission_for_test(0).await }
});
barrier.wait_until_paused().await;
let mut cancel = tokio::spawn({
let store = store.clone();
async move { store.decommission_cancel(0).await }
});
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut cancel).await.is_err(),
"cancel must wait for the final sweep source cleanup"
);
{
let pool_meta = store.pool_meta.read().await;
let decommission = pool_meta.pools[0]
.decommission
.as_ref()
.expect("decommission state should remain present");
assert!(
decommission.canceled,
"cancel must publish its durable terminal state before waiting for the final sweep"
);
assert!(
decommission.start_time.is_none(),
"the durable cancel state must clear the active run identity before quiescence"
);
}
barrier.release();
final_sweep
.await
.expect("final sweep task should not panic")
.expect("final sweep should finish after the barrier releases");
cancel
.await
.expect("cancel task should not panic")
.expect("cancel should complete after the final sweep releases the operation gate");
let pool_meta = store.pool_meta.read().await;
let decommission = pool_meta.pools[0]
.decommission
.as_ref()
.expect("decommission state should remain present");
assert!(decommission.canceled);
assert!(decommission.start_time.is_none());
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_durable_ilm_recovery_keeps_multiple_active_sources() {
let temp_dir = tempfile::tempdir().expect("create multi-source recovery store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"durable-ilm-multi-source-recovery",
&[4, 4, 4],
))
.await;
let tier_name = "DECOMMISSION-MULTI-SOURCE";
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let entry = Jentry {
persisted_version: 0,
obj_name: "multi-source-recovery-object".to_string(),
version_id: "multi-source-recovery-version".to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
let path = tier_delete_journal_object_name(&entry);
let data = encode_tier_delete_journal_entry(&entry).expect("tier journal should encode");
for pool in &store.pools {
com::save_config(pool.clone(), &path, data.clone())
.await
.expect("source and target tier journals should persist");
}
let active_pool_meta = {
let mut pool_meta = store.pool_meta.write().await;
let start_time = OffsetDateTime::now_utc();
for pool_idx in [0, 1] {
pool_meta.pools[pool_idx].decommission = Some(PoolDecommissionInfo {
start_time: Some(start_time),
..Default::default()
});
}
pool_meta.clone()
};
active_pool_meta
.save(store.pools.clone())
.await
.expect("multiple active decommission runs should persist");
let mut restarted_pool_meta = PoolMeta::default();
restarted_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("multiple active decommission runs should reload");
*store.pool_meta.write().await = restarted_pool_meta;
let record = validate_durable_ilm_record(&path, &data).expect("tier journal should validate");
let source_zero_receipt = store
.persist_decommission_durable_ilm_receipt_for_test(0, 1, &path, &record, false)
.await
.expect("source pool zero receipt should persist on the other active source");
let source_one_receipt = store
.persist_decommission_durable_ilm_receipt_for_test(1, 0, &path, &record, false)
.await
.expect("source pool one receipt should persist on the other active source");
assert_ne!(
source_zero_receipt, source_one_receipt,
"active source runs must have distinct receipt paths"
);
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("cross-source receipts should not remove active source journals");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 1, 0));
for pool in &store.pools {
assert_eq!(
com::read_config(pool.clone(), &path)
.await
.expect("cross-source receipts alone must retain every journal copy"),
data
);
}
store
.persist_decommission_durable_ilm_receipt_for_test(0, 2, &path, &record, false)
.await
.expect("source pool zero receipt should persist on the target");
store
.persist_decommission_durable_ilm_receipt_for_test(1, 2, &path, &record, false)
.await
.expect("source pool one receipt should persist on the target");
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("multi-source tier journal recovery should complete");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 1, 0));
assert_eq!(
com::read_config(store.pools[0].clone(), &path)
.await
.expect("first active source must remain after target recovery"),
data
);
assert_eq!(
com::read_config(store.pools[1].clone(), &path)
.await
.expect("second active source must remain after target recovery"),
data
);
assert!(matches!(
com::read_config(store.pools[2].clone(), &path).await,
Err(Error::ConfigNotFound)
));
assert!(backend.remove_versions().await.contains(&(entry.obj_name, entry.version_id)));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page() {
const RECEIPT_COUNT: usize = 1001;
let temp_dir = tempfile::tempdir().expect("create paginated receipt store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-receipt-pages", &[4, 4])).await;
store.pool_meta.write().await.pools[0].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::now_utc()),
..Default::default()
});
futures::stream::iter(0..RECEIPT_COUNT)
.map(|index| {
let store = store.clone();
async move {
let id = format!("{index:064x}");
let source_path = format!("ilm/tier-delete-journal/{id}.json");
let record = ValidatedDurableIlmRecord {
namespace: "tier-delete-journal",
id_kind: "operation_id",
id,
checkpoint: DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256: format!("{:064x}", index + RECEIPT_COUNT),
identity_sha256: "f".repeat(64),
committed: false,
dispatch_identity_sha256: None,
state: None,
},
};
store
.persist_decommission_durable_ilm_receipt_for_test(0, 0, &source_path, &record, true)
.await?;
store
.persist_decommission_durable_ilm_receipt_for_test(0, 1, &source_path, &record, true)
.await?;
Ok::<(), Error>(())
}
})
.buffer_unordered(32)
.try_collect::<Vec<_>>()
.await
.expect("more than one receipt page should persist");
store
.persist_decommission_durable_ilm_manifest_for_test(0)
.await
.expect("paginated source receipts should produce a manifest");
let target_receipts = store
.decommission_durable_ilm_receipt_paths_for_test(0)
.await
.expect("paginated target receipts should list");
assert_eq!(target_receipts.len(), RECEIPT_COUNT);
let (target_pool_idx, second_page_path) = target_receipts
.get(1000)
.cloned()
.expect("the real 1000-item page boundary should expose a second page receipt");
let receipt_bytes = com::read_config(store.pools[target_pool_idx].clone(), &second_page_path)
.await
.expect("second page receipt should be readable");
com::delete_config(store.pools[target_pool_idx].clone(), &second_page_path)
.await
.expect("second page receipt should delete");
let missing = store
.complete_decommission(0)
.await
.expect_err("a missing second page receipt must block completion")
.to_string();
assert!(missing.contains(&second_page_path));
assert!(
!store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("source pool should remain in decommission")
.complete
);
assert!(com::read_config(store.pools[0].clone(), &second_page_path).await.is_ok());
let full_tail = ObjectOptions {
max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
};
com::save_config_with_opts(store.pools[target_pool_idx].clone(), &second_page_path, receipt_bytes.clone(), &full_tail)
.await
.expect("second page receipt should restore");
com::save_config_with_opts(store.pools[target_pool_idx].clone(), &second_page_path, b"{corrupt".to_vec(), &full_tail)
.await
.expect("second page receipt should corrupt deterministically");
let corrupt = store
.complete_decommission(0)
.await
.expect_err("a corrupt second page receipt must block completion")
.to_string();
assert!(corrupt.contains(&second_page_path));
assert!(corrupt.contains("invalid"));
assert!(
!store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("source pool should remain in decommission")
.complete
);
assert!(com::read_config(store.pools[0].clone(), &second_page_path).await.is_ok());
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn decommission_migrates_and_verifies_registered_durable_ilm_records() {
std::thread::Builder::new()
.name("durable-ilm-decommission-test".to_string())
.stack_size(16 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("durable ILM decommission runtime should build");
runtime.block_on(decommission_migrates_and_verifies_registered_durable_ilm_records_scenario());
})
.expect("durable ILM decommission scenario thread should spawn")
.join()
.expect("durable ILM decommission scenario should not panic");
}
#[cfg(feature = "test-util")]
async fn decommission_migrates_and_verifies_registered_durable_ilm_records_scenario() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-decommission", &[4, 4])).await;
let tier_name = "DECOMMISSION-ILM";
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let tier_entry = Jentry {
persisted_version: 0,
obj_name: "decommissioned-remote-object".to_string(),
version_id: "decommissioned-remote-version".to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
let tier_path = tier_delete_journal_object_name(&tier_entry);
let tier_bytes = encode_tier_delete_journal_entry(&tier_entry).expect("tier journal should encode");
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Versioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transition transaction should build");
let regressed_transaction = transaction.clone();
transaction
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
.expect("transition transaction should advance before migration");
let transaction_path = transition_transaction_record_object_name(transaction.transaction_id)
.expect("transition transaction path should build");
let transaction_bytes = transaction.encode().expect("transition transaction should encode");
let manual_job_id = uuid::Uuid::new_v4();
let manual_bucket = format!("manual-decommission-{}", manual_job_id.simple());
let manual_options = ManualTransitionRunOptions {
prefix: "logs/".to_string(),
tier: Some(tier_name.to_string()),
..Default::default()
};
let mut manual_job = ManualTransitionJobRecord::new(manual_job_id, &manual_bucket, &manual_options, "old-owner");
manual_job.scan_completed = true;
manual_job.report.enqueued = 1;
manual_job.lease_expires_at_unix_nanos = 0;
let manual_scope = ManualTransitionScopeAdmission::from_job(&manual_job);
let task_key = manual_transition_worker_result_task_key(&manual_bucket, "logs/a", None);
let manual_task = ManualTransitionTaskRecord::new(manual_job_id, &task_key, &manual_bucket, "logs/a", None, tier_name);
let manual_result =
ManualTransitionWorkerResultRecord::new(manual_job_id, &task_key, ManualTransitionWorkerResult::Completed);
let manual_job_path = manual_transition_job_record_object_name(manual_job_id).expect("manual job path should build");
let manual_scope_path =
manual_transition_scope_record_object_name(&manual_scope.scope_key).expect("manual scope path should build");
let manual_task_path =
manual_transition_task_object_name(manual_job_id, &task_key).expect("manual task path should build");
let manual_result_path = manual_transition_worker_result_object_name(manual_job_id, &task_key)
.expect("manual worker result path should build");
let manual_job_bytes = manual_job.encode().expect("manual job should encode");
let manual_scope_bytes = serde_json::to_vec(&manual_scope).expect("manual scope should encode");
let manual_task_bytes = manual_task.encode().expect("manual task should encode");
let manual_result_bytes = manual_result.encode().expect("manual result should encode");
let records = vec![
(tier_path.clone(), tier_bytes.clone()),
(transaction_path.clone(), transaction_bytes.clone()),
(manual_job_path.clone(), manual_job_bytes.clone()),
(manual_scope_path.clone(), manual_scope_bytes.clone()),
(manual_task_path.clone(), manual_task_bytes.clone()),
(manual_result_path.clone(), manual_result_bytes.clone()),
];
for (path, data) in &records {
com::save_config(store.pools[0].clone(), path, data.clone())
.await
.expect("durable ILM source record should persist");
}
let legacy_queue = [com::CONFIG_PREFIX, BUCKET_META_PREFIX]
.into_iter()
.map(|prefix| {
DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: prefix.to_string(),
}
.to_string()
})
.collect();
let legacy_pool_meta = {
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
queued: true,
queued_buckets: legacy_queue,
..Default::default()
});
pool_meta.clone()
};
legacy_pool_meta
.save(store.pools.clone())
.await
.expect("legacy decommission queue should persist before restart");
let mut restarted_pool_meta = PoolMeta::default();
restarted_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("legacy decommission queue should reload after restart");
*store.pool_meta.write().await = restarted_pool_meta;
set_test_decommission_capacity_override(&store, 0);
store
.promote_queued_decommission_for_test(0)
.await
.expect("legacy queued decommission should resume");
let expected_ilm_queue = DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: ILM_META_PREFIX.to_string(),
}
.to_string();
{
let pool_meta = store.pool_meta.read().await;
let decommission = pool_meta.pools[0]
.decommission
.as_ref()
.expect("decommission state should remain present");
assert!(!decommission.queued);
assert!(decommission.queued_buckets.contains(&expected_ilm_queue));
}
let ilm_bucket = DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: ILM_META_PREFIX.to_string(),
};
for _ in 0..2 {
store
.decommission_pool_for_test(CancellationToken::new(), 0, store.pools[0].clone(), ilm_bucket.clone())
.await
.expect("durable ILM decommission should be idempotent");
}
for (path, expected) in &records {
assert_eq!(
com::read_config(store.pools[0].clone(), path)
.await
.expect("source should remain until the final sweep"),
*expected
);
assert_eq!(
com::read_config(store.pools[1].clone(), path)
.await
.expect("target should contain the migrated record"),
*expected
);
}
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("no receipt should exist before the final sweep"),
0
);
let isolated_tier_stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("tier recovery should retain a terminal record until its receipt is committed");
assert!(isolated_tier_stats.scanned >= 1);
assert_eq!(isolated_tier_stats.deleted, 0);
assert!(isolated_tier_stats.failed >= 1);
assert_eq!(
com::read_config(store.pools[1].clone(), &tier_path)
.await
.expect("receipt isolation must retain the target tier journal"),
tier_bytes
);
com::delete_config(store.pools[1].clone(), &manual_job_path)
.await
.expect("target manual job should delete");
let missing = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_job_path),
&manual_job_path,
)
.await
.expect_err("missing target must block source cleanup");
let missing = missing.to_string();
assert!(missing.contains(&manual_job_path) && missing.contains(&manual_job_id.to_string()));
assert_eq!(
com::read_config(store.pools[0].clone(), &manual_job_path)
.await
.expect("missing target must retain source"),
manual_job_bytes
);
com::save_config(store.pools[1].clone(), &manual_job_path, manual_job_bytes.clone())
.await
.expect("target manual job should restore");
com::save_config(store.pools[1].clone(), &transaction_path, b"{corrupt".to_vec())
.await
.expect("target transaction should corrupt deterministically");
let corrupt = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&transaction_path),
&transaction_path,
)
.await
.expect_err("corrupt target must block source cleanup");
let corrupt = corrupt.to_string();
assert!(corrupt.contains(&transaction_path) && corrupt.contains(&transaction.transaction_id.to_string()));
assert_eq!(
com::read_config(store.pools[0].clone(), &transaction_path)
.await
.expect("corrupt target must retain source"),
transaction_bytes
);
com::save_config(store.pools[1].clone(), &transaction_path, transaction_bytes.clone())
.await
.expect("target transaction should restore");
com::save_config(store.pools[1].clone(), &manual_scope_path, manual_scope_bytes.clone())
.await
.expect("target scope rewrite should invalidate cached metadata before the quorum check");
let target_scope_set = store.pools[1].get_disks_by_key(&manual_scope_path);
let original_target_scope_disks = {
let mut disks = target_scope_set.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(3) {
*disk = None;
}
original
};
let quorum_error = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_scope_path),
&manual_scope_path,
)
.await
.expect_err("target below read quorum must block source cleanup");
*target_scope_set.disks.write().await = original_target_scope_disks;
let quorum_error = quorum_error.to_string();
assert!(quorum_error.contains(&manual_scope_path) && quorum_error.contains(&manual_job_id.to_string()));
assert!(com::read_config(store.pools[0].clone(), &manual_scope_path).await.is_ok());
com::save_config(store.pools[1].clone(), &manual_task_path, manual_task_bytes.clone())
.await
.expect("target task rewrite should invalidate cached metadata before the quorum check");
let target_task_set = store.pools[1].get_disks_by_key(&manual_task_path);
let original_target_task_disks = {
let mut disks = target_task_set.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(2) {
*disk = None;
}
original
};
let receipt_quorum_error = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_task_path),
&manual_task_path,
)
.await
.expect_err("target read quorum without receipt write quorum must retain the source");
*target_task_set.disks.write().await = original_target_task_disks;
let receipt_quorum_error = receipt_quorum_error.to_string();
assert!(receipt_quorum_error.contains("receipt"));
assert!(receipt_quorum_error.contains(&manual_task_path));
assert!(receipt_quorum_error.contains(&manual_job_id.to_string()));
assert!(com::read_config(store.pools[0].clone(), &manual_task_path).await.is_ok());
store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_task_path),
&manual_task_path,
)
.await
.expect("healthy target should persist the receipt before source cleanup");
let unknown_path = "ilm/future-durable/jobs/one.json";
com::save_config(store.pools[0].clone(), unknown_path, b"{}".to_vec())
.await
.expect("unknown durable ILM record should persist for the guard test");
let unknown_migration = store
.decommission_pool_for_test(CancellationToken::new(), 0, store.pools[0].clone(), ilm_bucket)
.await
.expect_err("unregistered durable ILM namespace must block migration");
assert!(unknown_migration.to_string().contains(unknown_path));
let unknown_final_sweep = store
.check_after_decommission_for_test(0)
.await
.expect_err("unregistered durable ILM namespace must block completion");
assert!(unknown_final_sweep.to_string().contains(unknown_path));
com::delete_config(store.pools[0].clone(), unknown_path)
.await
.expect("unknown guard fixture should be removed before the successful final sweep");
store
.check_after_decommission_for_test(0)
.await
.expect("production final sweep should validate every target before cleanup");
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("durable ILM receipts should be listable"),
records.len(),
"every cleaned source record must have a durable validation receipt"
);
for (path, expected) in &records {
assert!(
matches!(com::read_config(store.pools[0].clone(), path).await, Err(Error::ConfigNotFound)),
"final sweep should remove the validated source `{path}`"
);
assert_eq!(
com::read_config(store.pools[1].clone(), path)
.await
.expect("final sweep must preserve the target"),
*expected
);
}
let mut crash_restarted_pool_meta = PoolMeta::default();
crash_restarted_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("pool metadata should reload after the simulated pre-complete crash");
*store.pool_meta.write().await = crash_restarted_pool_meta;
let (manual_job_receipt_pool, manual_job_receipt_path) = store
.decommission_durable_ilm_receipt_paths_for_test(0)
.await
.expect("durable ILM receipt paths should be listable")
.into_iter()
.find(|(_, path)| path.contains(&manual_job_path))
.expect("manual job should have one target receipt");
let manual_job_receipt_bytes = com::read_config(store.pools[manual_job_receipt_pool].clone(), &manual_job_receipt_path)
.await
.expect("manual job receipt should be readable before deletion");
com::delete_config(store.pools[manual_job_receipt_pool].clone(), &manual_job_receipt_path)
.await
.expect("manual job receipt should delete after source cleanup");
com::delete_config(store.pools[1].clone(), &manual_job_path)
.await
.expect("post-crash target manual job should delete");
let missing_after_crash = store
.complete_decommission(0)
.await
.expect_err("completion must reject a missing target after source cleanup and restart")
.to_string();
assert!(missing_after_crash.contains("receipt"));
assert!(missing_after_crash.contains(&manual_job_path));
assert!(missing_after_crash.contains(&manual_job_id.to_string()));
assert!(
!store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("decommission state should survive restart")
.complete
);
com::save_config(store.pools[1].clone(), &manual_job_path, manual_job_bytes.clone())
.await
.expect("post-crash target manual job should restore");
com::save_config(
store.pools[manual_job_receipt_pool].clone(),
&manual_job_receipt_path,
manual_job_receipt_bytes,
)
.await
.expect("manual job receipt should restore after the missing-receipt check");
com::save_config(store.pools[1].clone(), &transaction_path, b"{corrupt".to_vec())
.await
.expect("post-crash target transaction should corrupt deterministically");
let corrupt_after_crash = store
.complete_decommission(0)
.await
.expect_err("completion must reject a corrupt target after source cleanup and restart")
.to_string();
assert!(corrupt_after_crash.contains(&transaction_path));
assert!(corrupt_after_crash.contains(&transaction.transaction_id.to_string()));
com::save_config(store.pools[1].clone(), &transaction_path, transaction_bytes.clone())
.await
.expect("post-crash target transaction should restore");
let mut wrong_manual_job = manual_job.clone();
wrong_manual_job.job_id = uuid::Uuid::new_v4();
com::save_config(
store.pools[1].clone(),
&manual_job_path,
wrong_manual_job.encode().expect("wrong-id job should encode"),
)
.await
.expect("post-crash target manual job should accept the wrong-id fixture");
let wrong_id_after_crash = store
.complete_decommission(0)
.await
.expect_err("completion must reject a target record with the wrong id")
.to_string();
assert!(wrong_id_after_crash.contains(&manual_job_path));
assert!(wrong_id_after_crash.contains(&manual_job_id.to_string()));
com::save_config(store.pools[1].clone(), &manual_job_path, manual_job_bytes.clone())
.await
.expect("post-crash target manual job should restore after the wrong-id check");
com::save_config(
store.pools[1].clone(),
&transaction_path,
regressed_transaction
.encode()
.expect("regressed transition transaction should encode"),
)
.await
.expect("post-crash target transaction should accept the regression fixture");
let regression_after_crash = store
.complete_decommission(0)
.await
.expect_err("completion must reject a lower transition transaction revision")
.to_string();
assert!(regression_after_crash.contains("generation mismatch"));
assert!(regression_after_crash.contains(&transaction_path));
assert!(regression_after_crash.contains(&transaction.transaction_id.to_string()));
com::save_config(store.pools[1].clone(), &transaction_path, transaction_bytes.clone())
.await
.expect("post-crash target transaction should restore after the regression check");
let (manual_task_receipt_pool, manual_task_receipt_path) = store
.decommission_durable_ilm_receipt_paths_for_test(0)
.await
.expect("durable ILM receipt paths should be listable")
.into_iter()
.find(|(_, path)| path.contains(&manual_task_path))
.expect("manual task receipt should retain its reversible source path");
let manual_task_receipt_bytes =
com::read_config(store.pools[manual_task_receipt_pool].clone(), &manual_task_receipt_path)
.await
.expect("manual task receipt should be readable before corruption");
com::save_config(
store.pools[manual_task_receipt_pool].clone(),
&manual_task_receipt_path,
b"{corrupt".to_vec(),
)
.await
.expect("manual task receipt should corrupt deterministically");
let corrupt_receipt = store
.complete_decommission(0)
.await
.expect_err("completion must fail closed on a corrupt receipt")
.to_string();
assert!(corrupt_receipt.contains(&manual_task_path));
assert!(corrupt_receipt.contains(&manual_job_id.to_string()));
com::save_config(
store.pools[manual_task_receipt_pool].clone(),
&manual_task_receipt_path,
manual_task_receipt_bytes,
)
.await
.expect("manual task receipt should restore after the corruption check");
let tier_stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("tier journal recovery should consume the migrated record before completion");
assert_eq!((tier_stats.scanned, tier_stats.deleted, tier_stats.failed), (1, 1, 0));
assert!(matches!(com::read_config(store.clone(), &tier_path).await, Err(Error::ConfigNotFound)));
let recovered_transition_version = "recovered-transition-version".to_string();
backend
.set_transition_candidate_probe_override(Some(TransitionCandidateProbe::VersionedPresent(
recovered_transition_version.clone(),
)))
.await;
let transaction_stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition recovery should advance and consume the migrated transaction before completion");
backend.set_transition_candidate_probe_override(None).await;
assert_eq!(
(
transaction_stats.scanned,
transaction_stats.recovered,
transaction_stats.retained,
transaction_stats.failed,
),
(1, 1, 0, 0)
);
assert!(matches!(
com::read_config(store.clone(), &transaction_path).await,
Err(Error::ConfigNotFound)
));
com::save_config(
store.pools[1].clone(),
&transaction_path,
regressed_transaction
.encode()
.expect("post-terminal transition rollback should encode"),
)
.await
.expect("target should accept the post-terminal rollback fixture");
let post_terminal_regression = store
.complete_decommission(0)
.await
.expect_err("terminal proof must not mask a lower transition revision")
.to_string();
assert!(post_terminal_regression.contains("generation mismatch"));
assert!(post_terminal_regression.contains(&transaction_path));
assert!(post_terminal_regression.contains(&transaction.transaction_id.to_string()));
com::delete_config(store.pools[1].clone(), &transaction_path)
.await
.expect("post-terminal rollback fixture should be removed");
let manual_stats = recover_manual_transition_jobs_once(store.clone(), 100, None)
.await
.expect("manual recovery should advance the migrated job and consume its scope before completion");
assert_eq!(
(manual_stats.scanned, manual_stats.resumed, manual_stats.skipped, manual_stats.failed,),
(1, 1, 0, 0)
);
assert!(matches!(
com::read_config(store.clone(), &manual_scope_path).await,
Err(Error::ConfigNotFound)
));
let recovered_manual_job_bytes = com::read_config(store.pools[1].clone(), &manual_job_path)
.await
.expect("manual recovery should retain the advanced job record");
assert_ne!(recovered_manual_job_bytes, manual_job_bytes);
com::save_config(store.pools[1].clone(), &manual_job_path, manual_job_bytes.clone())
.await
.expect("target manual job should accept the rollback fixture");
let manual_regression = store
.complete_decommission(0)
.await
.expect_err("completion must reject a manual job generation rollback")
.to_string();
assert!(manual_regression.contains("generation mismatch"));
assert!(manual_regression.contains(&manual_job_path));
assert!(manual_regression.contains(&manual_job_id.to_string()));
com::save_config(store.pools[1].clone(), &manual_job_path, recovered_manual_job_bytes)
.await
.expect("target manual job should restore its recovered generation");
store
.complete_decommission(0)
.await
.expect("completion should persist before receipt cleanup");
assert!(
store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("completed decommission state should remain present")
.complete
);
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("receipt cleanup should be observable"),
0
);
store
.cleanup_decommission_durable_ilm_receipts_for_test(0)
.await
.expect("receipt cleanup should be idempotent");
let removed_versions = backend.remove_versions().await;
assert!(removed_versions.contains(&(tier_entry.obj_name.clone(), tier_entry.version_id.clone())));
assert!(removed_versions.contains(&(transaction.remote_object.clone(), recovered_transition_version)));
}
#[cfg(feature = "test-util")]
async fn tier_delete_journal_count(store: Arc<crate::store::ECStore>) -> usize {
store
.list_objects_v2(RUSTFS_META_BUCKET, TIER_DELETE_JOURNAL_PREFIX, None, None, 100, false, None, false)
.await
.expect("tier delete journal should be listable")
.objects
.len()
}
#[cfg(feature = "test-util")]
async fn tier_delete_dispatch_manifest_count(store: Arc<crate::store::ECStore>) -> usize {
store
.list_objects_v2(
RUSTFS_META_BUCKET,
TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
None,
None,
100,
false,
None,
false,
)
.await
.expect("tier delete dispatch manifests should be listable")
.objects
.len()
}
#[cfg(feature = "test-util")]
async fn install_committed_tier_delete_journals(
store: Arc<crate::store::ECStore>,
tier_name: &str,
backend_identity: [u8; 32],
count: usize,
) -> Vec<Jentry> {
let mut entries = Vec::with_capacity(count);
for index in 0..count {
let entry = Jentry {
persisted_version: 0,
obj_name: format!("remote/pass-drain-{index:06}.bin"),
version_id: uuid::Uuid::new_v4().to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
persist_tier_delete_journal_entry(store.clone(), &entry)
.await
.expect("committed tier-delete journal fixture should persist");
entries.push(entry);
}
entries
}
#[cfg(feature = "test-util")]
fn synthetic_v6_dispatch_entry(
bucket: &str,
object: &str,
tier_name: &str,
backend_identity: [u8; 32],
remote_version: &str,
) -> Jentry {
Jentry {
persisted_version: 0,
obj_name: format!("remote/{object}"),
version_id: remote_version.to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Prepared,
source: Some(TierDeleteSourceIdentity {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: Some(uuid::Uuid::new_v4().to_string()),
versioned: true,
version_suspended: false,
data_dir: Some(uuid::Uuid::new_v4().to_string()),
etag: Some(format!("etag-{object}")),
mod_time: Some(OffsetDateTime::UNIX_EPOCH.to_string()),
}),
dispatch: None,
}
}
#[cfg(feature = "test-util")]
async fn install_aborting_dispatch_fixture(
store: Arc<crate::store::ECStore>,
bucket: &str,
incarnation: uuid::Uuid,
prefix: &str,
tier_name: &str,
backend_identity: [u8; 32],
journal_count: usize,
) -> (String, Vec<Jentry>) {
let entries = (0..journal_count)
.map(|index| {
(
synthetic_v6_dispatch_entry(
bucket,
&format!("{prefix}{index:06}.bin"),
tier_name,
backend_identity,
&uuid::Uuid::new_v4().to_string(),
),
Some(TierDeleteJournalState::Prepared),
)
})
.collect();
install_test_tier_delete_dispatch_fixture(
store,
bucket,
incarnation,
prefix,
entries,
TierDeleteDispatchManifestState::Aborting,
)
.await
.expect("Aborting rollback fixture should persist")
}
#[cfg(feature = "test-util")]
async fn drive_tier_delete_dispatch_restart_to_convergence(store: Arc<crate::store::ECStore>) {
tokio::time::timeout(Duration::from_secs(30), async {
let mut stable_empty_observations = 0;
loop {
recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("restarted manifest recovery pass should finish");
recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("restarted journal recovery pass should finish");
if tier_delete_journal_count(store.clone()).await == 0
&& tier_delete_dispatch_manifest_count(store.clone()).await == 0
&& tier_delete_dispatch_manifest_recovery_count_for_test(&store) == 0
{
stable_empty_observations += 1;
if stable_empty_observations == 10 {
break;
}
} else {
stable_empty_observations = 0;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("same-disk restart recovery should converge without retained dispatch state");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_delete_recovery_pass_drains_multiple_fast_manifest_pages() {
const MANIFEST_COUNT: usize = 10;
let temp_dir = tempfile::tempdir().expect("create fast manifest pass recovery store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-fast-manifest-pass", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "tier-delete-fast-manifest-pass-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("fast manifest pass bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("fast manifest pass bucket incarnation should resolve");
let tier_name = "FAST-MANIFEST-PASS";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("fast manifest pass tier lease should resolve")
.backend_identity();
for index in 0..MANIFEST_COUNT {
install_aborting_dispatch_fixture(
store.clone(),
bucket,
incarnation,
&format!("manifest-page-{index:06}/"),
tier_name,
backend_identity,
1,
)
.await;
}
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, MANIFEST_COUNT);
assert_eq!(tier_delete_journal_count(store.clone()).await, MANIFEST_COUNT);
let cancel = CancellationToken::new();
let mut manifest_marker = None;
let mut journal_marker = None;
let stats = recover_test_tier_delete_journal_pass_with_budget(
store.clone(),
&cancel,
&mut manifest_marker,
&mut journal_marker,
Duration::from_secs(30),
)
.await;
assert!(!stats.canceled);
assert!(!stats.deadline_exhausted);
assert!(
stats.manifest_pages > 1,
"one production pass must cross the default eight-manifest page limit"
);
assert_eq!(stats.manifests.scanned, MANIFEST_COUNT);
assert_eq!(stats.manifests.deleted, MANIFEST_COUNT);
assert_eq!(stats.manifests.failed, 0);
assert_eq!(manifest_marker, None);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
assert_eq!(tier_delete_journal_count(store).await, 0);
assert_eq!(backend.remove_count().await, 0, "rollback recovery must not call the remote tier");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_delete_recovery_pass_drains_multiple_fast_journal_pages() {
const JOURNAL_COUNT: usize = 25;
let temp_dir = tempfile::tempdir().expect("create fast pass recovery store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-fast-pass", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "FAST-PASS-RECOVERY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("fast pass tier lease should resolve")
.backend_identity();
let entries = install_committed_tier_delete_journals(store.clone(), tier_name, backend_identity, JOURNAL_COUNT).await;
assert_eq!(tier_delete_journal_count(store.clone()).await, JOURNAL_COUNT);
let cancel = CancellationToken::new();
let mut manifest_marker = None;
let mut journal_marker = None;
let stats = recover_test_tier_delete_journal_pass_with_budget(
store.clone(),
&cancel,
&mut manifest_marker,
&mut journal_marker,
Duration::from_secs(30),
)
.await;
assert!(!stats.canceled);
assert!(!stats.deadline_exhausted);
assert!(
stats.journal_pages > 1,
"one production pass must cross the default eight-record page limit"
);
assert_eq!(stats.journals.scanned, JOURNAL_COUNT);
assert_eq!(stats.journals.deleted, JOURNAL_COUNT);
assert_eq!(stats.journals.failed, 0);
assert_eq!(journal_marker, None);
assert_eq!(tier_delete_journal_count(store).await, 0);
assert_eq!(backend.exact_remove_count(), JOURNAL_COUNT);
let mut removed = backend.remove_versions().await;
removed.sort();
let mut expected = entries
.into_iter()
.map(|entry| (entry.obj_name, entry.version_id))
.collect::<Vec<_>>();
expected.sort();
assert_eq!(removed, expected);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_delete_recovery_pass_rotates_failed_journal_pages_without_busy_loop() {
const JOURNAL_COUNT: usize = 9;
let temp_dir = tempfile::tempdir().expect("create failed pass recovery store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-delete-failed-pass", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "FAILED-PASS-RECOVERY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("failed pass tier lease should resolve")
.backend_identity();
install_committed_tier_delete_journals(store.clone(), tier_name, backend_identity, JOURNAL_COUNT).await;
backend.set_remove_failure(true);
let cancel = CancellationToken::new();
let mut manifest_marker = None;
let mut journal_marker = None;
let first = recover_test_tier_delete_journal_pass_with_budget(
store.clone(),
&cancel,
&mut manifest_marker,
&mut journal_marker,
Duration::from_secs(30),
)
.await;
assert_eq!(first.journal_pages, 1, "a no-progress failed page must stop the pass");
assert_eq!(first.journals.scanned, 8);
assert_eq!(first.journals.deleted, 0);
assert_eq!(first.journals.failed, 8);
assert!(journal_marker.is_some(), "a truncated failed page must retain its continuation marker");
assert_eq!(tier_delete_journal_count(store.clone()).await, JOURNAL_COUNT);
let second = recover_test_tier_delete_journal_pass_with_budget(
store.clone(),
&cancel,
&mut manifest_marker,
&mut journal_marker,
Duration::from_secs(30),
)
.await;
assert_eq!(second.journal_pages, 1);
assert_eq!(second.journals.scanned, 1);
assert_eq!(second.journals.deleted, 0);
assert_eq!(second.journals.failed, 1);
assert_eq!(
journal_marker, None,
"end-of-list rotation must revisit the failed prefix on a later pass"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, JOURNAL_COUNT);
backend.set_remove_failure(false);
let third = recover_test_tier_delete_journal_pass_with_budget(
store.clone(),
&cancel,
&mut manifest_marker,
&mut journal_marker,
Duration::from_secs(30),
)
.await;
assert_eq!(third.journals.scanned, JOURNAL_COUNT);
assert_eq!(third.journals.deleted, JOURNAL_COUNT);
assert_eq!(third.journals.failed, 0);
assert_eq!(tier_delete_journal_count(store).await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_delete_journal_rejects_unbound_new_prepare_without_persisting() {
let temp_dir = tempfile::tempdir().expect("create journal admission store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-journal-unbound-prepare", &[4])).await;
let entry = Jentry {
persisted_version: 0,
obj_name: "remote/object".to_string(),
version_id: uuid::Uuid::new_v4().to_string(),
tier_name: "COLD-A".to_string(),
backend_identity: Some([7; 32]),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Prepared,
source: Some(TierDeleteSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4().to_string()),
versioned: true,
version_suspended: false,
data_dir: Some(uuid::Uuid::new_v4().to_string()),
etag: Some("source-etag".to_string()),
mod_time: Some(OffsetDateTime::UNIX_EPOCH.to_string()),
}),
dispatch: None,
};
let error = persist_tier_delete_journal_entry(store.clone(), &entry)
.await
.expect_err("a new source-owning journal without a v6 manifest binding must fail closed");
assert!(error.to_string().contains("dispatch manifest binding"), "unexpected error: {error}");
assert_eq!(tier_delete_journal_count(store).await, 0, "rejected prepare must not write a journal");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_recovery_rolls_back_unapproved_crash_states_without_remote_io() {
let temp_dir = tempfile::tempdir().expect("create dispatch rollback store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-manifest-rollback", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-manifest-rollback-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("dispatch rollback bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should resolve");
let tier_name = "DISPATCH-ROLLBACK";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let preparing = [
("preparing/a.bin", TierDeleteJournalState::Prepared),
("preparing/b.bin", TierDeleteJournalState::Dispatched),
]
.into_iter()
.map(|(object, state)| {
(
synthetic_v6_dispatch_entry(bucket, object, tier_name, identity, &uuid::Uuid::new_v4().to_string()),
Some(state),
)
})
.collect();
install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"preparing/",
preparing,
TierDeleteDispatchManifestState::Preparing,
)
.await
.expect("Preparing crash fixture should persist");
let aborting = [
("aborting/missing.bin", None),
("aborting/prepared.bin", Some(TierDeleteJournalState::Prepared)),
("aborting/dispatched.bin", Some(TierDeleteJournalState::Dispatched)),
]
.into_iter()
.map(|(object, state)| {
(
synthetic_v6_dispatch_entry(bucket, object, tier_name, identity, &uuid::Uuid::new_v4().to_string()),
state,
)
})
.collect();
install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"aborting/",
aborting,
TierDeleteDispatchManifestState::Aborting,
)
.await
.expect("Aborting crash fixture should persist");
install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"empty/",
Vec::new(),
TierDeleteDispatchManifestState::Completed,
)
.await
.expect("empty Completed crash fixture should persist");
let deleted = tokio::time::timeout(Duration::from_secs(30), async {
let mut deleted = 0;
loop {
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("manifest recovery should reconcile negative-seal crash states");
deleted += stats.deleted;
if tier_delete_dispatch_manifest_count(store.clone()).await == 0 {
break deleted;
}
// A different process-global test can briefly revoke the
// fleet proof. Recovery must fail closed for that pass and
// converge after the proof generation is republished.
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("valid negative-seal crash states should eventually converge");
assert_eq!(deleted, 3);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(store).await, 0);
assert_eq!(
backend.remove_count().await,
0,
"rollback recovery must perform zero remote delete operations"
);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_recovery_quarantines_impossible_committed_rollback_set() {
let temp_dir = tempfile::tempdir().expect("create dispatch quarantine store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-manifest-quarantine", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-manifest-quarantine-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("dispatch quarantine bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should resolve");
let tier_name = "DISPATCH-QUARANTINE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let entries = [TierDeleteJournalState::Prepared, TierDeleteJournalState::Committed]
.into_iter()
.enumerate()
.map(|(index, state)| {
(
synthetic_v6_dispatch_entry(
bucket,
&format!("quarantine/{index}.bin"),
tier_name,
identity,
&uuid::Uuid::new_v4().to_string(),
),
Some(state),
)
})
.collect();
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"quarantine/",
entries,
TierDeleteDispatchManifestState::Aborting,
)
.await
.expect("impossible rollback fixture should persist");
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("quarantined manifest scan should finish");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 0, 1));
assert_eq!(
tier_delete_journal_count(store.clone()).await,
2,
"validation must precede every deletion"
);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 1);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store, &manifest_name)
.await
.expect("quarantined manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Aborting)
);
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_rollback_bounded_concurrency_reaches_tail_behind_slow_member() {
const JOURNAL_COUNT: usize = 65;
let temp_dir = tempfile::tempdir().expect("create bounded rollback store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "bounded-dispatch-rollback", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "bounded-dispatch-rollback-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bounded rollback bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bounded rollback bucket incarnation should resolve");
let tier_name = "BOUNDED-DISPATCH-ROLLBACK";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("bounded rollback tier lease should resolve")
.backend_identity();
let (manifest_name, entries) =
install_aborting_dispatch_fixture(store.clone(), bucket, incarnation, "slow/", tier_name, identity, JOURNAL_COUNT)
.await;
let first_name = tier_delete_journal_object_name(entries.first().expect("slow rollback fixture should not be empty"));
let last_name = tier_delete_journal_object_name(entries.last().expect("slow rollback fixture should not be empty"));
let hook = TierDeleteDispatchRollbackTestHook::install_slow_delete(&first_name, &last_name);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker =
tokio::spawn(async move { recover_test_tier_delete_dispatch_manifest(worker_store, &worker_manifest).await });
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_paused())
.await
.expect("the first rollback member should reach the slow hook");
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_observed())
.await
.expect("a member beyond the first two concurrency windows should remain reachable");
assert!(
(2..=32).contains(&hook.max_in_flight()),
"rollback delete concurrency must make progress without exceeding its bound; observed {}",
hook.max_in_flight()
);
hook.release_delete();
worker
.await
.expect("bounded rollback recovery task should join")
.expect("bounded rollback recovery should converge after the slow member releases");
drop(hook);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
assert_eq!(backend.remove_count().await, 0, "rollback must not call the remote tier");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn aborted_dispatch_retry_defers_residual_scan_to_recovery() {
const JOURNAL_COUNT: usize = 65;
let temp_dir = tempfile::tempdir().expect("create aborted retry store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "aborted-dispatch-retry", &[4])).await;
shutdown.cancel();
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "aborted-dispatch-retry-bucket";
let prefix = "archive/";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("aborted retry bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("aborted retry bucket incarnation should resolve");
let tier_name = "ABORTED-DISPATCH-RETRY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("aborted retry tier lease should resolve")
.backend_identity();
let aborted_entries = (0..JOURNAL_COUNT)
.map(|index| {
(
synthetic_v6_dispatch_entry(
bucket,
&format!("{prefix}{index:06}.bin"),
tier_name,
identity,
&uuid::Uuid::new_v4().to_string(),
),
Some(TierDeleteJournalState::Prepared),
)
})
.collect::<Vec<_>>();
let entries = aborted_entries.iter().map(|(entry, _)| entry.clone()).collect::<Vec<_>>();
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
prefix,
aborted_entries,
TierDeleteDispatchManifestState::Aborted,
)
.await
.expect("Aborted retry fixture should persist");
let lifecycle_guard = store
.acquire_bucket_lifecycle_write_lock(bucket)
.await
.expect("aborted retry should acquire the bucket lifecycle fence");
let mut fence_opts = ObjectOptions::default();
fence_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
let bucket_fence = fence_opts
.bucket_lifecycle_lock_fence
.clone()
.expect("aborted retry should capture the bucket lifecycle fence");
let fleet_proof = acquire_tier_delete_journal_fleet_proof().expect("aborted retry fixture should have a fleet proof");
let hook = TierDeleteDispatchMemberReadTestHook::install_pause(TierDeleteDispatchMemberReadTestStage::Validation);
let error =
match prepare_tier_delete_dispatch(store.clone(), bucket, incarnation, prefix, entries, fleet_proof, &bucket_fence)
.await
{
Ok(_) => panic!("an Aborted manifest must be retained for bounded recovery cleanup"),
Err(err) => err,
};
assert!(
error.to_string().contains("bounded recovery cleanup"),
"unexpected aborted retry error: {error}"
);
assert_eq!(
hook.entry_count(),
0,
"request retry must not scan the retained Aborted journal set under bucket write lock"
);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("retained Aborted manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Aborted)
);
assert_eq!(tier_delete_journal_count(store.clone()).await, JOURNAL_COUNT);
assert_eq!(backend.remove_count().await, 0, "retry must not call the remote tier");
drop(hook);
drop(lifecycle_guard);
recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect("bounded recovery should clean the retained Aborted manifest");
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(store).await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_page_timeout_detaches_one_deduplicated_worker_until_convergence() {
let temp_dir = tempfile::tempdir().expect("create detached rollback store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "detached-dispatch-rollback", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "detached-dispatch-rollback-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("detached rollback bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("detached rollback bucket incarnation should resolve");
let tier_name = "DETACHED-DISPATCH-ROLLBACK";
let _backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("detached rollback tier lease should resolve")
.backend_identity();
let (manifest_name, entries) =
install_aborting_dispatch_fixture(store.clone(), bucket, incarnation, "detached/", tier_name, identity, 1).await;
let journal_name = tier_delete_journal_object_name(&entries[0]);
let hook = TierDeleteDispatchRollbackTestHook::install_slow_delete(&journal_name, &journal_name);
assert!(
recover_test_tier_delete_dispatch_manifest_with_page_budget(
store.clone(),
&manifest_name,
Duration::from_millis(10),
)
.await,
"the synthetic page budget must elapse while the manifest worker continues"
);
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_paused())
.await
.expect("the detached manifest worker should continue into its delete phase");
assert!(
tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name),
"the detached worker must retain its per-store/object deduplication guard"
);
assert!(
!recover_test_tier_delete_dispatch_manifest_with_page_budget(store.clone(), &manifest_name, Duration::from_secs(30),)
.await,
"a duplicate page observation should be retained without queuing another worker"
);
hook.release_delete();
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let manifest_gone = matches!(com::read_config(store.clone(), &manifest_name).await, Err(Error::ConfigNotFound));
if manifest_gone && !tier_delete_dispatch_manifest_recovery_inflight_for_test(&store, &manifest_name) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("the detached manifest worker should release its guard after convergence");
drop(hook);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_detached_workers_are_store_bounded_and_shutdown_cancellable() {
const MANIFEST_COUNT: usize = 6;
const WORKER_LIMIT: usize = 4;
let temp_dir = tempfile::tempdir().expect("create bounded detached rollback store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "bounded-detached-dispatch-rollback", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "BOUNDED-DETACHED-DISPATCH-ROLLBACK";
let _backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("bounded detached rollback tier lease should resolve")
.backend_identity();
let mut manifest_names = Vec::with_capacity(MANIFEST_COUNT);
for index in 0..MANIFEST_COUNT {
let bucket = format!("bounded-detached-dispatch-rollback-{index}");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bounded detached rollback bucket should be created");
let incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("bounded detached rollback bucket incarnation should resolve");
let (manifest_name, _) = install_aborting_dispatch_fixture(
store.clone(),
&bucket,
incarnation,
&format!("detached-{index}/"),
tier_name,
identity,
1,
)
.await;
manifest_names.push(manifest_name);
}
let hook = TierDeleteDispatchRollbackTestHook::install_pause_all_deletes();
let first_pass = futures::stream::iter(manifest_names.iter().cloned().map(|manifest_name| {
let store = store.clone();
async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(store, &manifest_name, Duration::from_millis(10))
.await
}
}))
.buffer_unordered(MANIFEST_COUNT)
.collect::<Vec<_>>()
.await;
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_pause_count(WORKER_LIMIT))
.await
.expect("every admitted detached worker should pause before its first mutation");
assert_eq!(
first_pass.into_iter().filter(|timed_out| *timed_out).count(),
WORKER_LIMIT,
"only the per-store worker budget may detach"
);
assert_eq!(
tier_delete_dispatch_manifest_recovery_count_for_test(&store),
WORKER_LIMIT,
"detached workers must remain bounded per store"
);
let repeated_pass = futures::stream::iter(manifest_names.iter().cloned().map(|manifest_name| {
let store = store.clone();
async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(store, &manifest_name, Duration::from_secs(30)).await
}
}))
.buffer_unordered(MANIFEST_COUNT)
.collect::<Vec<_>>()
.await;
assert!(
repeated_pass.into_iter().all(|timed_out| !timed_out),
"duplicates and over-budget manifests must be retained without a waiter queue"
);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), WORKER_LIMIT);
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
tier_delete_dispatch_manifest_recovery_count_for_test(&store),
WORKER_LIMIT,
"shutdown must not drop a bounded batch before its admitted work drains"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, MANIFEST_COUNT);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, MANIFEST_COUNT);
hook.release_all_deletes();
tokio::time::timeout(Duration::from_secs(30), async {
while tier_delete_dispatch_manifest_recovery_count_for_test(&store) != 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("shutdown should cancel every detached worker and release its registry permit");
assert_eq!(tier_delete_journal_count(store.clone()).await, MANIFEST_COUNT);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, MANIFEST_COUNT);
drop(hook);
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
tier_delete_journal_count(store.clone()).await,
MANIFEST_COUNT,
"releasing the old hook after shutdown must not resume a cancelled mutation"
);
assert_eq!(
tier_delete_dispatch_manifest_recovery_count_for_test(&store),
0,
"the shutdown store must leave no registry permits behind"
);
drop(store);
drop(ctx);
let (_restarted_ctx, restarted_store, restarted_shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"bounded-detached-dispatch-rollback-restart",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
assert!(!restarted_shutdown.is_cancelled(), "the restarted store needs a fresh shutdown token");
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let active = tier_delete_dispatch_manifest_recovery_count_for_test(&restarted_store);
let manifests = tier_delete_dispatch_manifest_count(restarted_store.clone()).await;
if active > 0 || manifests < MANIFEST_COUNT {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the restarted store should start a fresh manifest recovery");
tokio::time::timeout(Duration::from_secs(30), async {
while tier_delete_dispatch_manifest_recovery_count_for_test(&restarted_store) != 0 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("the restarted store recovery should release every worker permit");
if tier_delete_dispatch_manifest_count(restarted_store.clone()).await != 0 {
let stats = recover_tier_delete_dispatch_manifests(restarted_store.clone(), 100, None)
.await
.expect("the restarted store should retry any conservatively retained manifest");
assert_eq!(stats.failed, 0, "the restarted recovery must not quarantine a valid manifest");
}
assert_eq!(tier_delete_journal_count(restarted_store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(restarted_store).await, 0);
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_shutdown_stops_aborting_validation_tail_reads() {
const JOURNAL_COUNT: usize = 65;
const ADMITTED_READS: usize = 32;
let temp_dir = tempfile::tempdir().expect("create validation-read shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-validation-read-shutdown", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-validation-read-shutdown-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("validation-read shutdown bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("validation-read shutdown bucket incarnation should resolve");
let tier_name = "VALIDATION-READ-SHUTDOWN";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("validation-read shutdown tier lease should resolve")
.backend_identity();
let (manifest_name, _) =
install_aborting_dispatch_fixture(store.clone(), bucket, incarnation, "archive/", tier_name, identity, JOURNAL_COUNT)
.await;
let hook = TierDeleteDispatchMemberReadTestHook::install_pause(TierDeleteDispatchMemberReadTestStage::Validation);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker = tokio::spawn(async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(worker_store, &worker_manifest, Duration::from_secs(30))
.await
});
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_read_pause_count(ADMITTED_READS))
.await
.expect("the first validation read window should be admitted");
assert_eq!(hook.entry_count(), ADMITTED_READS);
assert_eq!(hook.in_flight(), ADMITTED_READS);
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(hook.entry_count(), ADMITTED_READS, "shutdown must not admit validation tail reads");
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 1);
assert!(tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await);
assert!(crate::services::notification_sys::tier_delete_journal_fleet_proof_has_inflight_for_test());
assert_eq!(tier_delete_journal_count(store.clone()).await, JOURNAL_COUNT);
assert_eq!(backend.remove_count().await, 0);
hook.release_all_reads();
assert!(
!worker.await.expect("validation-read shutdown worker should join"),
"cooperative shutdown should retain an unvalidated rollback"
);
assert_eq!(hook.entry_count(), ADMITTED_READS);
assert_eq!(hook.in_flight(), 0);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 0);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
JOURNAL_COUNT,
"no rollback DELETE may start"
);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("retained Aborting manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Aborting)
);
drop(hook);
drop(store);
drop(ctx);
let (_restarted_ctx, restarted_store, restarted_shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"dispatch-validation-read-shutdown-restart",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
drive_tier_delete_dispatch_restart_to_convergence(restarted_store.clone()).await;
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_shutdown_stops_authorized_tail_reads_before_receipt_or_cas() {
const JOURNAL_COUNT: usize = 65;
const ADMITTED_READS: usize = 32;
let temp_dir = tempfile::tempdir().expect("create authorized-read shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-authorized-read-shutdown", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-authorized-read-shutdown-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("authorized-read shutdown bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("authorized-read shutdown bucket incarnation should resolve");
let tier_name = "AUTHORIZED-READ-SHUTDOWN";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("authorized-read shutdown tier lease should resolve")
.backend_identity();
let entries = (0..JOURNAL_COUNT)
.map(|index| {
(
synthetic_v6_dispatch_entry(
bucket,
&format!("archive/{index:06}.bin"),
tier_name,
identity,
&uuid::Uuid::new_v4().to_string(),
),
Some(TierDeleteJournalState::Committed),
)
})
.collect();
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
entries,
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized-read shutdown fixture should persist");
let tier_config = ctx
.tier_config_mgr()
.read()
.await
.tiers
.get(tier_name)
.cloned()
.expect("authorized-read tier config should remain available for restart");
let hook = TierDeleteDispatchMemberReadTestHook::install_pause(TierDeleteDispatchMemberReadTestStage::Authorized);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker = tokio::spawn(async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(worker_store, &worker_manifest, Duration::from_secs(30))
.await
});
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_read_pause_count(ADMITTED_READS))
.await
.expect("the first authorized read window should be admitted");
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(hook.entry_count(), ADMITTED_READS, "shutdown must not admit authorized tail reads");
assert_eq!(hook.in_flight(), ADMITTED_READS);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 1);
assert!(tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await);
assert!(crate::services::notification_sys::tier_delete_journal_fleet_proof_has_inflight_for_test());
hook.release_all_reads();
assert!(
!worker.await.expect("authorized-read shutdown worker should join"),
"cooperative shutdown should retain an unscanned authorized manifest"
);
assert_eq!(hook.entry_count(), ADMITTED_READS);
assert_eq!(hook.in_flight(), 0);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 0);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("retained Authorized manifest should remain readable"),
Some(TierDeleteDispatchManifestState::DispatchAuthorized),
"cancellation before member reads must prevent the Completed CAS"
);
assert_eq!(
hook.authorized_progress_count(),
0,
"cancellation before member reads must not attempt a decommission progress receipt"
);
assert_eq!(backend.remove_count().await, 0);
drop(hook);
drop(store);
drop(ctx);
let (restarted_ctx, restarted_store, restarted_shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"dispatch-authorized-read-shutdown-restart",
&[4],
))
.await;
{
let tier_config_mgr = restarted_ctx.tier_config_mgr();
let mut manager = tier_config_mgr.write().await;
manager.tiers.insert(tier_name.to_string(), tier_config);
manager
.install_test_driver(tier_name, Box::new(backend.clone()))
.expect("the exact authorized-read tier driver should reinstall after restart");
}
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
drive_tier_delete_dispatch_restart_to_convergence(restarted_store.clone()).await;
assert_eq!(backend.remove_count().await, JOURNAL_COUNT);
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_completion_cas_is_bounded_and_reaches_the_tail() {
const JOURNAL_COUNT: usize = 65;
const ADMITTED_COMMITS: usize = 32;
let temp_dir = tempfile::tempdir().expect("create dispatch completion concurrency store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-completion-concurrency", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-completion-concurrency-bucket";
let prefix = "archive/";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("dispatch completion concurrency bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("dispatch completion concurrency bucket incarnation should resolve");
let tier_name = "DISPATCH-COMPLETION-CONCURRENCY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
backend.set_remove_failure(true);
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("dispatch completion concurrency tier lease should resolve")
.backend_identity();
let entries = (0..JOURNAL_COUNT)
.map(|index| {
synthetic_v6_dispatch_entry(
bucket,
&format!("{prefix}{index:06}.bin"),
tier_name,
identity,
&uuid::Uuid::new_v4().to_string(),
)
})
.collect::<Vec<_>>();
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
prefix,
entries
.iter()
.cloned()
.map(|entry| (entry, Some(TierDeleteJournalState::Dispatched)))
.collect(),
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("dispatch completion concurrency fixture should persist");
let lifecycle_guard = store
.acquire_bucket_lifecycle_write_lock(bucket)
.await
.expect("dispatch completion should acquire the bucket lifecycle fence");
let mut fence_opts = ObjectOptions::default();
fence_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
let bucket_fence = fence_opts
.bucket_lifecycle_lock_fence
.clone()
.expect("dispatch completion should capture the bucket lifecycle fence");
let fleet_proof =
acquire_tier_delete_journal_fleet_proof().expect("dispatch completion concurrency fixture should have a fleet proof");
let prepared =
prepare_tier_delete_dispatch(store.clone(), bucket, incarnation, prefix, entries, fleet_proof, &bucket_fence)
.await
.expect("the existing Authorized dispatch should reload");
let active = prepared
.consume(bucket, incarnation, prefix)
.expect("the existing Authorized dispatch permit should be consumable");
active
.authorization()
.mark_mutation_started(bucket, incarnation, prefix)
.expect("the completion test should mark its synthetic local mutation");
let hook = TierDeleteDispatchMemberReadTestHook::install_pause(TierDeleteDispatchMemberReadTestStage::Completion);
let worker_store = store.clone();
let worker = tokio::spawn(async move { complete_tier_delete_dispatch(worker_store, &active, &bucket_fence).await });
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_read_pause_count(ADMITTED_COMMITS))
.await
.expect("the first completion CAS window should be admitted");
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(hook.entry_count(), ADMITTED_COMMITS, "completion must not admit an unbounded CAS tail");
assert_eq!(hook.in_flight(), ADMITTED_COMMITS);
hook.release_all_reads();
worker
.await
.expect("dispatch completion concurrency worker should join")
.expect("every bounded completion CAS should succeed");
assert_eq!(
hook.entry_count(),
JOURNAL_COUNT,
"completion must eventually admit the entire journal set"
);
assert_eq!(hook.in_flight(), 0);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("completed concurrency manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Completed)
);
drop(hook);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_shutdown_stops_completed_missing_journal_tail_reads() {
const JOURNAL_COUNT: usize = 65;
const ADMITTED_READS: usize = 32;
let temp_dir = tempfile::tempdir().expect("create completed-read shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-completed-read-shutdown", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-completed-read-shutdown-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("completed-read shutdown bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("completed-read shutdown bucket incarnation should resolve");
let tier_name = "COMPLETED-READ-SHUTDOWN";
let _backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("completed-read shutdown tier lease should resolve")
.backend_identity();
let entries = (0..JOURNAL_COUNT)
.map(|index| {
(
synthetic_v6_dispatch_entry(
bucket,
&format!("archive/{index:06}.bin"),
tier_name,
identity,
&uuid::Uuid::new_v4().to_string(),
),
None,
)
})
.collect();
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
entries,
TierDeleteDispatchManifestState::Completed,
)
.await
.expect("completed-read shutdown fixture should persist");
let hook = TierDeleteDispatchMemberReadTestHook::install_pause(TierDeleteDispatchMemberReadTestStage::Completed);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker = tokio::spawn(async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(worker_store, &worker_manifest, Duration::from_secs(30))
.await
});
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_read_pause_count(ADMITTED_READS))
.await
.expect("the first Completed read window should be admitted");
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(hook.entry_count(), ADMITTED_READS, "shutdown must not admit later Completed chunks");
assert_eq!(hook.in_flight(), ADMITTED_READS);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 1);
assert!(tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await);
assert!(crate::services::notification_sys::tier_delete_journal_fleet_proof_has_inflight_for_test());
hook.release_all_reads();
assert!(
!worker.await.expect("completed-read shutdown worker should join"),
"cooperative shutdown should retain a partially scanned Completed manifest"
);
assert_eq!(hook.entry_count(), ADMITTED_READS);
assert_eq!(hook.in_flight(), 0);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 0);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(
tier_delete_dispatch_manifest_count(store.clone()).await,
1,
"manifest DELETE must not run"
);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("retained Completed manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Completed)
);
drop(hook);
drop(store);
drop(ctx);
let (_restarted_ctx, restarted_store, restarted_shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"dispatch-completed-read-shutdown-restart",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
drive_tier_delete_dispatch_restart_to_convergence(restarted_store.clone()).await;
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_shutdown_drains_preparing_cas_before_releasing_fences() {
let temp_dir = tempfile::tempdir().expect("create preparing CAS shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-preparing-cas-shutdown", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-preparing-cas-shutdown-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("preparing CAS shutdown bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("preparing CAS shutdown bucket incarnation should resolve");
let entry = synthetic_v6_dispatch_entry(
bucket,
"archive/preparing-cas.bin",
"PREPARING-CAS-SHUTDOWN",
[31; 32],
&uuid::Uuid::new_v4().to_string(),
);
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
vec![(entry, Some(TierDeleteJournalState::Prepared))],
TierDeleteDispatchManifestState::Preparing,
)
.await
.expect("preparing CAS shutdown fixture should persist");
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
RUSTFS_META_BUCKET,
&manifest_name,
crate::set_disk::PutObjectCommitPause::BeforeQuotaRename,
);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker = tokio::spawn(async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(worker_store, &worker_manifest, Duration::from_secs(30))
.await
});
barrier.wait_until_paused().await;
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
tier_delete_dispatch_manifest_recovery_count_for_test(&store),
1,
"a paused CAS must retain its registry permit after shutdown"
);
assert!(
tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await,
"a paused CAS must retain its manifest operation lock after shutdown"
);
assert!(
crate::services::notification_sys::tier_delete_journal_fleet_proof_has_inflight_for_test(),
"a paused CAS must retain its fleet-proof generation permit"
);
barrier.release();
assert!(
!worker.await.expect("preparing CAS shutdown worker should join"),
"cooperative shutdown should retain the partially advanced manifest"
);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 0);
assert!(
!tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await,
"the operation lock may release only after the admitted CAS drains"
);
assert!(
com::read_config(store.clone(), &manifest_name).await.is_ok(),
"shutdown must retain the durable manifest for restart replay"
);
drop(barrier);
drop(store);
drop(ctx);
let (_restarted_ctx, restarted_store, restarted_shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"dispatch-preparing-cas-shutdown-restart",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
tokio::time::timeout(Duration::from_secs(30), async {
let mut stable_empty_observations = 0;
loop {
if tier_delete_journal_count(restarted_store.clone()).await == 0
&& tier_delete_dispatch_manifest_count(restarted_store.clone()).await == 0
&& tier_delete_dispatch_manifest_recovery_count_for_test(&restarted_store) == 0
{
stable_empty_observations += 1;
if stable_empty_observations == 10 {
break;
}
} else {
stable_empty_observations = 0;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("the restarted background recovery should replay the drained Preparing CAS");
assert_eq!(tier_delete_journal_count(restarted_store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(restarted_store).await, 0);
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_shutdown_drains_admitted_delete_batch_without_tail_admission() {
const JOURNAL_COUNT: usize = 65;
const ADMITTED_BATCH: usize = 32;
let temp_dir = tempfile::tempdir().expect("create delete-batch shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-delete-batch-shutdown", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-delete-batch-shutdown-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("delete-batch shutdown bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("delete-batch shutdown bucket incarnation should resolve");
let (manifest_name, entries) = install_aborting_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
"DELETE-BATCH-SHUTDOWN",
[32; 32],
JOURNAL_COUNT,
)
.await;
let mut journal_names = entries.iter().map(tier_delete_journal_object_name).collect::<Vec<_>>();
journal_names.sort();
let published_delete_name = journal_names[0].clone();
let hook = TierDeleteDispatchRollbackTestHook::install_pause_all_except_delete(&published_delete_name);
let barrier =
crate::set_disk::DeleteObjectCommitBarrier::install_after_publish(RUSTFS_META_BUCKET, &published_delete_name);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker = tokio::spawn(async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(worker_store, &worker_manifest, Duration::from_secs(30))
.await
});
barrier.wait_until_paused().await;
tokio::time::timeout(Duration::from_secs(30), hook.wait_until_delete_pause_count(ADMITTED_BATCH - 1))
.await
.expect("the rest of the bounded delete batch should pause before mutation");
assert_eq!(hook.delete_entry_count(), ADMITTED_BATCH);
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 1);
assert!(
tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await,
"the partially published DELETE must retain its operation lock"
);
assert!(
crate::services::notification_sys::tier_delete_journal_fleet_proof_has_inflight_for_test(),
"the partially published DELETE must retain its fleet-proof permit"
);
assert_eq!(
hook.delete_entry_count(),
ADMITTED_BATCH,
"shutdown must stop admitting the tail behind the bounded batch"
);
barrier.release();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
tier_delete_dispatch_manifest_recovery_count_for_test(&store),
1,
"the worker must retain its guards until every admitted sibling drains"
);
hook.release_all_deletes();
assert!(
!worker.await.expect("delete-batch shutdown worker should join"),
"cooperative shutdown should retain the partial rollback for restart"
);
assert_eq!(hook.delete_entry_count(), ADMITTED_BATCH);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 0);
assert!(
tier_delete_journal_count(store.clone()).await >= JOURNAL_COUNT - 1,
"only the already-published DELETE may commit before restart"
);
drop(barrier);
drop(hook);
drop(store);
drop(ctx);
let (_restarted_ctx, restarted_store, restarted_shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-delete-batch-shutdown-restart", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
tokio::time::timeout(Duration::from_secs(30), async {
let mut stable_empty_observations = 0;
loop {
if tier_delete_journal_count(restarted_store.clone()).await == 0
&& tier_delete_dispatch_manifest_count(restarted_store.clone()).await == 0
&& tier_delete_dispatch_manifest_recovery_count_for_test(&restarted_store) == 0
{
stable_empty_observations += 1;
if stable_empty_observations == 10 {
break;
}
} else {
stable_empty_observations = 0;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("the restarted background recovery should replay the partially drained delete batch");
assert_eq!(tier_delete_journal_count(restarted_store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(restarted_store).await, 0);
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatch_manifest_rollback_retries_delete_and_confirmation_failures() {
const JOURNAL_COUNT: usize = 40;
let temp_dir = tempfile::tempdir().expect("create rollback retry store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-rollback-retry", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-rollback-retry-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("rollback retry bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("rollback retry bucket incarnation should resolve");
let tier_name = "DISPATCH-ROLLBACK-RETRY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("rollback retry tier lease should resolve")
.backend_identity();
let (delete_manifest, delete_entries) = install_aborting_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"delete-failure/",
tier_name,
identity,
JOURNAL_COUNT,
)
.await;
let delete_failure_name = tier_delete_journal_object_name(&delete_entries[10]);
let delete_hook = TierDeleteDispatchRollbackTestHook::install_delete_failure(&delete_failure_name);
let delete_error = recover_test_tier_delete_dispatch_manifest(store.clone(), &delete_manifest)
.await
.expect_err("an injected member delete failure must retain the manifest")
.to_string();
assert!(delete_error.contains("injected tier delete dispatch rollback delete failure"));
assert!(
tier_delete_journal_count(store.clone()).await > 0,
"the failed member and all work not admitted after the first error must remain retryable"
);
drop(delete_hook);
recover_test_tier_delete_dispatch_manifest(store.clone(), &delete_manifest)
.await
.expect("the delete-stage retry should converge");
let (confirmation_manifest, confirmation_entries) = install_aborting_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"confirmation-failure/",
tier_name,
identity,
JOURNAL_COUNT,
)
.await;
let confirmation_failure_name = tier_delete_journal_object_name(&confirmation_entries[10]);
let confirmation_hook = TierDeleteDispatchRollbackTestHook::install_confirmation_failure(&confirmation_failure_name);
let confirmation_error = recover_test_tier_delete_dispatch_manifest(store.clone(), &confirmation_manifest)
.await
.expect_err("an injected confirmation failure must retain the manifest")
.to_string();
assert!(confirmation_error.contains("injected tier delete dispatch rollback confirmation failure"));
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"confirmation failure occurs only after the bounded delete phase drains"
);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 1);
drop(confirmation_hook);
recover_test_tier_delete_dispatch_manifest(store.clone(), &confirmation_manifest)
.await
.expect("the confirmation-stage retry should converge over missing members");
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
assert_eq!(backend.remove_count().await, 0, "rollback retries must never call the remote tier");
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn v6_decommission_checkpoint_no_lock_put_rejects_lost_publication_fence() {
run_large_stack_async_test(
"v6-checkpoint-fence-loss",
v6_decommission_checkpoint_no_lock_put_rejects_lost_publication_fence_case,
);
}
#[cfg(feature = "test-util")]
async fn v6_decommission_checkpoint_no_lock_put_rejects_lost_publication_fence_case() {
let temp_dir = tempfile::tempdir().expect("create v6 checkpoint fence-loss store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "v6-checkpoint-fence-loss", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "v6-checkpoint-fence-loss-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("checkpoint fence-loss bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("checkpoint fence-loss bucket incarnation should resolve");
let entry = synthetic_v6_dispatch_entry(
bucket,
"archive/fence-loss.bin",
"FENCE-LOSS-TIER",
[18; 32],
&uuid::Uuid::new_v4().to_string(),
);
let (manifest_name, entries) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
vec![(entry, Some(TierDeleteJournalState::Prepared))],
TierDeleteDispatchManifestState::Preparing,
)
.await
.expect("checkpoint fence-loss fixture should persist");
let journal_name = tier_delete_journal_object_name(&entries[0]);
let manifest_data = com::read_config(store.clone(), &manifest_name)
.await
.expect("checkpoint fence-loss manifest should be readable");
let journal_data = com::read_config(store.clone(), &journal_name)
.await
.expect("checkpoint fence-loss journal should be readable");
for pool in &store.pools {
com::save_config(pool.clone(), &manifest_name, manifest_data.clone())
.await
.expect("manifest copy should persist in each checkpoint fence-loss pool");
com::save_config(pool.clone(), &journal_name, journal_data.clone())
.await
.expect("journal copy should persist in each checkpoint fence-loss pool");
}
mark_test_pool_decommissioning(&store, 0).await;
for (path, data) in [
(&manifest_name, manifest_data.as_slice()),
(&journal_name, journal_data.as_slice()),
] {
let record = validate_durable_ilm_record(path, data).expect("checkpoint fence-loss v6 record should validate");
store
.persist_decommission_durable_ilm_receipt_for_test(0, 1, path, &record, false)
.await
.expect("checkpoint fence-loss receipt should persist");
}
let loss_hook = crate::store::object::DecommissionMutationFenceLossHook::install(
RUSTFS_META_BUCKET,
&manifest_name,
crate::store::object::DecommissionMutationFenceTestPhase::Migration,
);
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
RUSTFS_META_BUCKET,
&manifest_name,
crate::set_disk::PutObjectCommitPause::BeforeQuotaRename,
);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker =
tokio::spawn(async move { recover_test_tier_delete_dispatch_manifest(worker_store, &worker_manifest).await });
barrier.wait_until_paused().await;
loss_hook.mark_lost();
barrier.release();
drop(barrier);
let error = worker
.await
.expect("checkpoint fence-loss recovery task should join")
.expect_err("lost publication fence must reject the no-lock checkpoint PUT")
.to_string();
assert!(
error.contains("lock") || error.contains("fence"),
"unexpected checkpoint fence-loss error: {error}"
);
assert_eq!(
com::read_config(store.pools[1].clone(), &manifest_name)
.await
.expect("fenced target manifest should remain readable"),
manifest_data,
"the target checkpoint must not commit after its publication fence is lost"
);
drop(loss_hook);
recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect("checkpoint recovery should converge after a fresh fence is acquired");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn v6_decommission_checkpoint_partial_target_failure_retries_without_advancing_receipts() {
let temp_dir = tempfile::tempdir().expect("create v6 partial checkpoint store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "v6-partial-checkpoint", &[4, 4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "v6-partial-checkpoint-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("partial checkpoint bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("partial checkpoint bucket incarnation should resolve");
let entry = synthetic_v6_dispatch_entry(
bucket,
"archive/partial.bin",
"PARTIAL-CHECKPOINT-TIER",
[19; 32],
&uuid::Uuid::new_v4().to_string(),
);
let (manifest_name, entries) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
vec![(entry, Some(TierDeleteJournalState::Prepared))],
TierDeleteDispatchManifestState::Preparing,
)
.await
.expect("partial checkpoint fixture should persist");
let journal_name = tier_delete_journal_object_name(&entries[0]);
let manifest_data = com::read_config(store.clone(), &manifest_name)
.await
.expect("partial checkpoint manifest should be readable");
let journal_data = com::read_config(store.clone(), &journal_name)
.await
.expect("partial checkpoint journal should be readable");
for pool in &store.pools {
com::save_config(pool.clone(), &manifest_name, manifest_data.clone())
.await
.expect("manifest copy should persist in each partial checkpoint pool");
com::save_config(pool.clone(), &journal_name, journal_data.clone())
.await
.expect("journal copy should persist in each partial checkpoint pool");
}
mark_test_pool_decommissioning_with_split_targets(&store, 0).await;
for target_pool_index in [1, 2] {
for (path, data) in [
(&manifest_name, manifest_data.as_slice()),
(&journal_name, journal_data.as_slice()),
] {
let record = validate_durable_ilm_record(path, data).expect("partial checkpoint v6 record should validate");
store
.persist_decommission_durable_ilm_receipt_for_test(0, target_pool_index, path, &record, false)
.await
.expect("partial checkpoint receipt should persist on each target");
}
}
let (aborting_data, preparing_etag) = test_tier_delete_dispatch_manifest_checkpoint(
store.clone(),
&manifest_name,
TierDeleteDispatchManifestState::Aborting,
)
.await
.expect("partial checkpoint Aborting generation should encode");
let targets = store
.decommission_durable_ilm_checkpoint_targets(&manifest_name, &aborting_data, &preparing_etag)
.await
.expect("partial checkpoint targets should resolve")
.expect("the active decommission should own the partial checkpoint");
assert_eq!(
targets.iter().map(|target| target.target_pool_index).collect::<Vec<_>>(),
vec![1, 2],
"the capacity reservation must exercise two independently confirmed targets"
);
let failure_hook = DecommissionCheckpointTargetFailureHook::install(2);
let error = recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect_err("the injected second-target failure must reject the checkpoint");
assert!(
error.to_string().contains("injected decommission checkpoint target failure"),
"unexpected partial checkpoint error: {error}"
);
let mut diagnostic = &error as &(dyn std::error::Error + 'static);
let mut target_context_found = false;
while let Some(source) = diagnostic.source() {
if source.to_string().contains("target pool 2") {
target_context_found = true;
break;
}
diagnostic = source;
}
assert!(
target_context_found,
"the stable checkpoint error must retain the failed target in its diagnostic source chain: {error:?}"
);
assert_eq!(
com::read_config(store.pools[1].clone(), &manifest_name)
.await
.expect("first target checkpoint should be readable"),
aborting_data,
"the first target should model a committed checkpoint before the later failure"
);
assert_eq!(
com::read_config(store.pools[2].clone(), &manifest_name)
.await
.expect("second target checkpoint should be readable"),
manifest_data,
"the failed second target must retain the prior generation"
);
let retry_targets = store
.decommission_durable_ilm_checkpoint_targets(&manifest_name, &aborting_data, &preparing_etag)
.await
.expect("unadvanced receipts must still authorize the exact partial checkpoint retry")
.expect("the partial checkpoint retry should remain decommission-owned");
assert!(retry_targets[0].already_committed);
assert!(!retry_targets[1].already_committed);
drop(failure_hook);
recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect("the partial checkpoint should converge after the target recovers");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn v6_decommission_checkpoint_shutdown_drains_target_put_and_replays_capacity() {
let temp_dir = tempfile::tempdir().expect("create checkpoint shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "v6-checkpoint-shutdown", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "v6-checkpoint-shutdown-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("checkpoint shutdown bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("checkpoint shutdown bucket incarnation should resolve");
let entry = synthetic_v6_dispatch_entry(
bucket,
"archive/checkpoint-shutdown.bin",
"CHECKPOINT-SHUTDOWN",
[33; 32],
&uuid::Uuid::new_v4().to_string(),
);
let (manifest_name, entries) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
vec![(entry, Some(TierDeleteJournalState::Prepared))],
TierDeleteDispatchManifestState::Preparing,
)
.await
.expect("checkpoint shutdown fixture should persist");
let journal_name = tier_delete_journal_object_name(&entries[0]);
let manifest_data = com::read_config(store.clone(), &manifest_name)
.await
.expect("checkpoint shutdown manifest should be readable");
let journal_data = com::read_config(store.clone(), &journal_name)
.await
.expect("checkpoint shutdown journal should be readable");
for pool in &store.pools {
com::save_config(pool.clone(), &manifest_name, manifest_data.clone())
.await
.expect("manifest copy should persist in each checkpoint shutdown pool");
com::save_config(pool.clone(), &journal_name, journal_data.clone())
.await
.expect("journal copy should persist in each checkpoint shutdown pool");
}
mark_test_pool_decommissioning(&store, 0).await;
for (path, data) in [
(&manifest_name, manifest_data.as_slice()),
(&journal_name, journal_data.as_slice()),
] {
let record = validate_durable_ilm_record(path, data).expect("checkpoint shutdown v6 record should validate");
store
.persist_decommission_durable_ilm_receipt_for_test(0, 1, path, &record, false)
.await
.expect("checkpoint shutdown receipt should persist");
}
let (aborting_data, preparing_etag) = test_tier_delete_dispatch_manifest_checkpoint(
store.clone(),
&manifest_name,
TierDeleteDispatchManifestState::Aborting,
)
.await
.expect("checkpoint shutdown Aborting generation should encode");
let targets = store
.decommission_durable_ilm_checkpoint_targets(&manifest_name, &aborting_data, &preparing_etag)
.await
.expect("checkpoint shutdown target should resolve")
.expect("the active decommission should own the checkpoint target");
assert_eq!(targets.len(), 1);
let target = targets[0].clone();
let consumed_before_checkpoint = store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("checkpoint shutdown capacity reservation should exist")
.consumed_target_physical_bytes;
let precommit_error = store
.run_decommission_capacity_non_growing_replacement_with_capacity_lease(
target.target_pool_index,
Some(target.capacity_owner),
Some(aborting_data.len()),
|_| async { Err::<(), Error>(Error::other("injected checkpoint failure before commit")) },
)
.await
.expect_err("the first checkpoint attempt should fail before writing its target")
.to_string();
assert!(precommit_error.contains("injected checkpoint failure before commit"));
assert!(
store
.has_decommission_capacity_temporary_mutation_state(target.target_pool_index, target.capacity_owner)
.await,
"a failed checkpoint attempt must retain exact retry state"
);
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
RUSTFS_META_BUCKET,
&manifest_name,
crate::set_disk::PutObjectCommitPause::BeforeQuotaRename,
);
let worker_store = store.clone();
let worker_manifest = manifest_name.clone();
let worker = tokio::spawn(async move {
recover_test_tier_delete_dispatch_manifest_with_page_budget(worker_store, &worker_manifest, Duration::from_secs(30))
.await
});
barrier.wait_until_paused().await;
assert!(
store
.has_decommission_capacity_temporary_mutation_state(target.target_pool_index, target.capacity_owner)
.await,
"the target PUT must persist its capacity intent before commit"
);
shutdown.cancel();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 1);
assert!(
tier_delete_dispatch_manifest_operation_lock_held_for_test(store.clone(), &manifest_name).await,
"the target checkpoint PUT must retain its operation lock after shutdown"
);
assert!(
crate::services::notification_sys::tier_delete_journal_fleet_proof_has_inflight_for_test(),
"the target checkpoint PUT must retain its fleet-proof permit after shutdown"
);
barrier.release();
assert!(
!worker.await.expect("checkpoint shutdown worker should join"),
"cooperative shutdown should retain the partially committed checkpoint"
);
assert_eq!(tier_delete_dispatch_manifest_recovery_count_for_test(&store), 0);
assert_eq!(
com::read_config(store.pools[target.target_pool_index].clone(), &manifest_name)
.await
.expect("the committed target checkpoint should be readable"),
aborting_data,
"the admitted target PUT must finish before its fences release"
);
assert!(
!store
.has_decommission_capacity_temporary_mutation_state(target.target_pool_index, target.capacity_owner)
.await,
"the admitted target PUT must drain its capacity transaction before releasing the recovery fences"
);
{
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("checkpoint shutdown capacity reservation should remain active");
let capacity_target = reservation
.targets
.iter()
.find(|candidate| candidate.pool_index == target.target_pool_index)
.expect("checkpoint shutdown capacity target should remain allocated");
assert_eq!(
reservation.consumed_target_physical_bytes, consumed_before_checkpoint,
"a non-growing checkpoint replacement must not consume durable migration capacity"
);
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(reservation.inflight_target_physical_bytes, 0);
assert_eq!(capacity_target.pending_physical_bytes, 0);
assert!(capacity_target.temporary_mutations.is_empty());
}
drop(barrier);
let mut reloaded_pool_meta = PoolMeta::default();
reloaded_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("checkpoint shutdown pool metadata should reload from disk");
*store.pool_meta.write().await = reloaded_pool_meta;
recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect("a fresh recovery attempt should replay the committed checkpoint and receipt");
assert!(
!store
.has_decommission_capacity_temporary_mutation_state(target.target_pool_index, target.capacity_owner)
.await,
"durable replay must reconcile the exact capacity transaction"
);
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("checkpoint shutdown receipts should be listable"),
2,
"durable replay must advance both the manifest and journal receipts"
);
drop(ctx);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn v6_dispatch_recovery_advances_decommission_receipts_and_cleans_target_copies() {
let temp_dir = tempfile::tempdir().expect("create v6 decommission receipt store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "v6-dispatch-decommission-receipts", &[4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "v6-dispatch-decommission-receipts-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("receipt bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("receipt bucket incarnation should resolve");
let entry = synthetic_v6_dispatch_entry(
bucket,
"archive/receipt.bin",
"RECEIPT-TIER",
[17; 32],
&uuid::Uuid::new_v4().to_string(),
);
let (manifest_name, entries) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
vec![(entry, Some(TierDeleteJournalState::Prepared))],
TierDeleteDispatchManifestState::Preparing,
)
.await
.expect("Preparing v6 fixture should persist");
let journal_name = tier_delete_journal_object_name(&entries[0]);
let manifest_data = com::read_config(store.clone(), &manifest_name)
.await
.expect("manifest fixture should be readable");
let journal_data = com::read_config(store.clone(), &journal_name)
.await
.expect("journal fixture should be readable");
// Model an in-flight decommission after both records have been copied
// to its target pool but before their state machines advance.
for pool in &store.pools {
com::save_config(pool.clone(), &manifest_name, manifest_data.clone())
.await
.expect("manifest copy should persist in each pool");
com::save_config(pool.clone(), &journal_name, journal_data.clone())
.await
.expect("journal copy should persist in each pool");
}
// Exercise the same durable capacity reservation and identity that a
// real decommission start installs. A hand-written active flag makes
// every target mutation look external and masks whether recovery is
// correctly charged to the decommission owner.
mark_test_pool_decommissioning(&store, 0).await;
let missing_receipt_error = recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect_err("an active decommission without receipt coverage must block v6 checkpoint mutation")
.to_string();
assert!(
missing_receipt_error.contains("missing receipt coverage"),
"unexpected missing-receipt error: {missing_receipt_error}"
);
assert_eq!(
com::read_config(store.pools[1].clone(), &manifest_name)
.await
.expect("blocked checkpoint must retain the target manifest generation"),
manifest_data,
"missing receipt coverage must fail before mutating a target copy"
);
for (path, data) in [
(&manifest_name, manifest_data.as_slice()),
(&journal_name, journal_data.as_slice()),
] {
let record = validate_durable_ilm_record(path, data).expect("v6 durable record should validate");
store
.persist_decommission_durable_ilm_receipt_for_test(0, 1, path, &record, false)
.await
.expect("initial decommission receipt should persist");
}
// Model a crash after the target checkpoint PUT committed but before
// its temporary-capacity intent was resolved. Recovery must recognize
// the exact target bytes and finish that same deterministic intent
// instead of treating the checkpoint as an uncharged success.
let (aborting_data, preparing_etag) = test_tier_delete_dispatch_manifest_checkpoint(
store.clone(),
&manifest_name,
TierDeleteDispatchManifestState::Aborting,
)
.await
.expect("the Aborting checkpoint should encode");
let checkpoint_targets = store
.decommission_durable_ilm_checkpoint_targets(&manifest_name, &aborting_data, &preparing_etag)
.await
.expect("receipt-bearing checkpoint targets should resolve")
.expect("an active decommission should own the checkpoint");
assert_eq!(checkpoint_targets.len(), 1);
let checkpoint_target = checkpoint_targets
.into_iter()
.next()
.expect("one checkpoint target should exist");
let checkpoint_owner = checkpoint_target.capacity_owner;
let target_pool_index = checkpoint_target.target_pool_index;
let injected_write_error = store
.run_decommission_capacity_temporary_mutation_with_capacity_lease(
target_pool_index,
Some(checkpoint_owner),
Some(aborting_data.len()),
|_| {
let target_pool = store.pools[target_pool_index].clone();
let manifest_name = manifest_name.clone();
let aborting_data = aborting_data.clone();
async move {
com::save_config(target_pool, &manifest_name, aborting_data)
.await
.expect("the injected target checkpoint should commit");
Err::<(), Error>(Error::other("injected crash after checkpoint target commit"))
}
},
)
.await
.expect_err("the injected post-commit crash should leave a pending capacity intent")
.to_string();
assert!(injected_write_error.contains("injected crash after checkpoint target commit"));
assert!(
store
.has_decommission_capacity_temporary_mutation_state(target_pool_index, checkpoint_owner)
.await,
"the committed target must retain its exact unresolved capacity intent"
);
recover_test_tier_delete_dispatch_manifest(store.clone(), &manifest_name)
.await
.expect("Preparing rollback should advance receipts and finish terminal cleanup");
assert!(
!store
.has_decommission_capacity_temporary_mutation_state(target_pool_index, checkpoint_owner)
.await,
"already-committed checkpoint recovery must resolve the pending capacity intent"
);
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("the terminal source checkpoint should remain non-actionable");
assert_eq!((stats.scanned, stats.deleted, stats.retained, stats.failed), (1, 0, 1, 0));
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("v6 terminal receipts should be listable"),
2
);
{
let _proof_guard = crate::services::notification_sys::without_cross_pool_fence_fleet_proof_for_test();
let proof_error = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manifest_name),
&manifest_name,
)
.await
.expect_err("revoked v6 fleet proof must block final source cleanup")
.to_string();
assert!(
proof_error.contains("fleet capability is unavailable"),
"unexpected revoked-proof cleanup error: {proof_error}"
);
assert!(
com::read_config(store.pools[0].clone(), &manifest_name).await.is_ok(),
"revoked fleet proof must retain the last source manifest copy"
);
}
for path in [&manifest_name, &journal_name] {
store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(0, store.pools[0].get_disks_by_key(path), path)
.await
.expect("terminal receipt should authorize verified source cleanup");
}
let stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("verified source cleanup should remove the last manifest copy");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (0, 0, 0));
for path in [&manifest_name, &journal_name] {
for pool in &store.pools {
assert!(
matches!(com::read_config(pool.clone(), path).await, Err(Error::ConfigNotFound)),
"terminal receipt cleanup must remove `{path}` from every staged pool"
);
}
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn authorized_all_committed_manifest_recovers_after_last_commit_crash() {
let temp_dir = tempfile::tempdir().expect("create authorized manifest recovery store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "authorized-manifest-last-commit", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "authorized-manifest-last-commit-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("authorized recovery bucket should be created");
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should resolve");
let tier_name = "AUTHORIZED-LAST-COMMIT";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve");
let mut entries = Vec::new();
for index in 0..2 {
let remote_version = uuid::Uuid::new_v4().to_string();
let entry = synthetic_v6_dispatch_entry(
bucket,
&format!("archive/{index}.bin"),
tier_name,
lease.backend_identity(),
&remote_version,
);
backend.set_put_remote_version(Some(remote_version)).await;
lease
.put(&entry.obj_name, ReaderImpl::Body(bytes::Bytes::from_static(b"remote candidate")), 16)
.await
.expect("remote candidate should be seeded");
entries.push((entry, Some(TierDeleteJournalState::Committed)));
}
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
"archive/",
entries,
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("last-commit crash fixture should persist");
let manifest_stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("all-Committed Authorized manifest should advance");
assert_eq!((manifest_stats.scanned, manifest_stats.advanced, manifest_stats.failed), (1, 1, 0));
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("advanced manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Completed)
);
let journal_stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("Completed journal set should recover remote candidates");
assert_eq!((journal_stats.scanned, journal_stats.deleted, journal_stats.failed), (2, 2, 0));
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 1);
let gc_stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("manifest coordinator should remove the empty Completed manifest");
assert_eq!((gc_stats.scanned, gc_stats.deleted, gc_stats.failed), (1, 1, 0));
assert_eq!(tier_delete_dispatch_manifest_count(store).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.remove_versions().await.len(), 2);
}
#[cfg(feature = "test-util")]
async fn transition_transaction_record_count(store: Arc<crate::store::ECStore>) -> usize {
store
.list_objects_v2(
RUSTFS_META_BUCKET,
TRANSITION_TRANSACTION_RECORD_PREFIX,
None,
None,
100,
false,
None,
false,
)
.await
.expect("transition transaction records should be listable")
.objects
.len()
}
#[cfg(feature = "test-util")]
async fn register_transition_reconcile_test_tier(
handle: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
tier_name: &str,
) -> MockWarmBackend {
let backend = MockWarmBackend::new();
let mut manager = handle.write().await;
manager.tiers.insert(
tier_name.to_string(),
TierConfig {
version: "v1".to_string(),
tier_type: TierType::Wasabi,
name: tier_name.to_string(),
wasabi: Some(TierWasabi {
name: tier_name.to_string(),
endpoint: "https://s3.wasabisys.com".to_string(),
access_key: "test-access-key".to_string(),
secret_key: "test-secret-key".to_string(),
bucket: "mock-tier".to_string(),
prefix: format!("mock/{}/", uuid::Uuid::new_v4()),
region: "us-east-1".to_string(),
}),
..Default::default()
},
);
manager
.install_test_driver(tier_name, Box::new(backend.clone()))
.expect("mock fallback tier driver should install");
backend
}
#[cfg(feature = "test-util")]
async fn wait_for_tier_delete_journal_recovery(
store: Arc<crate::store::ECStore>,
backend: &MockWarmBackend,
expected_removes: usize,
) {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
if backend.remove_versions().await.len() >= expected_removes
&& tier_delete_journal_count(store.clone()).await == 0
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("tier delete journal recovery should complete");
}
#[cfg(feature = "test-util")]
async fn wait_for_tier_free_version_recovery(
store: Arc<crate::store::ECStore>,
backend: &MockWarmBackend,
expected_removes: usize,
) {
ExpiryState::resize_workers(1, store.clone()).await;
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let stats = recover_tier_free_versions(store.clone(), 100, None, None)
.await
.expect("tier free-version recovery scan should succeed");
if backend.remove_versions().await.len() >= expected_removes && stats.enqueued == 0 && stats.failed == 0 {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("tier free-version recovery should complete");
}
#[cfg(feature = "test-util")]
async fn wait_for_expiry_workers_idle(store: &crate::store::ECStore) {
let expiry_state = store.ctx.expiry_state();
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let idle = {
let state = expiry_state.read().await;
state.pending_tasks() == 0 && state.active_tasks() == 0
};
if idle {
// Recheck after a scheduler turn so the tiny hand-off
// between dequeue and active-task accounting cannot make
// the test observe a false idle state.
tokio::time::sleep(Duration::from_millis(10)).await;
let state = expiry_state.read().await;
if state.pending_tasks() == 0 && state.active_tasks() == 0 {
return;
}
} else {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
})
.await
.expect("lifecycle expiry workers should become idle");
}
#[cfg(feature = "test-util")]
async fn run_transitioned_delete_free_version_owner_case(
store_name: &str,
tier_name: &str,
bucket: &str,
object: &str,
restore_before_delete: bool,
causal_enqueue: bool,
delete_with_journal: bool,
) {
let temp_dir = tempfile::tempdir().expect("create transitioned delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), store_name, &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("source bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b'd'; 1024 * 1024]);
let original = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source should have an etag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("source transition should commit");
assert_eq!(backend.object_count().await, 1, "transition should create one remote object");
if restore_before_delete {
store
.clone()
.restore_transitioned_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
restore_request: s3s::dto::RestoreRequest {
days: Some(1),
..Default::default()
},
..Default::default()
},
..Default::default()
},
)
.await
.expect("restore should complete before transitioned delete");
let restored = store
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("restored transitioned source should remain readable");
assert!(
restored.user_defined.contains_key(s3s::header::X_AMZ_RESTORE.as_str()),
"restore completion metadata must be present before the delete regression"
);
}
if causal_enqueue {
ExpiryState::resize_workers(1, store.clone()).await;
}
backend.set_remove_failure(!causal_enqueue);
if delete_with_journal {
store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("transitioned source journal-wrapper delete should commit");
} else {
store
.delete_object(bucket, object, ObjectOptions::default())
.await
.expect("transitioned source plain object-layer delete should commit");
}
if causal_enqueue {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("causal free-version cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("committed free-version should be cleaned without a recovery scan");
assert_eq!(backend.object_count().await, 0, "causal cleanup should remove the remote object");
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary causal cleanup must not create a journal"
);
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("bucket delete should succeed after causal free-version cleanup");
return;
}
let local_versions = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-delete metadata should remain decodable");
let local_versions = local_versions.expect("ordinary delete must leave a durable free-version cleanup owner");
assert_eq!(
local_versions
.versions
.iter()
.chain(local_versions.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"ordinary delete must leave exactly one hidden free-version owner"
);
assert_eq!(
local_versions
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0,
"ordinary delete must remove the visible transitioned source"
);
let listed = store
.clone()
.list_object_versions(bucket, "", None, None, None, 100)
.await
.expect("source versions should be listable");
assert!(listed.objects.is_empty(), "source must have no visible versions after delete");
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary delete must not create a journal"
);
backend.set_remove_failure(false);
ExpiryState::resize_workers(1, store.clone()).await;
let recovered = recover_tier_free_versions(store.clone(), 100, None, None)
.await
.expect("free-version recovery should scan the hidden owner");
assert_eq!((recovered.enqueued, recovered.failed), (1, 0));
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("free-version cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("free-version recovery should delete the remote and exact local owner");
assert_eq!(backend.object_count().await, 0, "free-version recovery should remove the remote object");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("ordinary bucket delete should succeed after the last transitioned object");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transitioned_delete_uses_free_version_as_cleanup_owner() {
run_transitioned_delete_free_version_owner_case(
"transitioned-delete-journal-owner",
"DELETEOWNER",
"transitioned-delete-journal-owner-bucket",
"transition/archive.bin",
false,
false,
true,
)
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn restored_transitioned_delete_uses_free_version_as_cleanup_owner() {
run_transitioned_delete_free_version_owner_case(
"restored-transitioned-delete-journal-owner",
"RESTOREDELETEOWNER",
"restored-transitioned-delete-journal-owner-bucket",
"transition/archive.bin",
true,
false,
true,
)
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transitioned_delete_causally_enqueues_free_version() {
run_transitioned_delete_free_version_owner_case(
"transitioned-delete-causal-enqueue",
"DELETE-CAUSAL",
"transitioned-delete-causal-enqueue-bucket",
"transition/archive.bin",
false,
true,
false,
)
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn restored_transitioned_delete_causally_enqueues_free_version() {
run_transitioned_delete_free_version_owner_case(
"restored-transitioned-delete-causal-enqueue",
"RESTORE-DELETE-CAUSAL",
"restored-transitioned-delete-causal-enqueue-bucket",
"transition/archive.bin",
true,
true,
true,
)
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool() {
let temp_dir = tempfile::tempdir().expect("create tier reference store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-free-reference-proof", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "FREE-PROOF";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("test tier identity should resolve")
.backend_identity();
let bucket = "tier-free-reference-proof-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("source bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b'f'; 1024 * 1024]);
let original = store.pools[0]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source should have an etag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("source transition should commit");
backend.set_remove_failure(true);
store.pools[0]
.delete_object(
bucket,
object,
ObjectOptions {
tier_delete_journal_api: Some(store.clone()),
..Default::default()
},
)
.await
.expect("journal-less compatibility delete should retain a free-version");
let exact = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("free-version metadata should remain decodable")
.expect("free-version metadata should remain on disk");
assert_eq!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"fixture must contain one real serialized hidden free-version"
);
let mut hot_reader = PutObjReader::from_vec(vec![b'h'; 1024 * 1024]);
store.pools[1]
.put_object(
bucket,
object,
&mut hot_reader,
&ObjectOptions {
mod_time: Some(OffsetDateTime::now_utc() + time::Duration::hours(1)),
..Default::default()
},
)
.await
.expect("second pool hot source should be written");
let merged = store
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("merged lookup should return the second-pool hot source");
assert!(
merged.transitioned_object.status.is_empty() && !merged.transitioned_object.free_version,
"the ordinary second-pool source must hide the first-pool free-version from merged lookup"
);
let err = ensure_no_authoritative_tier_references_for_test(store, tier_name, backend_identity, true)
.await
.expect_err("force tier removal must not bypass a physical free-version cleanup obligation");
assert_eq!(err.code, ERR_TIER_BACKEND_IN_USE.code);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_reference_proof_fails_closed_when_physical_bucket_topology_is_incomplete() {
let temp_dir = tempfile::tempdir().expect("create incomplete tier reference store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-reference-incomplete-topology", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "INCOMPLETE-PROOF";
register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("test tier identity should resolve")
.backend_identity();
store.pools[0].disk_set[0].disks.write().await[0] = None;
let err = ensure_no_authoritative_tier_references_for_test(store, tier_name, backend_identity, true)
.await
.expect_err("an incomplete physical bucket enumeration must block tier mutation");
assert_eq!(err.code, ERR_TIER_INVALID_CONFIG.code);
assert!(
err.message.contains("complete set quorum"),
"unexpected reference proof error: {}",
err.message
);
}
#[cfg(feature = "test-util")]
async fn copy_test_xlmeta_between_pools(
root: &std::path::Path,
source_pool: usize,
target_pool: usize,
bucket: &str,
object: &str,
) {
for disk_index in 0..4 {
let source = root.join(format!("pool{source_pool}/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let target = root.join(format!("pool{target_pool}/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
tokio::fs::create_dir_all(target.parent().expect("target xl.meta should have a parent"))
.await
.expect("target object directory should be created");
tokio::fs::copy(&source, &target)
.await
.expect("xl.meta should copy exactly between pools");
}
}
#[cfg(feature = "test-util")]
async fn rewrite_transitioned_xlmeta_as_legacy_unknown(
root: &std::path::Path,
pool_index: usize,
bucket: &str,
object: &str,
) {
for disk_index in 0..4 {
let metadata_path =
root.join(format!("pool{pool_index}/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let encoded = tokio::fs::read(&metadata_path)
.await
.expect("transitioned xl.meta should be readable");
let mut metadata = FileMeta::load(&encoded).expect("transitioned xl.meta should decode");
let mut found = false;
for shallow in &mut metadata.versions {
let mut version = shallow
.parse_version_meta()
.expect("transitioned version record should decode");
let Some(object_meta) = version.object.as_mut() else {
continue;
};
if rustfs_utils::http::metadata_compat::get_bytes(
&object_meta.meta_sys,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_STATUS,
)
.as_deref()
!= Some(rustfs_filemeta::TRANSITION_COMPLETE.as_bytes())
{
continue;
}
found = true;
for suffix in [
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITIONED_VERSION_STATE,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITIONED_VERSION_ID,
] {
rustfs_utils::http::metadata_compat::remove_bytes(&mut object_meta.meta_sys, suffix);
}
*shallow = rustfs_filemeta::FileMetaShallowVersion::try_from(version)
.expect("legacy transitioned version should re-encode");
}
assert!(found, "transitioned source should exist in serialized xl.meta");
tokio::fs::write(&metadata_path, metadata.marshal_msg().expect("legacy transitioned xl.meta should encode"))
.await
.expect("legacy transitioned xl.meta should persist");
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix() {
let temp_dir = tempfile::tempdir().expect("create legacy transitioned delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "legacy-unknown-transitioned-delete", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "LEGACY-UNKNOWN-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "legacy-unknown-transitioned-delete-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("legacy source bucket should be created");
for (index, object) in ["single.bin", "batch.bin", "prefix/legacy.bin"].into_iter().enumerate() {
let mut reader = PutObjReader::from_vec(vec![b'l' + index as u8; 1024 * 1024]);
let source = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("legacy source should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("legacy source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("legacy source should transition");
rewrite_transitioned_xlmeta_as_legacy_unknown(temp_dir.path(), 0, bucket, object).await;
let legacy = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("legacy source metadata should remain decodable")
.expect("legacy source should remain on disk")
.versions
.into_iter()
.find(|version| version.transition_status == rustfs_filemeta::TRANSITION_COMPLETE)
.expect("legacy transitioned source should remain visible before delete");
assert_eq!(legacy.transition_version_state, rustfs_filemeta::TransitionVersionState::Unknown);
assert_eq!(
crate::services::tier::tier::tier_destination_id_from_metadata(&legacy.metadata)
.expect("legacy metadata identity lookup should not fail"),
None
);
}
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(bucket, "single.bin", ObjectOptions::default())
.await
.expect("legacy single delete should retain its free-version fallback");
let free_version_prefix_error = store
.delete_object_with_tier_delete_journal(
bucket,
"single.bin",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("recursive prefix delete must not discard an existing hidden cleanup owner");
assert!(
free_version_prefix_error
.to_string()
.contains("tier free-version cleanup obligation"),
"unexpected hidden-owner prefix failure: {free_version_prefix_error}"
);
let (_deleted, errors) = store
.delete_objects_with_tier_delete_journal(
bucket,
vec![ObjectToDelete {
object_name: "batch.bin".to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(errors.iter().all(Option::is_none), "legacy batch delete should succeed: {errors:?}");
let prefix_error = store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("legacy recursive prefix delete must fail closed before physical removal");
assert!(
prefix_error.to_string().contains("legacy Unknown"),
"unexpected prefix failure: {prefix_error}"
);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"legacy cleanup must not create an unsafe journal"
);
assert_eq!(
backend.object_count().await,
3,
"failed free-version cleanup must retain all remote objects"
);
for object in ["single.bin", "batch.bin"] {
let exact = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("legacy post-delete metadata should decode")
.expect("legacy cleanup owner should remain on disk");
assert_eq!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"legacy {object} must retain exactly one hidden cleanup owner"
);
assert_eq!(
exact.versions.iter().filter(|version| !version.tier_free_version()).count(),
0,
"legacy {object} must have no visible source after delete"
);
}
let retained_prefix = store.pools[0]
.get_disks_by_key("prefix/legacy.bin")
.load_file_info_versions_exact(bucket, "prefix/legacy.bin")
.await
.expect("rejected prefix metadata should decode")
.expect("rejected prefix delete must retain its source");
assert_eq!(
retained_prefix
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
1
);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn stable_transitioned_recursive_prefix_delete_uses_journal_owners() {
let temp_dir = tempfile::tempdir().expect("create stable transitioned prefix-delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "stable-transitioned-prefix-delete", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "STABLE-PREFIX-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "stable-transitioned-prefix-delete-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("stable prefix source bucket should be created");
let objects = ["prefix/first.bin", "prefix/second.bin"];
for (index, object) in objects.into_iter().enumerate() {
let mut reader = PutObjReader::from_vec(vec![b'a' + index as u8; 1024 * 1024]);
let source = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("stable prefix source should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("stable prefix source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("stable prefix source should transition");
}
assert_eq!(backend.object_count().await, objects.len());
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("stable recursive prefix delete should use journal ownership");
assert_eq!(tier_delete_journal_count(store.clone()).await, objects.len());
assert_eq!(backend.object_count().await, objects.len());
for object in objects {
assert!(
store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("stable prefix metadata lookup should succeed")
.is_none(),
"stable recursive delete must leave neither a visible source nor a free-version for {object}"
);
}
backend.set_remove_failure(false);
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("stable prefix journals should recover");
assert_eq!(stats.failed, 0);
assert!(stats.scanned <= objects.len() && stats.deleted <= objects.len());
assert_eq!(tier_delete_journal_count(store).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.remove_versions().await.len(), objects.len());
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_delete_prefix_limit_and_multi_chunk_batches_converge() {
run_large_stack_async_test(
"tier-delete-prefix-limit-and-multi-chunk",
tier_delete_prefix_limit_and_multi_chunk_batches_converge_case,
);
}
#[cfg(feature = "test-util")]
async fn tier_delete_prefix_limit_and_multi_chunk_batches_converge_case() {
let _batch_limit = TierDeleteDispatchBatchLimitGuard::install(2);
let temp_dir = tempfile::tempdir().expect("create chunked prefix-delete store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "chunked-prefix-delete", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "CHUNKED-PREFIX-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "chunked-prefix-delete-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("chunked prefix source bucket should be created");
for (prefix, count) in [("at-limit", 2), ("limit-plus-one", 3), ("multi-chunk", 5)] {
for index in 0..count {
let object = format!("{prefix}/object-{index}.bin");
let mut reader = PutObjReader::from_vec(vec![b'a' + index as u8; 1024 * 1024]);
let source = store
.put_object(bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("chunked prefix source should be written");
store
.transition_object(
bucket,
&object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("chunked prefix source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("chunked prefix source should transition");
}
}
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(
bucket,
"at-limit/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("exactly one batch must retain the v1 one-shot path");
let limit_plus_one_first = store
.delete_object_with_tier_delete_journal(
bucket,
"limit-plus-one/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("limit plus one must start a bounded parent transaction");
assert!(
limit_plus_one_first.to_string().contains("retry the next durable batch"),
"unexpected first limit-plus-one result: {limit_plus_one_first}"
);
let active_limit_plus_one_records = store
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
None,
None,
10,
false,
None,
false,
)
.await
.expect("active limit-plus-one records should be listable");
let mut active_limit_plus_one_parent_seen = false;
for record in active_limit_plus_one_records
.objects
.iter()
.filter(|record| !record.name.contains("/chunks/"))
{
let data = com::read_config(store.clone(), &record.name)
.await
.expect("an active dispatch root should be readable");
let value: serde_json::Value = serde_json::from_slice(&data).expect("an active dispatch root should contain JSON");
if value["prefix"] == "limit-plus-one/" {
assert_eq!(value["record_type"], "chunked_parent");
active_limit_plus_one_parent_seen = true;
}
}
assert!(
active_limit_plus_one_parent_seen,
"limit plus one must establish the fail-closed parent at the legacy root"
);
let mut limit_plus_one_completed = false;
let mut limit_plus_one_retries = 1;
for _ in 0..3 {
match store
.delete_object_with_tier_delete_journal(
bucket,
"limit-plus-one/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
{
Ok(_) => {
limit_plus_one_completed = true;
break;
}
Err(err) if err.to_string().contains("retry the next durable batch") => {
limit_plus_one_retries += 1;
}
Err(err) => panic!("limit-plus-one delete returned an unexpected error: {err}"),
}
}
assert!(limit_plus_one_completed, "limit plus one must converge through two bounded children");
assert_eq!(limit_plus_one_retries, 2, "limit plus one must require exactly two child batches");
let first_batch = store
.delete_object_with_tier_delete_journal(
bucket,
"multi-chunk/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("the first bounded child must request a successor batch");
assert!(
first_batch.to_string().contains("retry the next durable batch"),
"unexpected first child result: {first_batch}"
);
let newcomer = "multi-chunk/zzz-newcomer.bin";
let mut newcomer_reader = PutObjReader::from_vec(vec![b'n'; 1024 * 1024]);
let newcomer_source = store
.put_object(bucket, newcomer, &mut newcomer_reader, &ObjectOptions::default())
.await
.expect("a source created between chunks should be written");
store
.transition_object(
bucket,
newcomer,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: newcomer_source
.etag
.clone()
.expect("the between-chunks source should have an etag"),
..Default::default()
},
mod_time: newcomer_source.mod_time,
..Default::default()
},
)
.await
.expect("a source created between chunks should transition");
let mut completed = false;
let mut durable_batch_retries = 1;
for _ in 0..7 {
match store
.delete_object_with_tier_delete_journal(
bucket,
"multi-chunk/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
{
Ok(_) => {
completed = true;
break;
}
Err(err)
if err.to_string().contains("retry the next durable batch")
|| err.to_string().contains("retry the next batch") =>
{
durable_batch_retries += 1;
}
Err(err) => panic!("chunked prefix delete returned an unexpected error: {err}"),
}
}
assert!(completed, "six entries must converge through three bounded child batches");
assert_eq!(durable_batch_retries, 3, "limit two should require exactly three child batches");
assert_eq!(tier_delete_journal_count(store.clone()).await, 11);
assert_eq!(
backend.object_count().await,
11,
"remote cleanup must remain durable while the tier is unavailable"
);
let dispatch_records = store
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
None,
None,
20,
false,
None,
false,
)
.await
.expect("bounded dispatch records should be listable");
let mut child_batches = 0;
let mut legacy_at_limit_seen = false;
for record in &dispatch_records.objects {
let data = com::read_config(store.clone(), &record.name)
.await
.expect("a retained dispatch record should be readable");
let value: serde_json::Value = serde_json::from_slice(&data).expect("a retained dispatch record should contain JSON");
if record.name.contains("/chunks/") {
let journal_count = value["journal_count"]
.as_u64()
.expect("a retained child should declare its journal count");
assert!(journal_count <= 2, "a child batch exceeded the configured resource bound");
child_batches += 1;
} else if value["prefix"] == "at-limit/" {
assert!(
value.get("record_type").is_none(),
"the exact-limit root must remain a legacy v1 manifest"
);
assert_eq!(value["journal_count"], 2);
legacy_at_limit_seen = true;
}
}
assert!(
legacy_at_limit_seen,
"the exact-limit dispatch must retain its byte-compatible root shape"
);
assert_eq!(child_batches, 5, "nine chunked sources should persist exactly five bounded children");
backend.set_remove_failure(false);
drive_tier_delete_dispatch_restart_to_convergence(store.clone()).await;
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(
backend.remove_versions().await.len(),
11,
"each remote version must be removed exactly once"
);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_delete_chunk_crash_boundaries_resume_without_skipping_sources() {
run_large_stack_async_test(
"tier-delete-chunk-crash-boundaries",
tier_delete_chunk_crash_boundaries_resume_without_skipping_sources_case,
);
}
#[cfg(feature = "test-util")]
async fn tier_delete_chunk_crash_boundaries_resume_without_skipping_sources_case() {
let _batch_limit = TierDeleteDispatchBatchLimitGuard::install(1);
let stages = [
TierDeleteChunkTestStage::ParentPersisted,
TierDeleteChunkTestStage::ChildManifestPersisted,
TierDeleteChunkTestStage::ParentBound,
TierDeleteChunkTestStage::DispatchAuthorized,
TierDeleteChunkTestStage::LocalReplayCompleted,
TierDeleteChunkTestStage::ChildCompleted,
TierDeleteChunkTestStage::ParentProgressed,
TierDeleteChunkTestStage::FinalLocalDeletionCompleted,
TierDeleteChunkTestStage::ParentCompleted,
];
for (case, stage) in stages.into_iter().enumerate() {
let temp_dir = tempfile::tempdir().expect("create chunk crash-boundary store dir");
let initial_name = format!("chunk-crash-boundary-{case}");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), &initial_name, &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = format!("CHUNK-CRASH-BOUNDARY-{case}");
let backend = register_mock_tier(&ctx.tier_config_mgr(), &tier_name).await;
backend.set_remove_failure(true);
let bucket = format!("chunk-crash-boundary-{case}-bucket");
let prefix = "prefix/";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("chunk crash-boundary bucket should be created");
for index in 0..2 {
let object = format!("{prefix}object-{index}.bin");
let mut reader = PutObjReader::from_vec(vec![b'a' + index as u8; 1024 * 1024]);
let source = store
.put_object(&bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("chunk crash-boundary source should be written");
store
.transition_object(
&bucket,
&object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.clone(),
etag: source.etag.clone().expect("chunk crash-boundary source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("chunk crash-boundary source should transition");
}
let local_only = format!("{prefix}local-only.bin");
let mut local_reader = PutObjReader::from_vec(vec![b'l'; 1024 * 1024]);
store
.put_object(&bucket, &local_only, &mut local_reader, &ObjectOptions::default())
.await
.expect("chunk crash-boundary local-only object should be written");
let tier_config = ctx
.tier_config_mgr()
.read()
.await
.tiers
.get(&tier_name)
.expect("chunk crash-boundary tier config should remain available for restart")
.clone_with_credentials();
if matches!(
stage,
TierDeleteChunkTestStage::FinalLocalDeletionCompleted | TierDeleteChunkTestStage::ParentCompleted
) {
for _ in 0..2 {
let retry = store
.delete_object_with_tier_delete_journal(
&bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("each bounded child must complete before the final crash boundary");
assert!(
retry.to_string().contains("retry the next durable batch"),
"unexpected bounded-child result before {stage:?}: {retry}"
);
}
}
let barrier = TierDeleteChunkTestBarrier::install(stage);
let worker_store = store.clone();
let worker_bucket = bucket.clone();
let worker = tokio::spawn(async move {
worker_store
.delete_object_with_tier_delete_journal(
&worker_bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
.await
.unwrap_or_else(|_| panic!("chunk delete did not reach crash boundary {stage:?}"));
worker.abort();
let _ = worker.await;
drop(barrier);
let released_bucket_guard =
tokio::time::timeout(Duration::from_secs(5), store.acquire_bucket_lifecycle_write_lock(&bucket))
.await
.unwrap_or_else(|_| panic!("canceling at {stage:?} did not release the bucket lifecycle lock"))
.unwrap_or_else(|err| {
panic!("bucket lifecycle lock reacquire failed after cancellation at {stage:?}: {err}")
});
drop(released_bucket_guard);
shutdown.cancel();
drop(store);
drop(ctx);
let restarted_name = format!("chunk-crash-boundary-{case}-restart");
let (restarted_ctx, restarted_store, restarted_shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), &restarted_name, &[4])).await;
{
let tier_config_mgr = restarted_ctx.tier_config_mgr();
let mut manager = tier_config_mgr.write().await;
manager.tiers.insert(tier_name.clone(), tier_config);
manager
.install_test_driver(&tier_name, Box::new(backend.clone()))
.expect("the exact chunk crash-boundary tier driver should reinstall after restart");
}
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
let mut completed = false;
for _ in 0..20 {
let recovery = recover_tier_delete_dispatch_manifests(restarted_store.clone(), 100, None)
.await
.expect("chunk crash-boundary manifest recovery should remain readable");
assert_eq!(recovery.failed, 0, "recovery must not quarantine a valid chunk boundary");
match restarted_store
.delete_object_with_tier_delete_journal(
&bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
{
Ok(_) => {
completed = true;
break;
}
Err(err)
if err.to_string().contains("retry")
|| err.to_string().contains("rollback")
|| err.to_string().contains("durable cleanup") => {}
Err(err) => panic!("chunk crash-boundary retry returned an unexpected error at {stage:?}: {err}"),
}
}
assert!(completed, "chunk state must converge after cancellation at {stage:?}");
for index in 0..2 {
let object = format!("{prefix}object-{index}.bin");
assert!(
restarted_store.pools[0]
.get_disks_by_key(&object)
.load_file_info_versions_exact(&bucket, &object)
.await
.expect("chunk crash-boundary source lookup should succeed")
.is_none(),
"source {object} must not be skipped after cancellation at {stage:?}"
);
}
assert!(
restarted_store.pools[0]
.get_disks_by_key(&local_only)
.load_file_info_versions_exact(&bucket, &local_only)
.await
.expect("chunk crash-boundary local-only source lookup should succeed")
.is_none(),
"the final raw delete must remove the local-only source after cancellation at {stage:?}"
);
assert_eq!(backend.object_count().await, 2);
backend.set_remove_failure(false);
drive_tier_delete_dispatch_restart_to_convergence(restarted_store.clone()).await;
assert_eq!(tier_delete_journal_count(restarted_store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(restarted_store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(
backend.remove_versions().await.len(),
2,
"each crash case must retain exactly one cleanup owner per remote version"
);
restarted_shutdown.cancel();
}
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_delete_chunk_missing_child_with_journals_fails_closed() {
run_large_stack_async_test(
"tier-delete-chunk-missing-child",
tier_delete_chunk_missing_child_with_journals_fails_closed_case,
);
}
#[cfg(feature = "test-util")]
async fn tier_delete_chunk_missing_child_with_journals_fails_closed_case() {
let _batch_limit = TierDeleteDispatchBatchLimitGuard::install(1);
let temp_dir = tempfile::tempdir().expect("create missing-child store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "chunk-missing-child", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "CHUNK-MISSING-CHILD";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
backend.set_remove_failure(true);
let bucket = "chunk-missing-child-bucket";
let prefix = "prefix/";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("missing-child bucket should be created");
for index in 0..2 {
let object = format!("{prefix}object-{index}.bin");
let mut reader = PutObjReader::from_vec(vec![b'm' + index as u8; 1024 * 1024]);
let source = store
.put_object(bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("missing-child source should be written");
store
.transition_object(
bucket,
&object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("missing-child source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("missing-child source should transition");
}
let barrier = TierDeleteChunkTestBarrier::install(TierDeleteChunkTestStage::DispatchAuthorized);
let worker_store = store.clone();
let worker = tokio::spawn(async move {
worker_store
.delete_object_with_tier_delete_journal(
bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
.await
.expect("chunk delete should persist its authorization before corruption injection");
worker.abort();
let _ = worker.await;
drop(barrier);
let records = store
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
None,
None,
10,
false,
None,
false,
)
.await
.expect("parent and child records should be listable");
let child = records
.objects
.iter()
.find(|object| object.name.contains("/chunks/"))
.expect("the bound child manifest should exist")
.name
.clone();
com::delete_config(store.clone(), &child)
.await
.expect("the test should remove only the child manifest");
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
let error = store
.delete_object_with_tier_delete_journal(
bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("a missing child with retained journals must quarantine the parent");
assert!(
error.to_string().contains("missing child with retained journals"),
"unexpected missing-child result: {error}"
);
assert_eq!(backend.object_count().await, 2, "fail-closed inspection must not remove a remote version");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn authorized_prefix_retry_replays_predecessor_before_newcomer() {
let handle = std::thread::Builder::new()
.name("authorized-prefix-predecessor-replay-test".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("authorized predecessor replay test runtime should build");
runtime.block_on(authorized_prefix_retry_replays_predecessor_before_newcomer_case());
})
.expect("authorized predecessor replay test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[cfg(feature = "test-util")]
async fn authorized_prefix_retry_replays_predecessor_before_newcomer_case() {
let _batch_limit = TierDeleteDispatchBatchLimitGuard::install(1);
let temp_dir = tempfile::tempdir().expect("create authorized predecessor replay store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "authorized-prefix-predecessor-replay", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "AUTHORIZED-PREFIX-REPLAY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("authorized replay tier lease should resolve");
let bucket = "authorized-prefix-predecessor-replay-bucket";
let prefix = "prefix/";
let predecessor = "prefix/000-predecessor.bin";
let newcomer = "prefix/zzz-newcomer.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("authorized replay bucket should be created");
let mut predecessor_reader = PutObjReader::from_vec(vec![b'p'; 1024 * 1024]);
let predecessor_source = store
.put_object(bucket, predecessor, &mut predecessor_reader, &ObjectOptions::default())
.await
.expect("predecessor source should be written");
store
.transition_object(
bucket,
predecessor,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: predecessor_source.etag.clone().expect("predecessor should have an etag"),
..Default::default()
},
mod_time: predecessor_source.mod_time,
..Default::default()
},
)
.await
.expect("predecessor should transition");
let predecessor_info = store
.get_object_info(bucket, predecessor, &ObjectOptions::default())
.await
.expect("transitioned predecessor should be readable");
let mut predecessor_entry =
transitioned_delete_journal_entry_for_source(None, false, false, bucket, predecessor, &predecessor_info)
.expect("transitioned predecessor should produce a dispatch journal");
predecessor_entry.backend_identity = Some(lease.backend_identity());
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("authorized replay bucket incarnation should resolve");
let (manifest_name, _) = install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
prefix,
vec![(predecessor_entry, Some(TierDeleteJournalState::Dispatched))],
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized predecessor fixture should persist");
let mut newcomer_reader = PutObjReader::from_vec(vec![b'n'; 1024 * 1024]);
let newcomer_source = store
.put_object(bucket, newcomer, &mut newcomer_reader, &ObjectOptions::default())
.await
.expect("newcomer source should be written after authorization");
store
.transition_object(
bucket,
newcomer,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: newcomer_source.etag.clone().expect("newcomer should have an etag"),
..Default::default()
},
mod_time: newcomer_source.mod_time,
..Default::default()
},
)
.await
.expect("newcomer should transition");
backend.set_remove_failure(true);
let publication_epoch = store
.scanner_data_usage_publication_epoch()
.await
.expect("authorized replay should observe the current scanner publication epoch");
let publication_scope = store
.scanner_data_usage_publication_commit_scope(
publication_epoch,
tokio::time::Instant::now() + Duration::from_secs(30),
Vec::new(),
)
.await
.expect("authorized replay should acquire a scanner publication scope");
let retry = store
.delete_object_with_tier_delete_journal(
bucket,
prefix,
ObjectOptions {
delete_prefix: true,
scanner_publication_commit_scope: Some(publication_scope.clone()),
..Default::default()
},
)
.await
.expect_err("the predecessor-only replay must request a successor retry");
assert!(
retry.to_string().contains("retry the successor dispatch"),
"unexpected predecessor replay result: {retry}"
);
assert_eq!(
publication_scope.state(),
crate::object_api::ScannerPublicationCommitState::Committed,
"an exact predecessor replay must report its local mutation as committed"
);
assert!(publication_scope.release_movement_permit().await);
assert!(
store.pools[0]
.get_disks_by_key(predecessor)
.load_file_info_versions_exact(bucket, predecessor)
.await
.expect("predecessor metadata should remain readable")
.is_none(),
"the authorized predecessor must be removed exactly"
);
assert!(
store.pools[0]
.get_disks_by_key(newcomer)
.load_file_info_versions_exact(bucket, newcomer)
.await
.expect("newcomer metadata should remain readable")
.is_some(),
"the predecessor authorization must not delete the newcomer"
);
assert_eq!(
test_tier_delete_dispatch_manifest_state(store.clone(), &manifest_name)
.await
.expect("completed predecessor manifest should remain readable"),
Some(TierDeleteDispatchManifestState::Completed)
);
backend.set_remove_failure(false);
let predecessor_journals = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("completed predecessor journal should recover");
assert_eq!(predecessor_journals.failed, 0);
assert!(predecessor_journals.scanned <= 1 && predecessor_journals.deleted <= 1);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 1, "only the newcomer remote object should remain");
assert_eq!(backend.remove_versions().await.len(), 1);
let predecessor_manifest = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("completed predecessor manifest should be collected");
assert_eq!(predecessor_manifest.failed, 0);
assert!(predecessor_manifest.deleted <= 1);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
store
.delete_object_with_tier_delete_journal(
bucket,
prefix,
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("a successor dispatch should delete the remaining newcomer");
assert!(
store.pools[0]
.get_disks_by_key(newcomer)
.load_file_info_versions_exact(bucket, newcomer)
.await
.expect("newcomer metadata lookup should succeed after successor")
.is_none()
);
drive_tier_delete_dispatch_restart_to_convergence(store.clone()).await;
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(tier_delete_dispatch_manifest_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.remove_versions().await.len(), 2);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multi_pool_same_tuple_recursive_prefix_uses_one_journal_owner() {
let temp_dir = tempfile::tempdir().expect("create shared-tuple prefix-delete store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"multi-pool-shared-tuple-prefix-delete",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "SHARED-PREFIX-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "multi-pool-shared-tuple-prefix-delete-bucket";
let object = "prefix/shared.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("shared-tuple prefix bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b's'; 1024 * 1024]);
let source = store.pools[0]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("shared-tuple prefix source should be written");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("shared-tuple prefix source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("shared-tuple prefix source should transition");
copy_test_xlmeta_between_pools(temp_dir.path(), 0, 1, bucket, object).await;
assert_eq!(backend.object_count().await, 1);
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("shared-tuple recursive prefix delete should commit");
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
for pool_idx in 0..2 {
assert!(
store.pools[pool_idx]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("shared-tuple post-delete metadata should decode")
.is_none(),
"pool {pool_idx} must be physically empty"
);
}
backend.set_remove_failure(false);
ctx.wake_tier_delete_journal_recovery();
wait_for_tier_delete_journal_recovery(store.clone(), &backend, 1).await;
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.remove_versions().await.len(), 1);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multi_pool_recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete() {
let temp_dir = tempfile::tempdir().expect("create merge-loser prefix-delete store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"multi-pool-merge-loser-prefix-delete",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "MERGE-LOSER-PREFIX-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "multi-pool-merge-loser-prefix-delete-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("merge-loser prefix bucket should be created");
for (object, byte) in [("legacy/item.bin", b'l'), ("hidden/item.bin", b'h')] {
let mut reader = PutObjReader::from_vec(vec![byte; 1024 * 1024]);
let source = store.pools[0]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("merge-loser source should be written");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("merge-loser source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("merge-loser source should transition");
copy_test_xlmeta_between_pools(temp_dir.path(), 0, 1, bucket, object).await;
}
rewrite_transitioned_xlmeta_as_legacy_unknown(temp_dir.path(), 1, bucket, "legacy/item.bin").await;
backend.set_remove_failure(true);
store.pools[1]
.delete_object(bucket, "hidden/item.bin", ObjectOptions::default())
.await
.expect("second-pool source delete should retain a hidden free-version");
let legacy_error = store
.delete_object_with_tier_delete_journal(
bucket,
"legacy/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("a legacy merge loser must reject recursive deletion");
assert!(legacy_error.to_string().contains("legacy Unknown"), "unexpected error: {legacy_error}");
let hidden_error = store
.delete_object_with_tier_delete_journal(
bucket,
"hidden/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("a hidden free-version merge loser must reject recursive deletion");
assert!(
hidden_error.to_string().contains("free-version cleanup obligation"),
"unexpected error: {hidden_error}"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 2);
assert_eq!(backend.remove_count().await, 0);
for object in ["legacy/item.bin", "hidden/item.bin"] {
let pool0 = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("winner metadata should decode")
.expect("winner source must remain before destructive deletion");
assert_eq!(pool0.versions.iter().filter(|version| !version.tier_free_version()).count(), 1);
}
let hidden_pool1 = store.pools[1]
.get_disks_by_key("hidden/item.bin")
.load_file_info_versions_exact(bucket, "hidden/item.bin")
.await
.expect("hidden loser metadata should decode")
.expect("hidden loser cleanup owner must remain");
assert_eq!(
hidden_pool1
.versions
.iter()
.chain(hidden_pool1.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1
);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn recursive_prefix_partial_pool_failure_keeps_prepared_cleanup_owners() {
let temp_dir = tempfile::tempdir().expect("create partial prefix-delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "partial-pool-prefix-delete", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "PARTIAL-PREFIX-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "partial-pool-prefix-delete-bucket";
let object = "prefix/item.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("partial prefix bucket should be created");
for (pool_idx, byte) in [(0, b'a'), (1, b'b')] {
let mut reader = PutObjReader::from_vec(vec![byte; 1024 * 1024]);
let source = store.pools[pool_idx]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("pool-local prefix source should be written");
store.pools[pool_idx]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("pool-local source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("pool-local prefix source should transition");
}
assert_eq!(backend.object_count().await, 2);
backend.set_remove_failure(true);
let barrier = crate::store::object::PrefixDeleteAfterJournalPrepareBarrier::install(bucket, "prefix/");
let delete_store = store.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(Duration::from_secs(10), barrier.wait_until_paused())
.await
.expect("prefix delete should pause after every journal is Prepared");
assert_eq!(tier_delete_journal_count(store.clone()).await, 2);
let failed_set = store.pools[1].get_disks_by_key(object);
let offline_disks = force_set_disks_offline_for_test(&failed_set).await;
barrier.release();
let error = delete
.await
.expect("partial prefix delete task should join")
.expect_err("an offline second pool must fail the recursive prefix delete");
assert!(
matches!(
error,
StorageError::InsufficientWriteQuorum(_, _) | StorageError::InsufficientReadQuorum(_, _)
),
"unexpected error: {error:?}"
);
drop(offline_disks);
drop(barrier);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
2,
"an ambiguous partial mutation must retain every Dispatched owner"
);
let first_pool_state = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("first-pool metadata should remain decodable after an ambiguous failure");
if let Some(first_pool_state) = first_pool_state {
assert_eq!(
first_pool_state
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
1,
"a retained first-pool source must remain a visible source, not an unjournaled fallback owner"
);
}
assert!(
store.pools[1]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("failed-pool metadata should decode")
.is_some(),
"the failed pool should retain its live source"
);
let _ = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("Dispatched owners should reconcile after the failed pool returns");
assert_eq!(
tier_delete_journal_count(store.clone()).await,
2,
"recovery must never abort or delete a Dispatched journal while any source is still live"
);
store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("retry should delete the remaining pool source");
backend.set_remove_failure(false);
for _ in 0..3 {
let _ = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("remaining pool journals should settle after backend recovery");
if tier_delete_journal_count(store.clone()).await == 0 {
break;
}
}
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.remove_versions().await.len(), 2);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn recursive_prefix_partial_set_failure_keeps_prepared_cleanup_owners() {
let temp_dir = tempfile::tempdir().expect("create partial-set prefix-delete store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"partial-set-prefix-delete",
&[(2, 4)],
CancellationToken::new(),
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "PARTIAL-SET-PREFIX-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "partial-set-prefix-delete-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("partial-set prefix bucket should be created");
let objects = (0..2)
.map(|target_set| {
(0..512)
.map(|index| format!("prefix/set-{target_set}-{index}.bin"))
.find(|candidate| store.pools[0].get_disks_by_key(candidate).set_index == target_set)
.expect("a deterministic key should map to each test set")
})
.collect::<Vec<_>>();
for (index, object) in objects.iter().enumerate() {
let mut reader = PutObjReader::from_vec(vec![b'0' + index as u8; 1024 * 1024]);
let source = store.pools[0]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("set-local prefix source should be written");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("set-local source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("set-local prefix source should transition");
}
assert_eq!(backend.object_count().await, 2);
backend.set_remove_failure(true);
let barrier = crate::store::object::PrefixDeleteAfterJournalPrepareBarrier::install(bucket, "prefix/");
let delete_store = store.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(Duration::from_secs(10), barrier.wait_until_paused())
.await
.expect("set-scoped prefix delete should pause after journal Prepare");
assert_eq!(tier_delete_journal_count(store.clone()).await, 2);
let failed_set = store.pools[0].disk_set[1].clone();
let offline_disks = force_set_disks_offline_for_test(&failed_set).await;
barrier.release();
let error = delete
.await
.expect("partial-set prefix delete task should join")
.expect_err("an offline second set must fail recursive prefix deletion");
assert!(
matches!(
error,
StorageError::InsufficientWriteQuorum(_, _) | StorageError::InsufficientReadQuorum(_, _)
),
"unexpected error: {error:?}"
);
drop(offline_disks);
drop(barrier);
assert_eq!(tier_delete_journal_count(store.clone()).await, 2);
let first_set_state = store.pools[0]
.get_disks_by_key(&objects[0])
.load_file_info_versions_exact(bucket, &objects[0])
.await
.expect("first-set metadata should remain decodable after an ambiguous failure");
if let Some(first_set_state) = first_set_state {
assert_eq!(
first_set_state
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
1,
"a retained first-set source must remain visible under its Dispatched journal"
);
}
assert!(
store.pools[0]
.get_disks_by_key(&objects[1])
.load_file_info_versions_exact(bucket, &objects[1])
.await
.expect("failed-set metadata should decode")
.is_some()
);
let _ = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("set-scoped Dispatched owners should reconcile");
assert_eq!(
tier_delete_journal_count(store.clone()).await,
2,
"recovery must retain the complete Authorized dispatch until every source is absent"
);
store
.delete_object_with_tier_delete_journal(
bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("set-scoped retry should delete the remaining source");
backend.set_remove_failure(false);
for _ in 0..3 {
let _ = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("remaining set journals should settle after backend recovery");
if tier_delete_journal_count(store.clone()).await == 0 {
break;
}
}
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.remove_versions().await.len(), 2);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete() {
let temp_dir = tempfile::tempdir().expect("create versioned transitioned delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "versioned-transitioned-delete-owner", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "VERSIONED-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "versioned-transitioned-delete-owner-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("versioned source bucket should be created");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("bucket versioning should be enabled");
let mut first_reader = PutObjReader::from_vec(vec![b'1'; 1024 * 1024]);
let first = store
.put_object(
bucket,
object,
&mut first_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("first version should be written");
let first_version = first.version_id.expect("versioned PUT should return an identity");
store
.transition_object(
bucket,
object,
&ObjectOptions {
version_id: Some(first_version.to_string()),
versioned: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: first.etag.clone().expect("first version should have an etag"),
..Default::default()
},
mod_time: first.mod_time,
..Default::default()
},
)
.await
.expect("first version should transition");
let mut second_reader = PutObjReader::from_vec(vec![b'2'; 1024 * 1024]);
let second = store
.put_object(
bucket,
object,
&mut second_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("second version should be written");
let second_version = second.version_id.expect("second PUT should return an identity");
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(
bucket,
object,
ObjectOptions {
version_id: Some(first_version.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("explicit transitioned version delete should commit");
store
.get_object_info(
bucket,
object,
&ObjectOptions {
version_id: Some(second_version.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("the unrelated second version must remain readable");
let exact = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("remaining version metadata should decode")
.expect("the second version should remain");
assert_eq!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"ordinary exact-version delete must leave one hidden free-version owner"
);
assert!(
exact
.versions
.iter()
.any(|version| version.version_id == Some(second_version)),
"the explicit delete must not remove the other version"
);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary exact-version delete must not create a journal"
);
backend.set_remove_failure(false);
wait_for_tier_free_version_recovery(store.clone(), &backend, 1).await;
let exact = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-cleanup metadata should decode")
.expect("the unrelated second version should remain after cleanup");
assert_eq!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
0,
"free-version recovery must remove only its exact owner"
);
assert!(
exact
.versions
.iter()
.any(|version| version.version_id == Some(second_version)),
"free-version recovery must preserve the unrelated version"
);
store
.delete_object_with_tier_delete_journal(
bucket,
object,
ObjectOptions {
version_id: Some(second_version.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("last ordinary version should be removed");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("bucket delete should succeed after the last version is removed");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn suspended_null_transition_delete_uses_free_version_as_sole_owner() {
let temp_dir = tempfile::tempdir().expect("create suspended transitioned delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "suspended-transitioned-delete-owner", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "SUSPENDED-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "suspended-transitioned-delete-owner-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("suspended source bucket should be created");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("bucket versioning should be suspended");
let mut reader = PutObjReader::from_vec(vec![b's'; 1024 * 1024]);
let source = store
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
version_suspended: true,
..Default::default()
},
)
.await
.expect("suspended null source should be written");
let null_version = source.version_id.expect("suspended source should expose the null identity");
assert!(null_version.is_nil(), "suspended source identity must be null");
store
.transition_object(
bucket,
object,
&ObjectOptions {
version_id: Some(null_version.to_string()),
version_suspended: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("suspended source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("suspended null source should transition");
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(
bucket,
object,
ObjectOptions {
version_id: Some(null_version.to_string()),
version_suspended: true,
..Default::default()
},
)
.await
.expect("explicit null transitioned version delete should commit");
let exact = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-delete null metadata should decode")
.expect("ordinary null-version delete must retain a cleanup owner");
assert_eq!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"ordinary null-version delete must retain exactly one hidden free-version owner"
);
assert_eq!(
exact.versions.iter().filter(|version| !version.tier_free_version()).count(),
0,
"ordinary null-version delete must remove the visible source"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
backend.set_remove_failure(false);
wait_for_tier_free_version_recovery(store.clone(), &backend, 1).await;
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("suspended bucket should be empty after exact null-version cleanup");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn versioned_delete_marker_keeps_transitioned_source_and_remote_object() {
let temp_dir = tempfile::tempdir().expect("create versioned delete-marker store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transitioned-delete-marker", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "DELETE-MARKER";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "transitioned-delete-marker-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("versioned source bucket should be created");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("bucket versioning should be enabled");
let mut reader = PutObjReader::from_vec(vec![b'm'; 1024 * 1024]);
let source = store
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("versioned source should be written");
let source_version = source.version_id.expect("versioned source should have an identity");
store
.transition_object(
bucket,
object,
&ObjectOptions {
version_id: Some(source_version.to_string()),
versioned: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("versioned source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("versioned source should transition");
let marker = store
.delete_object_with_tier_delete_journal(
bucket,
object,
ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("versioned DELETE without versionId should create a marker");
assert!(marker.delete_marker);
let marker_version = marker.version_id.expect("delete marker should have an identity");
assert_eq!(backend.object_count().await, 1, "delete-marker creation must retain the remote object");
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"delete-marker creation must not schedule cleanup"
);
let exact = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("versioned delete-marker metadata should decode")
.expect("source and delete marker should remain on disk");
assert!(
exact.versions.iter().any(|version| {
version.version_id == Some(source_version) && version.transition_status == rustfs_filemeta::TRANSITION_COMPLETE
}),
"the transitioned source must remain behind the delete marker"
);
assert_eq!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
0,
"delete-marker creation must not create a cleanup free-version"
);
store
.delete_object_with_tier_delete_journal(
bucket,
object,
ObjectOptions {
version_id: Some(marker_version.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("delete marker should be removable by exact identity");
store
.delete_object_with_tier_delete_journal(
bucket,
object,
ObjectOptions {
version_id: Some(source_version.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("transitioned source should be removable by exact identity");
wait_for_tier_free_version_recovery(store.clone(), &backend, 1).await;
if let Some(exact) = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-recovery version metadata should decode")
{
assert!(
exact
.versions
.iter()
.chain(exact.free_versions.iter())
.all(|version| !version.tier_free_version()),
"free-version recovery must remove the all-physical cleanup owner"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("versioned source bucket should be empty after explicit cleanup");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn batch_transitioned_delete_uses_free_version_per_item() {
let temp_dir = tempfile::tempdir().expect("create batch transitioned delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-transitioned-delete-owner", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "BATCH-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "batch-transitioned-delete-owner-bucket";
let transitioned = "transitioned.bin";
let ordinary = "ordinary.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("batch source bucket should be created");
let mut transitioned_reader = PutObjReader::from_vec(vec![b't'; 1024 * 1024]);
let transitioned_source = store
.put_object(bucket, transitioned, &mut transitioned_reader, &ObjectOptions::default())
.await
.expect("transitioned batch source should be written");
store
.transition_object(
bucket,
transitioned,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: transitioned_source
.etag
.clone()
.expect("transitioned batch source should have an etag"),
..Default::default()
},
mod_time: transitioned_source.mod_time,
..Default::default()
},
)
.await
.expect("batch source transition should commit");
let mut ordinary_reader = PutObjReader::from_vec(vec![b'o'; 1024 * 1024]);
store
.put_object(bucket, ordinary, &mut ordinary_reader, &ObjectOptions::default())
.await
.expect("ordinary batch source should be written");
backend.set_remove_failure(true);
let (_deleted, errors) = store
.delete_objects_with_tier_delete_journal(
bucket,
vec![
ObjectToDelete {
object_name: transitioned.to_string(),
..Default::default()
},
ObjectToDelete {
object_name: ordinary.to_string(),
..Default::default()
},
ObjectToDelete {
object_name: "missing.bin".to_string(),
..Default::default()
},
],
ObjectOptions::default(),
)
.await;
assert!(errors.iter().all(Option::is_none), "S3 batch delete should be idempotent: {errors:?}");
let transitioned_versions = store.pools[0]
.get_disks_by_key(transitioned)
.load_file_info_versions_exact(bucket, transitioned)
.await
.expect("transitioned batch metadata should remain decodable")
.expect("transitioned batch item must retain a cleanup owner");
assert_eq!(
transitioned_versions
.versions
.iter()
.chain(transitioned_versions.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"transitioned batch item must retain exactly one hidden free-version owner"
);
assert_eq!(
transitioned_versions
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0,
"transitioned batch item must have no visible source"
);
assert!(
store.pools[0]
.get_disks_by_key(ordinary)
.load_file_info_versions_exact(bucket, ordinary)
.await
.expect("ordinary batch metadata should remain decodable")
.is_none(),
"ordinary batch item should have no local metadata"
);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary batch delete must not create a tier-delete journal"
);
assert_eq!(backend.object_count().await, 1, "failed remote cleanup must retain its object");
backend.set_remove_failure(false);
wait_for_tier_free_version_recovery(store.clone(), &backend, 1).await;
assert!(
store.pools[0]
.get_disks_by_key(transitioned)
.load_file_info_versions_exact(bucket, transitioned)
.await
.expect("cleaned batch metadata should remain decodable")
.is_none(),
"free-version recovery must remove the exact cleanup owner"
);
let causal = "causal.bin";
let mut causal_reader = PutObjReader::from_vec(vec![b'c'; 1024 * 1024]);
let causal_source = store
.put_object(bucket, causal, &mut causal_reader, &ObjectOptions::default())
.await
.expect("causal batch source should be written");
store
.transition_object(
bucket,
causal,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: causal_source.etag.clone().expect("causal batch source should have an etag"),
..Default::default()
},
mod_time: causal_source.mod_time,
..Default::default()
},
)
.await
.expect("causal batch source transition should commit");
let (_deleted, errors) = store
.delete_objects(
bucket,
vec![
ObjectToDelete {
object_name: causal.to_string(),
..Default::default()
},
ObjectToDelete {
object_name: causal.to_string(),
..Default::default()
},
],
ObjectOptions::default(),
)
.await;
assert!(
errors.iter().all(Option::is_none),
"duplicate causal batch deletes should remain idempotent: {errors:?}"
);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(causal)
.load_file_info_versions_exact(bucket, causal)
.await
.expect("causal batch cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() >= 2 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("batch free-version receipt should converge without another recovery scan");
assert_eq!(
backend.remove_versions().await.len(),
2,
"duplicate batch requests must cause only one remote delete for the causal object"
);
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("causal batch bucket versioning should be enabled");
let versioned_causal = "versioned-causal.bin";
let mut versioned_reader = PutObjReader::from_vec(vec![b'v'; 1024 * 1024]);
let versioned_source = store
.put_object(
bucket,
versioned_causal,
&mut versioned_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("versioned causal batch source should be written");
let versioned_source_id = versioned_source
.version_id
.expect("versioned causal batch source should have an identity");
store
.transition_object(
bucket,
versioned_causal,
&ObjectOptions {
version_id: Some(versioned_source_id.to_string()),
versioned: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: versioned_source
.etag
.clone()
.expect("versioned causal batch source should have an etag"),
..Default::default()
},
mod_time: versioned_source.mod_time,
..Default::default()
},
)
.await
.expect("versioned causal batch source transition should commit");
let (_deleted, errors) = store
.delete_objects(
bucket,
vec![ObjectToDelete {
object_name: versioned_causal.to_string(),
version_id: Some(versioned_source_id),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(
errors.iter().all(Option::is_none),
"explicit-version causal batch delete should commit: {errors:?}"
);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(versioned_causal)
.load_file_info_versions_exact(bucket, versioned_causal)
.await
.expect("versioned causal batch cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() == 3 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("explicit-version batch receipt should converge without a recovery scan");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("batch source bucket should be physically empty");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn batch_transitioned_delete_aggregate_error_still_enqueues_committed_free_version() {
let temp_dir = tempfile::tempdir().expect("create aggregate-error batch delete store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-transitioned-aggregate-error", &[4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "BATCH-AGGREGATE-ERROR";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "batch-transitioned-aggregate-error-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("aggregate-error source bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b'a'; 1024 * 1024]);
let source = store.pools[0]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("aggregate-error source should be written");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("aggregate-error source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("aggregate-error source should transition");
// Model a data-movement copy: both pools own the same logical source
// and exact remote tuple, but each batch delete creates its own local
// free-version UUID in the shared request sink.
for disk_index in 0..4 {
let source_meta = temp_dir
.path()
.join(format!("pool0/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let target_meta = temp_dir
.path()
.join(format!("pool1/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
tokio::fs::create_dir_all(target_meta.parent().expect("target xl.meta should have a parent"))
.await
.expect("second-pool object directory should be created");
tokio::fs::copy(&source_meta, &target_meta)
.await
.expect("transitioned xl.meta should copy exactly to the second pool");
}
ExpiryState::resize_workers(1, store.clone()).await;
let injection = crate::store::object::BatchDeletePoolErrorInjection::install(
bucket,
1,
vec![(object.to_string(), StorageError::ErasureWriteQuorum)],
);
let (deleted, errors) = store
.delete_objects(
bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert_eq!(injection.observed(), 1, "the second pool should inject one post-commit aggregate error");
assert_eq!(errors, vec![Some(StorageError::ErasureWriteQuorum)]);
assert!(deleted[0].found, "the aggregate error must retain the committed pool result");
drop(injection);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let mut metadata_absent = true;
for pool in &store.pools {
metadata_absent &= pool
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("aggregate-error cleanup metadata should remain readable")
.is_none();
}
if metadata_absent && backend.remove_count().await == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aggregate failure must not suppress committed receipt dispatch");
assert_eq!(backend.object_count().await, 0, "the shared remote object should be removed exactly once");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("aggregate-error bucket should be physically empty");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transitioned_delete_free_version_replays_after_store_restart() {
let temp_dir = tempfile::tempdir().expect("create restarted free-version store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transitioned-delete-restart-before", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "DELETE-RESTART";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let tier_config = ctx
.tier_config_mgr()
.read()
.await
.tiers
.get(tier_name)
.expect("mock tier config should exist")
.clone_with_credentials();
let bucket = "transitioned-delete-restart-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("restart source bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b'r'; 1024 * 1024]);
let original = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("restart source object should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("restart source should have an etag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("restart source transition should commit");
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("local source delete should commit while remote cleanup is retryable");
let retained = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-delete local metadata should decode")
.expect("ordinary delete must retain a free-version across restart");
assert_eq!(
retained
.versions
.iter()
.chain(retained.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"ordinary delete must retain exactly one free-version across restart"
);
assert_eq!(
retained
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0,
"ordinary delete must remove the visible source before restart"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 1);
shutdown.cancel();
drop(store);
drop(ctx);
let (restarted_ctx, restarted_store, restarted_shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transitioned-delete-restart-after", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(restarted_store.clone(), Vec::new()).await;
{
let restarted_tier_config_mgr = restarted_ctx.tier_config_mgr();
let mut manager = restarted_tier_config_mgr.write().await;
manager.tiers.insert(tier_name.to_string(), tier_config);
manager
.install_test_driver(tier_name, Box::new(backend.clone()))
.expect("restarted store should bind the original tier destination");
}
backend.set_remove_failure(false);
wait_for_tier_free_version_recovery(restarted_store.clone(), &backend, 1).await;
assert_eq!(tier_delete_journal_count(restarted_store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0);
assert!(
restarted_store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("restarted cleanup metadata should decode")
.is_none(),
"restarted recovery must remove the exact free-version owner"
);
restarted_store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("source bucket should be empty after restarted free-version recovery");
restarted_shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transitioned_delete_local_quorum_failure_rolls_back_without_cleanup_owner() {
let temp_dir = tempfile::tempdir().expect("create failed local delete store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transitioned-delete-local-failure", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "DELETE-LOCAL-FAIL";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "transitioned-delete-local-failure-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("failed-delete source bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b'q'; 1024 * 1024]);
let source = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("failed-delete source should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("failed-delete source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("failed-delete source should transition");
let set = store.pools[0].get_disks_by_key(object);
let saved_disk = {
let mut disks = set.disks.write().await;
let first = disks[0].as_ref().expect("first disk should be online");
crate::disk::local::set_delete_version_fail_after_commit(first.path().as_path(), object);
disks[1].take().expect("second disk should be online before fault injection")
};
let err = store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect_err("one post-commit error plus one offline disk must miss delete quorum");
assert!(matches!(err, StorageError::Io(_)), "unexpected local delete failure: {err:?}");
assert!(
err.to_string()
.contains("Storage resources are insufficient for the write operation"),
"the caller must observe the wrapped quorum failure: {err}"
);
set.disks.write().await[1] = Some(saved_disk);
let retained = store
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("failed local delete must roll back the transitioned source");
assert_eq!(retained.transitioned_object.status, rustfs_filemeta::TRANSITION_COMPLETE);
let retained_versions = set
.load_file_info_versions_exact(bucket, object)
.await
.expect("rolled-back metadata should decode")
.expect("rolled-back source should remain on disk");
assert_eq!(
retained_versions
.versions
.iter()
.chain(retained_versions.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
0,
"failed local quorum must not create a cleanup owner"
);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary delete must not create a journal, including on rollback"
);
assert_eq!(backend.object_count().await, 1, "failed local commit must retain the remote object");
assert_eq!(backend.remove_count().await, 0, "failed local commit must not dispatch remote cleanup");
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("retry after disk recovery should commit");
let cleanup_owner = set
.load_file_info_versions_exact(bucket, object)
.await
.expect("retry metadata should decode")
.expect("successful retry must leave a free-version owner");
assert_eq!(
cleanup_owner
.versions
.iter()
.chain(cleanup_owner.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"successful retry must leave exactly one free-version owner"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
backend.set_remove_failure(false);
wait_for_tier_free_version_recovery(store.clone(), &backend, 1).await;
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("retry should leave the source bucket empty");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn batch_transitioned_delete_post_commit_failures_roll_back_without_free_version_receipt() {
let temp_dir = tempfile::tempdir().expect("create failed batch delete store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-delete-local-failure", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "BATCH-DELETE-LOCAL-FAIL";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "batch-delete-local-failure-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("failed batch source bucket should be created");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("failed batch bucket versioning should be enabled");
let mut transitioned_reader = PutObjReader::from_vec(vec![b't'; 1024 * 1024]);
let transitioned_source = store
.put_object(
bucket,
object,
&mut transitioned_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch transitioned version should be written");
let transitioned_version_id = transitioned_source
.version_id
.expect("failed batch transitioned source should have a version identity");
store
.transition_object(
bucket,
object,
&ObjectOptions {
version_id: Some(transitioned_version_id.to_string()),
versioned: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: transitioned_source
.etag
.clone()
.expect("failed batch transitioned source should have an etag"),
..Default::default()
},
mod_time: transitioned_source.mod_time,
..Default::default()
},
)
.await
.expect("failed batch source version should transition");
let mut ordinary_reader = PutObjReader::from_vec(vec![b'o'; 1024 * 1024]);
let ordinary_source = store
.put_object(
bucket,
object,
&mut ordinary_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch ordinary sibling should be written");
let ordinary_version_id = ordinary_source
.version_id
.expect("failed batch ordinary sibling should have a version identity");
let delete_requests = || {
vec![
ObjectToDelete {
object_name: object.to_string(),
version_id: Some(transitioned_version_id),
..Default::default()
},
ObjectToDelete {
object_name: object.to_string(),
version_id: Some(ordinary_version_id),
..Default::default()
},
]
};
let set = store.pools[0].get_disks_by_key(object);
let disks = set.disks.read().await;
assert_eq!(disks.len(), 4, "the rollback fixture must use four disks");
// Keep discovery fully online, then make a quorum of disks report an
// error only after their batch metadata commit has completed.
for disk in disks.iter().take(3) {
let disk = disk.as_ref().expect("injected rollback disks should be online");
crate::disk::local::set_delete_version_fail_after_commit(disk.path().as_path(), object);
}
drop(disks);
let receipt_sink = crate::object_api::TierFreeVersionReceiptSink::new();
let (_deleted, errors) = store
.delete_objects(
bucket,
delete_requests(),
ObjectOptions {
tier_free_version_receipt_sink: Some(receipt_sink.clone()),
..Default::default()
},
)
.await;
assert_eq!(
errors,
vec![Some(StorageError::Unexpected), Some(StorageError::Unexpected),],
"three post-commit disk errors must fail batch delete before receipts publish"
);
assert!(
receipt_sink
.drain()
.expect("the test-owned failed-batch sink should drain exactly once")
.is_empty(),
"a rolled-back physical group must publish no cleanup receipt"
);
let retained_transitioned = store
.get_object_info(
bucket,
object,
&ObjectOptions {
version_id: Some(transitioned_version_id.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch delete must restore the transitioned sibling");
assert_eq!(retained_transitioned.transitioned_object.status, rustfs_filemeta::TRANSITION_COMPLETE);
let retained_ordinary = store
.get_object_info(
bucket,
object,
&ObjectOptions {
version_id: Some(ordinary_version_id.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch delete must restore the ordinary sibling");
assert_ne!(retained_ordinary.transitioned_object.status, rustfs_filemeta::TRANSITION_COMPLETE);
let retained_versions = set
.load_file_info_versions_exact(bucket, object)
.await
.expect("rolled-back batch metadata should decode")
.expect("rolled-back batch source should remain on disk");
assert_eq!(
retained_versions
.versions
.iter()
.chain(retained_versions.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
0,
"failed batch quorum must not retain a free-version owner"
);
let retained_version_ids = retained_versions
.versions
.iter()
.filter_map(|version| version.version_id)
.collect::<std::collections::HashSet<_>>();
assert_eq!(
retained_version_ids,
std::collections::HashSet::from([transitioned_version_id, ordinary_version_id]),
"the physical-group rollback must restore both explicit siblings"
);
assert_eq!(backend.object_count().await, 1, "failed batch commit must retain the remote object");
assert_eq!(backend.remove_count().await, 0, "failed batch commit must not dispatch remote cleanup");
ExpiryState::resize_workers(1, store.clone()).await;
let (_deleted, retry_errors) = store
.delete_objects(bucket, delete_requests(), ObjectOptions::default())
.await;
assert!(
retry_errors.iter().all(Option::is_none),
"retry after disk recovery should commit: {retry_errors:?}"
);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = set
.load_file_info_versions_exact(bucket, object)
.await
.expect("retry cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_count().await == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("successful batch retry should converge without a recovery scan");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("successful batch retry should leave the bucket empty");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
async fn run_multi_pool_same_remote_tuple_delete_case(batch: bool) {
let temp_dir = tempfile::tempdir().expect("create shared-tuple multi-pool store dir");
let case = if batch { "batch" } else { "single" };
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
&format!("multi-pool-same-tuple-{case}"),
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = if batch { "SAME-TUPLE-BATCH" } else { "SAME-TUPLE-SINGLE" };
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = format!("multi-pool-same-tuple-{case}-bucket");
let object = "archive.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("shared-tuple bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b's'; 1024 * 1024]);
let source = store.pools[0]
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("first pool source should be written");
store.pools[0]
.transition_object(
&bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("shared source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("first pool source should transition");
let first = store.pools[0]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("first pool transitioned source should be readable");
// Model the exact metadata duplication produced by data movement:
// both pools refer to the same remote object/version/backend and carry
// the same stable source identity.
for disk_index in 0..4 {
let source_meta = temp_dir
.path()
.join(format!("pool0/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let target_meta = temp_dir
.path()
.join(format!("pool1/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
tokio::fs::create_dir_all(target_meta.parent().expect("target xl.meta should have a parent"))
.await
.expect("second pool object directory should be created");
tokio::fs::copy(&source_meta, &target_meta)
.await
.expect("transitioned xl.meta should copy exactly to the second pool");
}
let second = store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("copied second-pool source should be readable");
assert_eq!(
(first.transitioned_object.name.clone(), first.transitioned_object.version_id.clone()),
(second.transitioned_object.name.clone(), second.transitioned_object.version_id.clone())
);
assert_eq!(backend.object_count().await, 1);
let receipt_sink = crate::object_api::TierFreeVersionReceiptSink::new();
let mut delete_opts = ObjectOptions {
tier_delete_journal_api: Some(store.clone()),
tier_free_version_receipt_sink: Some(receipt_sink.clone()),
..Default::default()
};
if batch {
delete_opts.delete_replication_config_snapshot = Some(Arc::new(
ReplicationObjectBridge::delete_request_config_in(&store.ctx, &bucket)
.await
.expect("batch delete replication snapshot should load"),
));
let (_deleted, errors, _accounting) = store.pools[0]
.delete_objects_with_accounting(
&bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
delete_opts.clone(),
)
.await;
assert!(errors.iter().all(Option::is_none), "first pool batch delete should commit: {errors:?}");
} else {
store.pools[0]
.delete_object(&bucket, object, delete_opts.clone())
.await
.expect("first pool single delete should commit");
}
let first_pool_versions = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("first-pool metadata should decode")
.expect("first pool must retain a free-version owner");
assert_eq!(
first_pool_versions
.versions
.iter()
.chain(first_pool_versions.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"first pool must retain exactly one free-version owner"
);
assert_eq!(
first_pool_versions
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0,
"first pool must remove its visible source"
);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary pool-local delete must not create a journal"
);
ExpiryState::resize_workers(1, store.clone()).await;
let first_recovery = recover_tier_free_versions(store.clone(), 100, None, None)
.await
.expect("first-pool free-version should be recoverable");
assert_eq!(
(first_recovery.enqueued, first_recovery.failed),
(0, 0),
"the live second-pool source is the merged walk winner, so its hidden loser must not dispatch cleanup"
);
wait_for_expiry_workers_idle(&store).await;
assert_eq!(backend.remove_count().await, 0, "live second-pool source must gate remote DELETE");
assert_eq!(backend.object_count().await, 1, "live source must retain its remote body");
assert!(
store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("blocked cleanup metadata should decode")
.is_some(),
"blocked cleanup must retain the first-pool free-version"
);
let surviving = store.pools[1]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("second-pool source must remain GET-addressable");
assert_eq!(surviving.transitioned_object.name, first.transitioned_object.name);
backend.set_remove_failure(true);
if batch {
let (_deleted, errors, _accounting) = store.pools[1]
.delete_objects_with_accounting(
&bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
delete_opts,
)
.await;
assert!(errors.iter().all(Option::is_none), "second pool batch retry should commit: {errors:?}");
} else {
store.pools[1]
.delete_object(&bucket, object, delete_opts)
.await
.expect("second pool single retry should commit");
}
wait_for_expiry_workers_idle(&store).await;
let mut free_version_ids = Vec::new();
for pool_idx in 0..2 {
let retained = store.pools[pool_idx]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("shared-tuple free-version metadata should decode")
.expect("remote failure must retain every shared-tuple cleanup owner");
let owners = retained
.versions
.iter()
.chain(retained.free_versions.iter())
.filter(|version| version.tier_free_version())
.collect::<Vec<_>>();
assert_eq!(owners.len(), 1, "pool {pool_idx} must retain one free-version owner");
free_version_ids.push(owners[0].version_id);
}
assert_ne!(
free_version_ids[0], free_version_ids[1],
"ordinary pool-local deletes must model distinct local free-version identities"
);
backend.set_remove_failure(false);
let receipts = receipt_sink
.drain()
.expect("the simulated outer multi-pool wrapper should drain exactly once");
assert_eq!(
receipts.len(),
1,
"the same physical key and remote tuple must collapse to one causal task"
);
assert_eq!(
crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_committed_free_versions(&store, receipts).await,
1,
"the committed shared-tuple task should enter the running worker"
);
wait_for_expiry_workers_idle(&store).await;
assert_eq!(backend.remove_count().await, 1, "shared remote tuple should be deleted exactly once");
for pool_idx in 0..2 {
assert!(
store.pools[pool_idx]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("post-delete metadata should decode")
.is_none(),
"pool {pool_idx} must not retain source or free-version metadata"
);
}
store
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("shared-tuple bucket should be physically empty");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multi_pool_same_remote_tuple_single_delete_waits_for_all_sources() {
run_multi_pool_same_remote_tuple_delete_case(false).await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multi_pool_same_remote_tuple_batch_delete_waits_for_all_sources() {
run_multi_pool_same_remote_tuple_delete_case(true).await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn multi_pool_transitioned_delete_persists_one_free_version_per_remote_tuple() {
let temp_dir = tempfile::tempdir().expect("create multi-pool transitioned delete store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"multi-pool-transitioned-delete-owner",
&[4, 4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "MULTIPOOL-DELETE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "multi-pool-transitioned-delete-owner-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("multi-pool source bucket should be created");
let mut remote_tuples = Vec::new();
for pool_idx in 0..2 {
let mut reader = PutObjReader::from_vec(vec![b'a' + pool_idx as u8; 1024 * 1024]);
let source = store.pools[pool_idx]
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
mod_time: Some(OffsetDateTime::now_utc() + time::Duration::seconds(pool_idx as i64)),
..Default::default()
},
)
.await
.expect("pool-local source should be written");
store.pools[pool_idx]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("pool-local source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("pool-local source should transition");
let transitioned = store.pools[pool_idx]
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("pool-local transitioned source should be readable");
remote_tuples.push((
transitioned.transitioned_object.name.clone(),
transitioned.transitioned_object.version_id.clone(),
));
}
assert_ne!(remote_tuples[0], remote_tuples[1], "fixture must use distinct remote cleanup tuples");
assert_eq!(backend.object_count().await, 2);
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("store-level delete should remove both pool copies");
for pool_idx in 0..2 {
let retained = store.pools[pool_idx]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("pool-local post-delete metadata should decode")
.expect("each pool-specific remote tuple must retain a cleanup owner");
assert_eq!(
retained
.versions
.iter()
.chain(retained.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
1,
"pool {pool_idx} must retain exactly one free-version owner"
);
assert_eq!(
retained
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0,
"pool {pool_idx} must not retain a visible source"
);
}
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary multi-pool delete must not create tier-delete journals"
);
assert_eq!(backend.object_count().await, 2, "failed remote cleanup must retain both remote objects");
backend.set_remove_failure(false);
wait_for_tier_free_version_recovery(store.clone(), &backend, 2).await;
let mut removed = backend.remove_versions().await;
removed.sort();
remote_tuples.sort();
assert_eq!(removed, remote_tuples, "recovery must use each pool's exact remote tuple");
for pool_idx in 0..2 {
assert!(
store.pools[pool_idx]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("post-recovery pool metadata should decode")
.is_none(),
"pool {pool_idx} must not retain source or free-version metadata"
);
}
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("multi-pool source bucket should be physically empty");
}
// Phase 5 follow-up (backlog#1052): building a real store through the
// ctx-explicit constructor lands every construction-time write — object
// graph adoption, local-disk registry, deployment id — on the passed
// context, not on the process bootstrap one. This is the storage-layer
// seam a future second embedded server needs to stay isolated.
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn new_with_instance_ctx_threads_context_through_store_graph() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (instance_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "instance-ctx-store-graph-test", &[4])).await;
assert!(
Arc::ptr_eq(&store.ctx, &instance_ctx),
"the store must adopt the explicitly passed instance context"
);
for sets in &store.pools {
assert!(
Arc::ptr_eq(sets.instance_ctx(), &instance_ctx),
"every pool's Sets must carry the passed instance context"
);
}
assert_eq!(
instance_ctx.deployment_id(),
Some(store.id),
"the deployment id must land on the passed context and mirror the store id"
);
let registered: Vec<String> = instance_ctx.local_disk_map().read().await.keys().cloned().collect();
assert_eq!(registered.len(), 4, "the passed context must register all four local disks");
let registered_disk_ids = instance_ctx.local_disk_id_map();
let registered_disk_ids = registered_disk_ids.read().await;
assert_eq!(registered_disk_ids.len(), 4, "the passed context must publish all four disk IDs");
for endpoint in registered_disk_ids.values() {
assert!(
registered.contains(endpoint),
"every disk ID in the passed context must resolve to one of its registered endpoints"
);
}
drop(registered_disk_ids);
let bootstrap = crate::runtime::instance::bootstrap_ctx();
assert_ne!(
bootstrap.deployment_id(),
Some(store.id),
"the bootstrap context must not absorb the fresh store's deployment id"
);
let bootstrap_map = bootstrap.local_disk_map();
let bootstrap_map = bootstrap_map.read().await;
for key in &registered {
assert!(
!bootstrap_map.contains_key(key),
"the bootstrap context must not absorb the fresh store's disks"
);
}
drop(bootstrap_map);
let bootstrap_disk_ids = bootstrap.local_disk_id_map();
let bootstrap_disk_ids = bootstrap_disk_ids.read().await;
for endpoint in bootstrap_disk_ids.values() {
assert!(
!registered.contains(endpoint),
"the bootstrap context must not absorb the fresh store's disk IDs"
);
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn new_with_instance_ctx_applies_default_parity_to_each_real_pool() {
let temp_dir = tempfile::tempdir().expect("create multi-pool store dir");
let (_, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "pool-parity-regression", &[4, 2])).await;
assert_eq!(store.pools.len(), 2);
assert_eq!(store.pools[0].default_parity_count, 2);
assert_eq!(store.pools[0].disk_set[0].default_parity_count, 2);
assert_eq!(store.pools[1].default_parity_count, 1);
assert_eq!(store.pools[1].disk_set[0].default_parity_count, 1);
}
// backlog#1052 S3: two stores in one process each initialize their own
// bucket metadata system on their own instance context. Before this, the
// second `init_bucket_metadata_sys` panicked on the process-global
// OnceLock — the hard blocker for a second embedded server's services.
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn two_stores_initialize_their_own_bucket_metadata_sys() {
let temp_a = tempfile::tempdir().expect("create temp store dir a");
let temp_b = tempfile::tempdir().expect("create temp store dir b");
let (ctx_a, store_a, _shutdown_a) =
without_storage_class_env(build_isolated_test_store(temp_a.path(), "bucket-metadata-isolation-a", &[4])).await;
let (ctx_b, store_b, _shutdown_b) =
without_storage_class_env(build_isolated_test_store(temp_b.path(), "bucket-metadata-isolation-b", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_a.clone(), Vec::new()).await;
// The old process-global cell would panic right here.
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_b.clone(), Vec::new()).await;
let sys_a = ctx_a
.bucket_metadata_sys()
.expect("store A's context must hold its metadata system");
let sys_b = ctx_b
.bucket_metadata_sys()
.expect("store B's context must hold its metadata system");
assert!(!Arc::ptr_eq(&sys_a, &sys_b), "each store must own a distinct bucket metadata system");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn same_name_bucket_recursive_delete_uses_owning_instance_object_lock_state() {
let temp_a = tempfile::tempdir().expect("create temp store dir a");
let temp_b = tempfile::tempdir().expect("create temp store dir b");
let (_ctx_a, store_a, _shutdown_a) =
without_storage_class_env(build_isolated_test_store(temp_a.path(), "delete-scope-a", &[4])).await;
let (_ctx_b, store_b, _shutdown_b) =
without_storage_class_env(build_isolated_test_store(temp_b.path(), "delete-scope-b", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_a.clone(), Vec::new()).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_b.clone(), Vec::new()).await;
let bucket = format!("same-name-delete-scope-{}", uuid::Uuid::new_v4());
store_a
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create plain bucket in store A");
store_b
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create Object Lock bucket in store B");
for store in [&store_a, &store_b] {
let mut reader = PutObjReader::from_vec(b"payload".to_vec());
store
.put_object(
&bucket,
"prefix/object.bin",
&mut reader,
&ObjectOptions {
versioned: Arc::ptr_eq(store, &store_b),
..Default::default()
},
)
.await
.expect("put same-name object");
}
store_a
.delete_object(
&bucket,
"prefix",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect("plain store prefix delete should remain allowed");
let err = store_b
.delete_object(
&bucket,
"prefix",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("store B must not consult store A's plain bucket metadata");
assert!(matches!(err, StorageError::InvalidArgument(_, _, _)));
store_b
.get_object_info(&bucket, "prefix/object.bin", &ObjectOptions::default())
.await
.expect("locked store object must survive the rejected prefix delete");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn recursive_delete_checks_object_lock_on_every_version_page() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "recursive-delete-pagination", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("recursive-page-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create plain bucket");
for object in ["prefix/a.bin", "prefix/b.bin"] {
let mut reader = PutObjReader::from_vec(b"plain".to_vec());
store
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("write first-page object");
}
let locked_object = "prefix/z-locked.bin";
let mut reader = PutObjReader::from_vec(b"locked".to_vec());
store
.put_object(
&bucket,
locked_object,
&mut reader,
&ObjectOptions {
user_defined: HashMap::from([
(
s3s::header::X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string(),
),
(
s3s::header::X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
"2099-01-01T00:00:00Z".to_string(),
),
]),
..Default::default()
},
)
.await
.expect("write second-page locked object");
let first_page = store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(&bucket, "prefix/", None, None, 2)
.await
.expect("list the first recursive-delete validation page");
assert!(first_page.is_truncated, "the fixture must require a continuation page");
assert!(
first_page.objects.iter().all(|object| object.name != locked_object),
"the locked object must not be visible on the first page"
);
let second_page = store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(
&bucket,
"prefix/",
first_page.next_marker,
first_page.next_version_idmarker,
2,
)
.await
.expect("list the recursive-delete continuation page");
assert!(
second_page.objects.iter().any(|object| object.name == locked_object),
"the continuation page must contain the locked object"
);
let err = store
.delete_object(
&bucket,
"prefix/",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("recursive delete must scan the continuation page and find the locked object");
assert!(
matches!(err, StorageError::PrefixAccessDenied(ref name, ref object) if name == &bucket && object == locked_object),
"unexpected recursive delete error: {err:?}"
);
for object in ["prefix/a.bin", "prefix/b.bin", locked_object] {
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("a rejected recursive delete must leave every page intact");
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_holds_bucket_incarnation_sentinel_through_object_commit() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "delete-bucket-incarnation", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("delete-bucket-incarnation-{}", uuid::Uuid::new_v4());
let object = "same-key.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create initial plain bucket");
let old_incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("read initial bucket incarnation");
let mut reader = PutObjReader::from_vec(b"old generation".to_vec());
store
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("put old-generation object");
let barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object(&delete_bucket, object, ObjectOptions::default())
.await
});
barrier.wait_until_paused().await;
let recreate_store = Arc::clone(&store);
let recreate_bucket = bucket.clone();
let mut recreate = tokio::spawn(async move {
recreate_store
.delete_bucket(
&recreate_bucket,
&crate::storage_api_contracts::bucket::DeleteBucketOptions {
force: true,
..Default::default()
},
)
.await?;
recreate_store
.make_bucket(
&recreate_bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await?;
let mut reader = PutObjReader::from_vec(b"new protected generation".to_vec());
recreate_store
.put_object(
&recreate_bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await?;
Ok::<_, Error>(())
});
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut recreate).await.is_err(),
"bucket delete and same-name recreation must wait for the in-flight object delete"
);
barrier.release();
delete
.await
.expect("delete task should join")
.expect("old-generation delete should complete");
tokio::time::timeout(Duration::from_secs(10), recreate)
.await
.expect("bucket recreation should finish after delete releases the sentinel")
.expect("bucket recreation task should join")
.expect("bucket recreation should succeed");
let stale_delete_err = store
.delete_object(
&bucket,
object,
ObjectOptions {
expected_bucket_incarnation_id: Some(old_incarnation),
versioned: true,
..Default::default()
},
)
.await
.expect_err("an authorization snapshot from the old bucket incarnation must be rejected");
assert!(matches!(stale_delete_err, StorageError::BucketNotFound(name) if name == bucket));
let (_deleted, stale_batch_errors) = store
.delete_objects(
&bucket,
vec![crate::storage_api_contracts::object::ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions {
expected_bucket_incarnation_id: Some(old_incarnation),
versioned: true,
..Default::default()
},
)
.await;
assert!(
matches!(stale_batch_errors.as_slice(), [Some(StorageError::BucketNotFound(name))] if name.as_str() == bucket),
"batch delete must reject an authorization snapshot from the old bucket incarnation"
);
store
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("the new locked-bucket object must survive the old-generation delete");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_objects_holds_bucket_incarnation_sentinel_through_commit() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "batch-delete-bucket-incarnation", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("batch-incarnation-{}", uuid::Uuid::new_v4());
let object = "same-key.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create initial bucket");
let mut reader = PutObjReader::from_vec(b"old generation".to_vec());
store
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("put old-generation object");
let barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_objects(
&delete_bucket,
vec![crate::storage_api_contracts::object::ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await
});
barrier.wait_until_paused().await;
let recreate_store = Arc::clone(&store);
let recreate_bucket = bucket.clone();
let mut recreate = tokio::spawn(async move {
recreate_store
.delete_bucket(
&recreate_bucket,
&crate::storage_api_contracts::bucket::DeleteBucketOptions {
force: true,
..Default::default()
},
)
.await?;
recreate_store
.make_bucket(&recreate_bucket, &MakeBucketOptions::default())
.await?;
let mut reader = PutObjReader::from_vec(b"new generation".to_vec());
recreate_store
.put_object(&recreate_bucket, object, &mut reader, &ObjectOptions::default())
.await?;
Ok::<_, Error>(())
});
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut recreate).await.is_err(),
"bucket recreation must wait for the in-flight batch delete"
);
barrier.release();
let (_deleted, errors) = delete.await.expect("batch delete task should join");
assert!(
errors.iter().all(Option::is_none),
"old-generation batch delete should complete: {errors:?}"
);
tokio::time::timeout(Duration::from_secs(10), recreate)
.await
.expect("bucket recreation should finish after batch delete releases the sentinel")
.expect("bucket recreation task should join")
.expect("bucket recreation should succeed");
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("new-generation object must survive the old batch delete");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn prefix_delete_serializes_with_concurrent_object_lock_metadata_updates() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "prefix-delete-lock-metadata", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let cases = [
(
"compliance",
HashMap::from([
("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string()),
("x-amz-object-lock-retain-until-date".to_string(), "2099-01-01T00:00:00Z".to_string()),
]),
),
(
"legal-hold",
HashMap::from([("x-amz-object-lock-legal-hold".to_string(), "ON".to_string())]),
),
];
for (case, eval_metadata) in cases {
let bucket = format!("prefix-lock-{case}-{}", uuid::Uuid::new_v4());
let object = "protected-after-scan.bin";
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create Object Lock bucket");
let mut reader = PutObjReader::from_vec(b"body".to_vec());
let object_info = store
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write unprotected version");
let version_id = object_info.version_id.expect("versioned PUT should return an ID").to_string();
let barrier = crate::store::object::DeleteAfterObjectLockSnapshotBarrier::install(&bucket);
let delete_store = Arc::clone(&store);
let delete_bucket = bucket.clone();
let delete = tokio::spawn(async move {
delete_store
.delete_object(
&delete_bucket,
object,
ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
versioned: true,
..Default::default()
},
)
.await
});
barrier.wait_until_paused().await;
let update_store = Arc::clone(&store);
let update_bucket = bucket.clone();
let mut update = tokio::spawn(async move {
update_store
.put_object_metadata(
&update_bucket,
object,
&ObjectOptions {
version_id: Some(version_id),
versioned: true,
eval_metadata: Some(eval_metadata),
..Default::default()
},
)
.await
});
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut update).await.is_err(),
"{case} metadata update must wait behind the prefix-delete lifecycle write fence"
);
barrier.release();
delete
.await
.expect("prefix delete task should join")
.expect("prefix delete should remove the unprotected version");
update
.await
.expect("metadata update task should join")
.expect_err("metadata update must not succeed after the version was deleted");
assert!(
store
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.is_err(),
"{case} case must not report a successful protected write followed by deletion"
);
}
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn put_rejects_same_name_bucket_recreated_after_request_snapshot() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "put-bucket-incarnation", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("put-bucket-incarnation-{}", uuid::Uuid::new_v4());
let object = "object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create initial bucket");
let old_incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("read initial bucket incarnation");
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
&bucket,
object,
crate::set_disk::PutObjectCommitPause::BeforeNamespace,
);
let put_store = Arc::clone(&store);
let put_bucket = bucket.clone();
let put = tokio::spawn(async move {
let mut reader = PutObjReader::from_vec(b"old generation".to_vec());
put_store
.put_object(
&put_bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
});
barrier.wait_until_paused().await;
store
.delete_bucket(&bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
.await
.expect("delete initial empty bucket while PUT is staged");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("recreate same-name bucket");
let new_incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("read recreated bucket incarnation");
assert_ne!(old_incarnation, new_incarnation);
barrier.release();
let err = put
.await
.expect("PUT task should join")
.expect_err("old-generation PUT must not commit into the recreated bucket");
assert!(
matches!(err, StorageError::BucketNotFound(ref name) if name == &bucket),
"unexpected stale PUT error: {err:?}"
);
assert!(
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.is_err(),
"the recreated bucket must not contain the staged old-generation object"
);
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn exact_version_put_uses_only_its_own_bucket_object_lock_snapshot() {
let target_temp = tempfile::tempdir().expect("create target store dir");
let foreign_temp = tempfile::tempdir().expect("create foreign store dir");
let (target_ctx, target_store, _target_shutdown) =
without_storage_class_env(build_isolated_test_store(target_temp.path(), "put-object-lock-target", &[4])).await;
let (_foreign_ctx, foreign_store, _foreign_shutdown) =
without_storage_class_env(build_isolated_test_store(foreign_temp.path(), "put-object-lock-foreign", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(target_store.clone(), Vec::new()).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(foreign_store.clone(), Vec::new()).await;
let bucket = format!("put-object-lock-snapshot-{}", uuid::Uuid::new_v4());
let object = "object.bin";
target_store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create target Object Lock bucket");
foreign_store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create same-name plain bucket in foreign store");
let stale_snapshot = target_store
.object_lock_config_snapshot(&bucket)
.await
.expect("load target snapshot before changing its default retention");
let mut metadata = crate::bucket::metadata_sys::get_in(&target_ctx, &bucket)
.await
.expect("load target bucket metadata")
.as_ref()
.clone();
metadata
.update_config(
crate::bucket::metadata::OBJECT_LOCK_CONFIG,
b"<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>COMPLIANCE</Mode><Days>1</Days></DefaultRetention></Rule></ObjectLockConfiguration>".to_vec(),
)
.expect("set default COMPLIANCE metadata");
let update_started = Arc::new(tokio::sync::Barrier::new(2));
let update_ctx = Arc::clone(&target_ctx);
let update_started_task = Arc::clone(&update_started);
let mut update = tokio::spawn(async move {
update_started_task.wait().await;
let _transaction_guard =
crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_lock_in(&update_ctx, &metadata.name).await?;
crate::bucket::metadata_sys::set_bucket_metadata_in(&update_ctx, metadata).await
});
update_started.wait().await;
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut update).await.is_err(),
"an active Object Lock snapshot must hold the metadata revision read fence"
);
drop(stale_snapshot);
update
.await
.expect("Object Lock metadata update task should join")
.expect("persist target Object Lock metadata after snapshot release");
let original_body = b"protected body".to_vec();
let mut original_reader = PutObjReader::from_vec(original_body.clone());
let original = target_store
.put_object(
&bucket,
object,
&mut original_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write protected target version");
let version_id = original
.version_id
.expect("versioned PUT should return a version ID")
.to_string();
let mut replacement = PutObjReader::from_vec(b"replacement".to_vec());
let err = target_store
.put_object(
&bucket,
object,
&mut replacement,
&ObjectOptions {
version_id: Some(version_id.clone()),
versioned: true,
..Default::default()
},
)
.await
.expect_err("ECStore must load default COMPLIANCE before an exact-version overwrite");
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)));
let foreign_snapshot = foreign_store
.object_lock_config_snapshot(&bucket)
.await
.expect("load same-name foreign bucket snapshot");
let mut foreign_replacement = PutObjReader::from_vec(b"foreign replacement".to_vec());
target_store
.put_object(
&bucket,
object,
&mut foreign_replacement,
&ObjectOptions {
version_id: Some(version_id.clone()),
versioned: true,
object_lock_config_snapshot: Some(foreign_snapshot),
..Default::default()
},
)
.await
.expect_err("a snapshot from another store must fail closed");
let mut reader = target_store
.get_object_reader(
&bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(version_id),
versioned: true,
..Default::default()
},
)
.await
.expect("protected target version should remain readable");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("protected body should drain");
assert_eq!(body, original_body);
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn force_create_existing_bucket_preserves_incarnation_and_inflight_request() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "force-create-incarnation", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("force-create-incarnation-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create initial bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("read initial incarnation");
store
.make_bucket(
&bucket,
&MakeBucketOptions {
force_create: true,
..Default::default()
},
)
.await
.expect("idempotent force-create should succeed");
assert_eq!(
store.bucket_incarnation_id(&bucket).await.unwrap(),
incarnation,
"idempotent force-create must not rotate the bucket generation"
);
let mut reader = PutObjReader::from_vec(b"authorized before force-create".to_vec());
store
.put_object(
&bucket,
"inflight.bin",
&mut reader,
&ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
},
)
.await
.expect("a request authorized before idempotent force-create must still commit");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn lock_enabled_create_retries_metadata_intent_without_unlocking() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "lock-create-intent-retry", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("lock-create-intent-retry-{}", uuid::Uuid::new_v4());
let mut intent = crate::bucket::metadata::BucketMetadata::new(&bucket);
intent.lock_enabled = true;
crate::bucket::metadata_sys::set_new_bucket_metadata_in(&ctx, intent)
.await
.expect("persist pre-visibility lock intent");
assert!(
store
.peer_sys
.get_bucket_info(&bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.is_err(),
"the intent must not make the physical bucket visible"
);
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("retry lock-enabled creation");
assert!(matches!(
crate::bucket::metadata_sys::get_object_lock_config_state_in(&ctx, &bucket)
.await
.expect("read retried lock state"),
crate::bucket::metadata_sys::ObjectLockConfigState::Configured { .. }
));
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn physical_lock_bucket_with_incomplete_metadata_fails_closed() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "lock-create-incomplete-metadata", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("lock-create-incomplete-metadata-{}", uuid::Uuid::new_v4());
let mut metadata = crate::bucket::metadata::BucketMetadata::new(&bucket);
metadata.lock_enabled = true;
metadata
.save_with_store(store.clone())
.await
.expect("persist lock metadata before sidecar");
store
.peer_sys
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("expose physical bucket after metadata phase");
let err = crate::bucket::metadata_sys::get_object_lock_config_state_in(&ctx, &bucket)
.await
.expect_err("missing sidecar must not fabricate an unlocked bucket");
assert!(err.to_string().contains("sidecar is missing"), "unexpected error: {err}");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn successful_lock_enabled_create_has_complete_metadata_intent() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "lock-create-complete-metadata", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("lock-create-{}", uuid::Uuid::new_v4());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create lock-enabled bucket");
let (metadata, persisted) = crate::bucket::metadata_sys::get_config_from_disk_with_presence_in(&ctx, &bucket)
.await
.expect("read completed metadata intent");
assert!(persisted && metadata.bucket_incarnation_sidecar && metadata.lock_enabled);
assert!(matches!(
crate::bucket::metadata_sys::get_object_lock_config_state_in(&ctx, &bucket)
.await
.expect("read completed lock state"),
crate::bucket::metadata_sys::ObjectLockConfigState::Configured { .. }
));
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn lifecycle_expiry_rejects_task_from_recreated_bucket_incarnation() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "lifecycle-expiry-incarnation", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("lifecycle-incarnation-{}", uuid::Uuid::new_v4());
let object = "same-key.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create initial bucket");
let old_incarnation = store.bucket_incarnation_id(&bucket).await.expect("read old incarnation");
let mut old_reader = PutObjReader::from_vec(b"old-generation".to_vec());
store
.put_object(&bucket, object, &mut old_reader, &ObjectOptions::default())
.await
.expect("put old-generation object");
let old_object = store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("snapshot old-generation object");
store
.delete_object(&bucket, object, ObjectOptions::default())
.await
.expect("delete old-generation object");
store
.delete_bucket(&bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
.await
.expect("delete old bucket");
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("recreate bucket");
let mut new_reader = PutObjReader::from_vec(b"new-generation".to_vec());
store
.put_object(&bucket, object, &mut new_reader, &ObjectOptions::default())
.await
.expect("put replacement object");
let applied = crate::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
store.clone(),
&old_object,
&crate::bucket::lifecycle::lifecycle::Event {
action: crate::bucket::lifecycle::lifecycle::IlmAction::DeleteAction,
..Default::default()
},
&crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
old_incarnation,
)
.await;
assert!(!applied, "an expiry task from the old bucket incarnation must fail closed");
let mut reader = store
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("new-generation object must survive stale expiry");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("read replacement object");
assert_eq!(body, b"new-generation");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn pre_upgrade_multipart_upload_can_complete_and_abort_in_the_same_bucket_lifetime() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "multipart-upgrade-compat", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("multipart-upgrade-compat-{}", uuid::Uuid::new_v4());
let created = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed timestamp should be valid");
store
.make_bucket(
&bucket,
&MakeBucketOptions {
created_at: Some(created),
..Default::default()
},
)
.await
.expect("create bucket");
let complete_object = "complete.bin";
let complete_upload = store.pools[0]
.new_multipart_upload(
&bucket,
complete_object,
&ObjectOptions {
mod_time: Some(created + time::Duration::seconds(1)),
..Default::default()
},
)
.await
.expect("create a pre-upgrade multipart upload without an incarnation stamp");
let mut part_reader = PutObjReader::from_vec(b"pre-upgrade multipart body".to_vec());
let part = store.pools[0]
.put_object_part(
&bucket,
complete_object,
&complete_upload.upload_id,
1,
&mut part_reader,
&ObjectOptions::default(),
)
.await
.expect("stage a pre-upgrade multipart part");
store
.clone()
.complete_multipart_upload(
&bucket,
complete_object,
&complete_upload.upload_id,
vec![crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect("a pre-upgrade upload from the current bucket lifetime should complete");
store
.get_object_info(&bucket, complete_object, &ObjectOptions::default())
.await
.expect("completed pre-upgrade upload should be visible");
let abort_object = "abort.bin";
let abort_upload = store.pools[0]
.new_multipart_upload(
&bucket,
abort_object,
&ObjectOptions {
mod_time: Some(created + time::Duration::seconds(2)),
..Default::default()
},
)
.await
.expect("create another pre-upgrade multipart upload");
store
.abort_multipart_upload(&bucket, abort_object, &abort_upload.upload_id, &ObjectOptions::default())
.await
.expect("a pre-upgrade upload from the current bucket lifetime should abort");
let err = store
.get_multipart_info(&bucket, abort_object, &abort_upload.upload_id, &ObjectOptions::default())
.await
.expect_err("aborted pre-upgrade upload should be removed");
assert!(matches!(err, StorageError::InvalidUploadID(..)));
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn complete_multipart_rejects_legacy_upload_from_recreated_bucket_incarnation() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "multipart-bucket-incarnation", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("multipart-incarnation-{}", uuid::Uuid::new_v4());
let object = "object.bin";
let created = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed timestamp should be valid");
store
.make_bucket(
&bucket,
&MakeBucketOptions {
created_at: Some(created),
..Default::default()
},
)
.await
.expect("create initial bucket");
let old_incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("read initial bucket incarnation");
let upload = store.pools[0]
.new_multipart_upload(
&bucket,
object,
&ObjectOptions {
mod_time: Some(created + time::Duration::seconds(1)),
..Default::default()
},
)
.await
.expect("create old-generation legacy multipart upload");
store
.delete_bucket(&bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
.await
.expect("delete bucket with an incomplete multipart upload");
store
.make_bucket(
&bucket,
&MakeBucketOptions {
created_at: Some(created + time::Duration::seconds(2)),
..Default::default()
},
)
.await
.expect("recreate same-name bucket");
let new_incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("read recreated bucket incarnation");
assert_ne!(old_incarnation, new_incarnation);
let err = store
.clone()
.complete_multipart_upload(&bucket, object, &upload.upload_id, Vec::new(), &ObjectOptions::default())
.await
.expect_err("an upload initiated in the old bucket incarnation must not commit into the replacement");
assert!(
match &err {
StorageError::BucketNotFound(name) => name == &bucket,
StorageError::InvalidUploadID(name, key, _) => name == &bucket && key == object,
_ => false,
},
"unexpected stale multipart completion error: {err:?}"
);
assert!(
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.is_err(),
"the recreated bucket must not contain the old-generation multipart upload"
);
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn delete_after_bucket_validation_preserves_bucket_not_found() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "delete-missing-bucket", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("delete-missing-bucket-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket before validation");
store
.get_bucket_info(&bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.expect("request-level bucket validation should succeed");
store
.delete_bucket(&bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
.await
.expect("delete bucket after validation");
let err = store
.delete_object(&bucket, "missing.bin", ObjectOptions::default())
.await
.expect_err("post-validation bucket deletion must not become an object-not-found error");
assert!(matches!(err, StorageError::BucketNotFound(name) if name == bucket));
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn object_lock_metadata_read_error_blocks_every_destructive_delete_shape() {
let temp = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "delete-config-read-error", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("delete-config-read-error-{}", uuid::Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
for object in ["single.bin", "batch.bin", "prefix/child.bin", "lifecycle.bin"] {
let mut reader = PutObjReader::from_vec(b"must survive".to_vec());
store
.put_object(&bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("put object before metadata corruption");
}
crate::bucket::metadata_sys::inject_object_lock_disk_read_error_in(&ctx, &bucket)
.await
.expect("inject single-delete metadata read failure");
let single_err = store
.delete_object(&bucket, "single.bin", ObjectOptions::default())
.await
.expect_err("single delete must propagate the metadata read failure");
assert!(
single_err
.to_string()
.contains("injected Object Lock metadata disk read failure")
);
crate::bucket::metadata_sys::inject_object_lock_disk_read_error_in(&ctx, &bucket)
.await
.expect("inject batch-delete metadata read failure");
let (_deleted, batch_errs) = store
.delete_objects(
&bucket,
vec![crate::storage_api_contracts::object::ObjectToDelete {
object_name: "batch.bin".to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(
batch_errs[0]
.as_ref()
.is_some_and(|err| err.to_string().contains("injected Object Lock metadata disk read failure"))
);
crate::bucket::metadata_sys::inject_object_lock_disk_read_error_in(&ctx, &bucket)
.await
.expect("inject prefix-delete metadata read failure");
let prefix_err = store
.delete_object(
&bucket,
"prefix",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
)
.await
.expect_err("prefix delete must propagate the metadata read failure");
assert!(
prefix_err
.to_string()
.contains("injected Object Lock metadata disk read failure")
);
crate::bucket::metadata_sys::inject_object_lock_disk_read_error_in(&ctx, &bucket)
.await
.expect("inject lifecycle-style delete-all metadata read failure");
let lifecycle_err = store
.delete_object(
&bucket,
"lifecycle.bin",
ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
..Default::default()
},
)
.await
.expect_err("lifecycle-style delete-all must propagate the metadata read failure");
assert!(
lifecycle_err
.to_string()
.contains("injected Object Lock metadata disk read failure")
);
for object in ["single.bin", "batch.bin", "prefix/child.bin", "lifecycle.bin"] {
store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("metadata failure must preserve every object");
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn v4_tier_delete_journal_recovery_is_exact_and_store_scoped() {
let temp_a = tempfile::tempdir().expect("create temp store dir a");
let temp_b = tempfile::tempdir().expect("create temp store dir b");
let (ctx_a, store_a, _shutdown_a) =
without_storage_class_env(build_isolated_test_store(temp_a.path(), "tier-journal-recovery-a", &[4])).await;
let (ctx_b, store_b, _shutdown_b) =
without_storage_class_env(build_isolated_test_store(temp_b.path(), "tier-journal-recovery-b", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_a.clone(), Vec::new()).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_b.clone(), Vec::new()).await;
let tier_a = "JOURNAL-A";
let tier_b = "JOURNAL-B";
let backend_a = register_mock_tier(&ctx_a.tier_config_mgr(), tier_a).await;
let backend_b = register_mock_tier(&ctx_b.tier_config_mgr(), tier_b).await;
let identity_a = TierConfigMgr::acquire_operation_lease(&ctx_a.tier_config_mgr(), tier_a)
.await
.expect("store A tier lease should resolve")
.backend_identity();
let identity_b = TierConfigMgr::acquire_operation_lease(&ctx_b.tier_config_mgr(), tier_b)
.await
.expect("store B tier lease should resolve")
.backend_identity();
let entry_a = Jentry {
persisted_version: 0,
obj_name: "remote-a".to_string(),
version_id: "version-a".to_string(),
tier_name: tier_a.to_string(),
backend_identity: Some(identity_a),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
let entry_b = Jentry {
persisted_version: 0,
obj_name: "remote-b".to_string(),
version_id: "version-b".to_string(),
tier_name: tier_b.to_string(),
backend_identity: Some(identity_b),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
persist_tier_delete_journal_entry(store_a.clone(), &entry_a)
.await
.expect("store A v4 compatibility fixture should persist");
persist_tier_delete_journal_entry(store_b.clone(), &entry_b)
.await
.expect("store B v4 compatibility fixture should persist");
let entry_a_v3 = Jentry {
obj_name: "remote-a-v3".to_string(),
version_id: "version-a-v3".to_string(),
..entry_a.clone()
};
let mut raw_v3: serde_json::Value = serde_json::from_slice(
&encode_tier_delete_journal_entry(&entry_a_v3).expect("store A v3 fixture should encode from the v4 shape"),
)
.expect("store A v3 fixture should be JSON");
raw_v3["version"] = serde_json::json!(3);
raw_v3
.as_object_mut()
.expect("store A v3 fixture should be an object")
.remove("version_state");
let path_a_v3 = tier_delete_journal_object_name(&entry_a_v3);
com::save_config(
store_a.clone(),
&path_a_v3,
serde_json::to_vec(&raw_v3).expect("store A raw v3 fixture should encode"),
)
.await
.expect("store A raw v3 compatibility fixture should persist");
let path_a = tier_delete_journal_object_name(&entry_a);
let path_b = tier_delete_journal_object_name(&entry_b);
let recovered_a = recover_tier_delete_journal_entries(store_a.clone(), 100, None)
.await
.expect("store A recovery scan should finish");
let recovered_b = recover_tier_delete_journal_entries(store_b.clone(), 100, None)
.await
.expect("store B recovery scan should finish");
assert_eq!((recovered_a.scanned, recovered_a.deleted, recovered_a.failed), (2, 2, 0));
assert_eq!((recovered_b.scanned, recovered_b.deleted, recovered_b.failed), (1, 1, 0));
let mut removed_a = backend_a.remove_versions().await;
removed_a.sort();
assert_eq!(
removed_a,
vec![
("remote-a".to_string(), "version-a".to_string()),
("remote-a-v3".to_string(), "version-a-v3".to_string()),
]
);
assert_eq!(backend_b.remove_versions().await, vec![("remote-b".to_string(), "version-b".to_string())]);
assert!(matches!(com::read_config(store_a.clone(), &path_a).await, Err(Error::ConfigNotFound)));
assert!(matches!(com::read_config(store_a.clone(), &path_a_v3).await, Err(Error::ConfigNotFound)));
assert!(matches!(com::read_config(store_b.clone(), &path_b).await, Err(Error::ConfigNotFound)));
assert_eq!(tier_delete_journal_count(store_a).await, 0);
assert_eq!(tier_delete_journal_count(store_b).await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn v5_tier_delete_recovery_retains_live_sources_and_aborts_only_prepared() {
let temp_dir = tempfile::tempdir().expect("create v5 live-source store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "v5-live-source-recovery", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "V5-LIVE-SOURCE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("v5 live-source tier lease should resolve")
.backend_identity();
let bucket = "v5-live-source-recovery-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("v5 live-source bucket should be created");
let mut paths = Vec::new();
for (index, state) in [
TierDeleteJournalState::Prepared,
TierDeleteJournalState::Dispatched,
TierDeleteJournalState::Committed,
]
.into_iter()
.enumerate()
{
let object = format!("live-{index}.bin");
let remote_version = uuid::Uuid::new_v4().to_string();
backend.set_put_remote_version(Some(remote_version)).await;
let mut reader = PutObjReader::from_vec(format!("v5 live source {index}").into_bytes());
let source = store
.put_object(bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("v5 live source should be written");
store
.transition_object(
bucket,
&object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("v5 live source should have an ETag"),
..Default::default()
},
version_id: source.version_id.map(|version| version.to_string()),
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("v5 live source should transition");
let transitioned = store
.get_object_info(
bucket,
&object,
&ObjectOptions {
no_lock: true,
metadata_cache_safe: false,
..Default::default()
},
)
.await
.expect("v5 transitioned source should be readable");
let mut entry = transitioned_delete_journal_entry_for_source(None, false, false, bucket, &object, &transitioned)
.expect("v5 transitioned source should produce a stable journal identity");
entry.persisted_version = 5;
entry.backend_identity = Some(backend_identity);
entry.state = state;
entry.dispatch = None;
let path = tier_delete_journal_object_name(&entry);
com::save_config(
store.clone(),
&path,
encode_tier_delete_journal_entry(&entry).expect("raw v5 live-source journal should encode"),
)
.await
.expect("raw v5 live-source journal should persist");
paths.push((state, path));
}
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("v5 live-source recovery should finish");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (3, 1, 2));
assert_eq!(backend.remove_count().await, 0, "a live source must block every remote DELETE");
for (state, path) in paths {
let observed = com::read_config(store.clone(), &path).await;
if state == TierDeleteJournalState::Prepared {
assert!(matches!(observed, Err(Error::ConfigNotFound)), "Prepared v5 evidence should abort");
} else {
assert!(observed.is_ok(), "{state:?} v5 evidence must remain while its exact source exists");
}
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn v5_tier_delete_recovery_replays_all_crash_states_without_sources() {
let temp_dir = tempfile::tempdir().expect("create v5 crash-replay store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "v5-crash-replay", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "V5-CRASH-REPLAY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("v5 crash-replay tier lease should resolve");
let backend_identity = lease.backend_identity();
let bucket = "v5-crash-replay-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("v5 crash-replay bucket should be created");
for (index, state, seed_remote) in [
(0, TierDeleteJournalState::Prepared, true),
(1, TierDeleteJournalState::Dispatched, true),
(2, TierDeleteJournalState::Committed, true),
// Models a restart after exact remote DELETE succeeded but before
// the local Committed journal was removed.
(3, TierDeleteJournalState::Committed, false),
] {
let remote_version = uuid::Uuid::new_v4().to_string();
let mut entry = synthetic_v6_dispatch_entry(
bucket,
&format!("missing-{index}.bin"),
tier_name,
backend_identity,
&remote_version,
);
entry.persisted_version = 5;
entry.state = state;
entry.dispatch = None;
if seed_remote {
backend.set_put_remote_version(Some(remote_version)).await;
let body = bytes::Bytes::from(format!("v5 remote candidate {index}"));
lease
.put(
&entry.obj_name,
ReaderImpl::Body(body.clone()),
i64::try_from(body.len()).expect("v5 candidate length should fit i64"),
)
.await
.expect("v5 remote candidate should be seeded");
}
let path = tier_delete_journal_object_name(&entry);
com::save_config(
store.clone(),
&path,
encode_tier_delete_journal_entry(&entry).expect("raw v5 crash journal should encode"),
)
.await
.expect("raw v5 crash journal should persist");
}
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("v5 crash-state recovery should finish");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (4, 4, 0));
assert_eq!(tier_delete_journal_count(store).await, 0);
assert_eq!(backend.object_count().await, 0);
assert_eq!(backend.exact_remove_count(), 4, "every v5 state must use exact-version cleanup");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatched_tier_delete_recovery_finds_directory_source_on_encoded_set() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let shutdown = CancellationToken::new();
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"prepared-directory-recovery",
&[(2, 4)],
shutdown,
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let pool = &store.pools[0];
let object = (0..10_000)
.map(|index| format!("directory-{index}/"))
.find(|candidate| {
let encoded = rustfs_utils::path::encode_dir_object(candidate);
!Arc::ptr_eq(&pool.get_disks_by_key(candidate), &pool.get_disks_by_key(&encoded))
})
.expect("test topology should have a directory key whose encoded form hashes to another set");
let encoded = rustfs_utils::path::encode_dir_object(&object);
assert!(!Arc::ptr_eq(&pool.get_disks_by_key(&object), &pool.get_disks_by_key(&encoded)));
let tier_name = "PREPAREDDIRECTORY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let bucket = "prepared-directory-recovery-bucket";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"directory source".to_vec());
let original = store
.put_object(bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("directory source should be written");
store
.transition_object(
bucket,
&object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source should have an ETag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("directory transition should commit");
let committed = store
.get_object_info(
bucket,
&object,
&ObjectOptions {
no_lock: true,
metadata_cache_safe: false,
..Default::default()
},
)
.await
.expect("transitioned directory source should be readable");
let mut entry = transitioned_delete_journal_entry_for_source(None, false, false, bucket, &object, &committed)
.expect("transitioned source should produce a prepared journal entry");
entry.backend_identity = Some(backend_identity);
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should resolve");
install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
&object,
vec![(entry, Some(TierDeleteJournalState::Dispatched))],
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized dispatched journal should persist");
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("prepared recovery should complete");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 0, 1));
assert_eq!(tier_delete_journal_count(store).await, 1);
assert_eq!(backend.remove_count().await, 0);
assert_eq!(backend.object_count().await, 1, "live directory source must retain its remote object");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatched_tier_delete_recovery_checks_later_pool_then_commits_after_source_removal() {
let temp_dir = tempfile::tempdir().expect("create cross-pool recovery store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "prepared-cross-pool-recovery", &[4, 4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "PREPAREDCROSSPOOL";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let bucket = "prepared-cross-pool-recovery-bucket";
let object = "object";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"later pool source".to_vec());
let original = store.pools[1]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source should be written only to the later pool");
store.pools[1]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source should have an ETag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("later-pool transition should commit");
let committed = store.pools[1]
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("transitioned source should be readable");
let mut entry = transitioned_delete_journal_entry_for_source(None, false, false, bucket, object, &committed)
.expect("transitioned source should produce a prepared journal");
entry.backend_identity = Some(backend_identity);
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should resolve");
install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
object,
vec![(entry, Some(TierDeleteJournalState::Dispatched))],
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized dispatched journal should persist");
let retained = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("recovery should scan the later pool");
assert_eq!((retained.scanned, retained.deleted, retained.failed), (1, 0, 1));
assert_eq!(backend.remove_count().await, 0);
assert_eq!(backend.object_count().await, 1);
assert_eq!(
tier_delete_journal_count(store.clone()).await,
1,
"an authorized journal must remain while its live source is durable"
);
store.pools[1]
.delete_object(
bucket,
object,
ObjectOptions {
version_id: committed.version_id.map(|version| version.to_string()),
expiration: crate::storage_api_contracts::lifecycle::ExpirationOptions { expire: true },
skip_free_version: true,
..Default::default()
},
)
.await
.expect("source version should be removed before recovery retry");
let committed_stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("journal recovery should commit an absent stable source");
assert_eq!((committed_stats.scanned, committed_stats.deleted, committed_stats.failed), (1, 0, 1));
assert_eq!(backend.remove_count().await, 0);
let manifest_stats = recover_tier_delete_dispatch_manifests(store.clone(), 100, None)
.await
.expect("manifest recovery should authorize committed remote cleanup");
assert_eq!((manifest_stats.advanced, manifest_stats.failed), (1, 0));
let deleted = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("completed journal should delete the exact remote version");
assert_eq!((deleted.scanned, deleted.deleted, deleted.failed), (1, 1, 0));
assert_eq!(tier_delete_journal_count(store).await, 0);
assert_eq!(backend.remove_count().await, 1);
assert_eq!(backend.object_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatched_tier_delete_recovery_retains_journal_on_source_metadata_error() {
let temp_dir = tempfile::tempdir().expect("create metadata-error recovery store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "prepared-metadata-error-recovery", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "PREPAREDMETADATAERROR";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let bucket = "prepared-metadata-error-recovery-bucket";
let object = "object";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"source with unreadable metadata".to_vec());
let original = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source should have an ETag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("source transition should commit");
let committed = store
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("transitioned source should be readable");
let mut entry = transitioned_delete_journal_entry_for_source(None, false, false, bucket, object, &committed)
.expect("transitioned source should produce a prepared journal");
entry.backend_identity = Some(backend_identity);
let incarnation = store
.bucket_incarnation_id(bucket)
.await
.expect("bucket incarnation should resolve");
install_test_tier_delete_dispatch_fixture(
store.clone(),
bucket,
incarnation,
object,
vec![(entry, Some(TierDeleteJournalState::Dispatched))],
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized dispatched journal should persist");
for disk_index in 0..4 {
let metadata_path = temp_dir
.path()
.join(format!("pool0/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
tokio::fs::write(metadata_path, b"not-xl-meta")
.await
.expect("source metadata should be corrupted");
}
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("recovery scan should complete despite the entry failure");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 0, 1));
assert_eq!(tier_delete_journal_count(store).await, 1, "journal must remain dispatched for retry");
assert_eq!(backend.remove_count().await, 0, "unreadable source metadata must block remote deletion");
assert_eq!(backend.object_count().await, 1, "remote source must remain intact");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_delete_recovery_retains_content_that_does_not_match_its_object_name() {
let temp_dir = tempfile::tempdir().expect("create mismatched journal store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "mismatched-tier-journal", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "MISMATCHEDJOURNAL";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let lease = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve");
let remote_version = uuid::Uuid::new_v4().to_string();
backend.set_put_remote_version(Some(remote_version.clone())).await;
lease
.put("remote/original", ReaderImpl::Body(bytes::Bytes::from_static(b"remote body")), 11)
.await
.expect("remote body should be seeded");
let entry = Jentry {
persisted_version: 0,
obj_name: "remote/original".to_string(),
version_id: remote_version,
tier_name: tier_name.to_string(),
backend_identity: Some(lease.backend_identity()),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Prepared,
source: Some(TierDeleteSourceIdentity {
bucket: "absent-source-bucket".to_string(),
object: "absent-source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4().to_string()),
versioned: true,
version_suspended: false,
data_dir: Some(uuid::Uuid::new_v4().to_string()),
etag: Some("etag".to_string()),
mod_time: Some(OffsetDateTime::UNIX_EPOCH.to_string()),
}),
dispatch: None,
};
let (_manifest_name, bound) = install_test_tier_delete_dispatch_fixture(
store.clone(),
"absent-source-bucket",
uuid::Uuid::new_v4(),
"absent-source-object",
vec![(entry, Some(TierDeleteJournalState::Dispatched))],
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized dispatched journal should persist");
let journal_name = tier_delete_journal_object_name(&bound[0]);
let data = com::read_config(store.clone(), &journal_name)
.await
.expect("prepared journal should be readable");
let mut value: serde_json::Value = serde_json::from_slice(&data).expect("journal should contain JSON");
value["obj_name"] = serde_json::json!("remote/replaced");
com::save_config(
store.clone(),
&journal_name,
serde_json::to_vec(&value).expect("mismatched journal should encode"),
)
.await
.expect("mismatched journal content should be written under the original name");
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("recovery scan should complete");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 0, 1));
assert_eq!(tier_delete_journal_count(store).await, 1, "mismatched journal must be retained");
assert_eq!(backend.remove_count().await, 0);
assert_eq!(backend.object_count().await, 1);
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn transitioned_history_expiry_uses_journal_or_legacy_free_version() {
std::thread::Builder::new()
.name("transitioned-delete-all-test".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("test runtime should build");
runtime.block_on(async {
let temp_dir = tempfile::tempdir().expect("create transitioned delete-all store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transitioned-delete-all", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "DELETEALLTRANSITIONED";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "transitioned-delete-all-bucket";
let object = "object";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("bucket versioning should be enabled");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_LIFECYCLE_CONFIG,
br#"<LifecycleConfiguration>
<Rule>
<ID>delete-all-versions</ID>
<Status>Enabled</Status>
<Filter><Prefix></Prefix></Filter>
<Expiration><Days>1</Days><ExpiredObjectAllVersions>true</ExpiredObjectAllVersions></Expiration>
</Rule>
</LifecycleConfiguration>"#
.to_vec(),
)
.await
.expect("delete-all lifecycle should be configured");
let old_time = OffsetDateTime::now_utc() - time::Duration::days(3);
let mut history_reader = PutObjReader::from_vec(b"transitioned history".to_vec());
let history = store
.put_object(
bucket,
object,
&mut history_reader,
&ObjectOptions {
versioned: true,
mod_time: Some(old_time - time::Duration::hours(1)),
..Default::default()
},
)
.await
.expect("historical version should be written");
let mut current_reader = PutObjReader::from_vec(b"current version".to_vec());
let current = store
.put_object(
bucket,
object,
&mut current_reader,
&ObjectOptions {
versioned: true,
mod_time: Some(old_time),
..Default::default()
},
)
.await
.expect("current version should be written");
store
.transition_object(
bucket,
object,
&ObjectOptions {
versioned: true,
version_id: history.version_id.map(|version_id| version_id.to_string()),
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: history.etag.clone().expect("history should have an ETag"),
..Default::default()
},
mod_time: history.mod_time,
..Default::default()
},
)
.await
.expect("historical version should transition");
assert_eq!(backend.object_count().await, 1);
assert_eq!(backend.put_versions().await.len(), 1);
backend.set_remove_failure(true);
let incarnation = store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("bucket incarnation should be available");
for disk_index in 0..4 {
let metadata_path = temp_dir
.path()
.join(format!("pool0/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let encoded = tokio::fs::read(&metadata_path)
.await
.expect("transition metadata should be readable");
let mut metadata = FileMeta::load(&encoded).expect("transition metadata should decode");
let mut transitioned = metadata
.get_all_file_info_versions(bucket, object, true)
.expect("transitioned versions should decode")
.versions
.into_iter()
.find(|version| version.version_id == history.version_id)
.expect("transitioned history should exist");
transitioned.transition_version_state = rustfs_filemeta::TransitionVersionState::Unknown;
metadata
.add_version(transitioned)
.expect("unknown state should replace the transitioned version");
tokio::fs::write(
&metadata_path,
metadata.marshal_msg().expect("unknown transition metadata should encode"),
)
.await
.expect("unknown transition metadata should be written");
}
let lifecycle_event = crate::bucket::lifecycle::lifecycle::Event {
action: rustfs_scanner_metrics::metrics::IlmAction::DeleteAllVersionsAction,
rule_id: "delete-all-versions".to_string(),
..Default::default()
};
let legacy_applied =
crate::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
store.clone(),
&current,
&lifecycle_event,
&crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
incarnation,
)
.await;
assert!(legacy_applied, "legacy Unknown transition metadata should use the free-version fallback");
assert_eq!(tier_delete_journal_count(store.clone()).await, 0);
assert_eq!(backend.remove_count().await, 0);
let retained = store.pools[0].disk_set[0]
.load_file_info_versions_exact(bucket, object)
.await
.expect("fallback delete-all metadata should remain readable")
.expect("fallback delete-all should retain a hidden cleanup owner");
assert_eq!(
retained
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0
);
assert_eq!(
retained.versions.iter().filter(|version| version.tier_free_version()).count(),
1,
"legacy cleanup must retain exactly one hidden free-version"
);
assert_eq!(backend.object_count().await, 1, "legacy remote cleanup must remain pending");
// Use a distinct tier/backend so the legacy worker can be
// held in a deterministic remote-failure state while the
// exact journal path independently proves convergence.
let exact_tier_name = "DELETEALLTRANSITIONEDEXACT";
let exact_backend = register_mock_tier(&ctx.tier_config_mgr(), exact_tier_name).await;
let exact_object = "exact-object";
let mut exact_history_reader = PutObjReader::from_vec(b"exact transitioned history".to_vec());
let exact_history = store
.put_object(
bucket,
exact_object,
&mut exact_history_reader,
&ObjectOptions {
versioned: true,
mod_time: Some(old_time - time::Duration::hours(1)),
..Default::default()
},
)
.await
.expect("second historical version should be written");
let mut exact_current_reader = PutObjReader::from_vec(b"exact current version".to_vec());
let exact_current = store
.put_object(
bucket,
exact_object,
&mut exact_current_reader,
&ObjectOptions {
versioned: true,
mod_time: Some(old_time),
..Default::default()
},
)
.await
.expect("second current version should be written");
store
.transition_object(
bucket,
exact_object,
&ObjectOptions {
versioned: true,
version_id: exact_history.version_id.map(|version_id| version_id.to_string()),
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: exact_tier_name.to_string(),
etag: exact_history.etag.clone().expect("second history should have an ETag"),
..Default::default()
},
mod_time: exact_history.mod_time,
..Default::default()
},
)
.await
.expect("second historical version should transition exactly");
let exact_remote_version = exact_backend
.put_versions()
.await
.last()
.cloned()
.expect("second transition should publish a remote version");
exact_backend.set_remove_failure(true);
let applied = crate::bucket::lifecycle::bucket_lifecycle_ops::apply_expiry_on_non_transitioned_objects(
store.clone(),
&exact_current,
&lifecycle_event,
&crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc::Scanner,
incarnation,
)
.await;
assert!(applied, "delete-all should remove current and transitioned history");
assert!(
store.pools[0].disk_set[0]
.load_file_info_versions_exact(bucket, exact_object)
.await
.expect("exact journal-owned metadata lookup should succeed")
.is_none(),
"exact journal ownership must remove the source key without leaving a free-version"
);
let versions = store.pools[0].disk_set[0]
.load_file_info_versions_exact(bucket, object)
.await
.expect("remaining fallback metadata should be readable")
.expect("the prior legacy cleanup owner should remain");
assert_eq!(
versions
.versions
.iter()
.filter(|version| !version.tier_free_version())
.count(),
0,
"exact delete-all must remove every visible version"
);
assert_eq!(
versions.versions.iter().filter(|version| version.tier_free_version()).count(),
1,
"the exact v6 journal must not add another free-version"
);
assert_eq!(tier_delete_journal_count(store.clone()).await, 1);
assert_eq!(backend.object_count().await, 1, "legacy remote cleanup must remain pending");
assert_eq!(exact_backend.object_count().await, 1, "exact remote cleanup must remain journal-owned");
exact_backend.set_remove_failure(false);
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("committed journal should recover");
assert_eq!(stats.failed, 0);
assert!(stats.scanned <= 1 && stats.deleted <= 1);
assert_eq!(tier_delete_journal_count(store).await, 0);
assert_eq!(
backend.object_count().await,
1,
"journal recovery must leave the legacy free-version remote"
);
assert_eq!(exact_backend.object_count().await, 0);
assert!(exact_backend.exact_remove_count() >= 1);
assert_eq!(exact_backend.remove_versions().await, vec![exact_remote_version]);
});
})
.expect("test thread should spawn")
.join()
.expect("test thread should complete");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn dispatched_tier_delete_recovery_requires_namespace_locking() {
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some("false"))], async {
let temp_dir = tempfile::tempdir().expect("create lock-disabled store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "prepared-recovery-lock-disabled", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
assert!(ctx.lock_manager().is_disabled());
let tier_name = "PREPAREDLOCKDISABLED";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let entry = Jentry {
persisted_version: 0,
obj_name: "remote/lock-disabled".to_string(),
version_id: uuid::Uuid::new_v4().to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Prepared,
source: Some(TierDeleteSourceIdentity {
bucket: "absent-source-bucket".to_string(),
object: "absent-source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4().to_string()),
versioned: true,
version_suspended: false,
data_dir: Some(uuid::Uuid::new_v4().to_string()),
etag: Some("etag".to_string()),
mod_time: Some(OffsetDateTime::UNIX_EPOCH.to_string()),
}),
dispatch: None,
};
install_test_tier_delete_dispatch_fixture(
store.clone(),
"absent-source-bucket",
uuid::Uuid::new_v4(),
"absent-source-object",
vec![(entry, Some(TierDeleteJournalState::Dispatched))],
TierDeleteDispatchManifestState::DispatchAuthorized,
)
.await
.expect("authorized dispatched journal should persist");
let stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("recovery scan should complete");
assert_eq!((stats.scanned, stats.deleted, stats.failed), (1, 0, 1));
assert_eq!(tier_delete_journal_count(store).await, 1, "journal must remain dispatched for retry");
assert_eq!(backend.remove_count().await, 0, "lock-disabled recovery must not delete remotely");
})
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn lifecycle_delete_all_requires_namespace_locking_before_mutation() {
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some("false"))], async {
let temp_dir = tempfile::tempdir().expect("create lock-disabled delete-all store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "delete-all-lock-disabled", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
assert!(ctx.lock_manager().is_disabled());
let bucket = "delete-all-lock-disabled-bucket";
let object = "object";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"must survive".to_vec());
let no_lock_opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let original = store.pools[0]
.put_object(bucket, object, &mut reader, &no_lock_opts)
.await
.expect("source should be written");
let mut delete_opts = ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
lifecycle_delete_all: Some(crate::object_api::LifecycleDeleteAllRequest {
version_id: original.version_id,
delete_marker: false,
action: rustfs_scanner_metrics::metrics::IlmAction::DeleteAllVersionsAction,
rule_id: "rule".to_string(),
phase: crate::object_api::LifecycleDeleteAllPhase::Preflight,
}),
delete_replication_config_snapshot: Some(Arc::new(
crate::bucket::replication::DeleteReplicationConfigSnapshot::default(),
)),
..Default::default()
};
delete_opts.ensure_lifecycle_delete_all_journal();
let err = store
.delete_object_with_tier_delete_journal(bucket, object, delete_opts)
.await
.expect_err("delete-all must reject disabled namespace locking");
assert!(err.to_string().contains("requires namespace locking"));
let retained = store.pools[0]
.get_object_info(bucket, object, &no_lock_opts)
.await
.expect("rejected delete-all must retain the source");
assert_eq!(retained.etag, original.etag);
assert_eq!(tier_delete_journal_count(store).await, 0, "rejected delete-all must not prepare journals");
})
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_intent_record_round_trips_through_config_store() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-intent-record", &[4])).await;
let mutation_id = uuid::Uuid::new_v4();
let intent = TierMutationIntent {
mutation_id,
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest: [3; 32],
affected_targets: vec![TierMutationIntentTarget {
tier_name: "COLD-A".to_string(),
old_backend_identity: Some([1; 32]),
new_backend_identity: Some([2; 32]),
}],
expires_at_unix_nanos: 1_780_000_000_000_000_000,
};
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("tier mutation intent record should persist");
let loaded = load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("tier mutation intent record should load");
assert_eq!(loaded, intent);
delete_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("tier mutation intent record delete should be idempotent");
delete_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("tier mutation intent record delete should tolerate missing records");
let err = load_tier_mutation_intent_record(store, mutation_id)
.await
.expect_err("deleted tier mutation intent record should not load");
assert!(matches!(err, Error::ConfigNotFound));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_probe_intent_store_enforces_create_cas_and_terminal_delete_preconditions() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-probe-intent-cas", &[4])).await;
let probe_id = uuid::Uuid::new_v4();
let creator_epoch = uuid::Uuid::new_v4();
let initial = TierProbeIntent {
probe_id,
revision: 1,
state: TierProbeIntentState::UploadOutcomeUnknown,
operation: TierProbeOperationIdentity::Verify {
config_etag: "config-etag".to_string(),
backend_identity: [1; 32],
},
tier_name: "COLD-A".to_string(),
destination_id: [1; 32],
probe_object: format!("rustfs-tier-probe-{probe_id}"),
creator_id: "node-a".to_string(),
creator_epoch,
created_at_unix_nanos: 1_780_000_000_000_000_000,
owner: TierProbeOwnerFence {
owner_id: "node-a".to_string(),
owner_epoch: creator_epoch,
not_after_unix_nanos: 1_780_000_900_000_000_000,
},
remote_version: TierProbeRemoteVersion::default(),
};
save_tier_probe_intent_record_if_absent(store.clone(), &initial)
.await
.expect("initial probe intent should persist with create-only semantics");
let duplicate = save_tier_probe_intent_record_if_absent(store.clone(), &initial)
.await
.expect_err("duplicate create must fail closed");
assert!(matches!(duplicate, Error::PreconditionFailed));
let observed_initial = load_tier_probe_intent_record(store.clone(), probe_id)
.await
.expect("initial probe intent should load with an ETag");
assert_eq!(observed_initial.intent(), &initial);
let nonterminal_delete = delete_tier_probe_intent_record_if_current(store.clone(), &observed_initial)
.await
.expect_err("nonterminal evidence must not be deleted");
assert!(nonterminal_delete.to_string().contains("must be terminal"));
let mut fabricated_current_intent = initial.clone();
fabricated_current_intent.tier_name = "COLD-B".to_string();
let mut fabricated_successor = fabricated_current_intent.clone();
fabricated_successor
.advance(
TierProbeIntentState::Uploaded,
TierProbeRemoteVersion::versioned(uuid::Uuid::new_v4().to_string()),
)
.expect("fabricated successor should be internally valid");
let fabricated_current = observed_initial.with_intent_for_test(fabricated_current_intent.clone());
let crossed_cas = save_tier_probe_intent_record_if_current(store.clone(), &fabricated_current, &fabricated_successor)
.await
.expect_err("a live ETag must not authorize a different caller record");
assert!(matches!(crossed_cas, Error::PreconditionFailed));
assert_eq!(
load_tier_probe_intent_record(store.clone(), probe_id)
.await
.expect("crossed CAS must retain the authoritative record")
.intent(),
&initial
);
let mut fabricated_terminal_intent = fabricated_current_intent;
fabricated_terminal_intent
.advance(TierProbeIntentState::AbortedNoRemote, TierProbeRemoteVersion::default())
.expect("fabricated terminal should be internally valid");
let fabricated_terminal = observed_initial.with_intent_for_test(fabricated_terminal_intent);
let crossed_delete = delete_tier_probe_intent_record_if_current(store.clone(), &fabricated_terminal)
.await
.expect_err("a live ETag must not delete for a different caller record");
assert!(matches!(crossed_delete, Error::PreconditionFailed));
assert_eq!(
load_tier_probe_intent_record(store.clone(), probe_id)
.await
.expect("crossed delete must retain the authoritative record")
.intent(),
&initial
);
let remote_version = TierProbeRemoteVersion::versioned(uuid::Uuid::new_v4().to_string());
let mut uploaded = observed_initial.intent().clone();
uploaded
.advance(TierProbeIntentState::Uploaded, remote_version.clone())
.expect("known PUT result should advance");
save_tier_probe_intent_record_if_current(store.clone(), &observed_initial, &uploaded)
.await
.expect("the matching initial ETag should admit one successor");
let stale_cas = save_tier_probe_intent_record_if_current(store.clone(), &observed_initial, &uploaded)
.await
.expect_err("a consumed ETag must not overwrite the current generation");
assert!(matches!(stale_cas, Error::PreconditionFailed));
let observed_uploaded = load_tier_probe_intent_record(store.clone(), probe_id)
.await
.expect("uploaded generation should load");
assert_eq!(observed_uploaded.intent(), &uploaded);
let mut cleanup = observed_uploaded.intent().clone();
cleanup
.advance(TierProbeIntentState::CleanupPending, remote_version.clone())
.expect("known candidate should become cleanup-pending");
save_tier_probe_intent_record_if_current(store.clone(), &observed_uploaded, &cleanup)
.await
.expect("cleanup generation should persist by exact ETag");
let observed_cleanup = load_tier_probe_intent_record(store.clone(), probe_id)
.await
.expect("cleanup generation should load");
let mut completed = observed_cleanup.intent().clone();
completed
.advance(TierProbeIntentState::Completed, remote_version)
.expect("exact cleanup should become terminal");
save_tier_probe_intent_record_if_current(store.clone(), &observed_cleanup, &completed)
.await
.expect("terminal generation should persist by exact ETag");
let stale_terminal = observed_cleanup.with_intent_for_test(completed.clone());
let stale_delete = delete_tier_probe_intent_record_if_current(store.clone(), &stale_terminal)
.await
.expect_err("a stale ETag must not delete terminal evidence");
assert!(matches!(stale_delete, Error::PreconditionFailed));
let observed_completed = load_tier_probe_intent_record(store.clone(), probe_id)
.await
.expect("terminal generation should remain after stale delete");
assert_eq!(observed_completed.intent(), &completed);
delete_tier_probe_intent_record_if_current(store.clone(), &observed_completed)
.await
.expect("the exact terminal ETag should delete the record");
assert!(matches!(load_tier_probe_intent_record(store, probe_id).await, Err(Error::ConfigNotFound)));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_intent_record_scan_retains_good_records_and_counts_bad_records() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-intent-scan", &[4])).await;
let build_intent = |mutation_id: uuid::Uuid, tier_name: &str| TierMutationIntent {
mutation_id,
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest: [3; 32],
affected_targets: vec![TierMutationIntentTarget {
tier_name: tier_name.to_string(),
old_backend_identity: Some([1; 32]),
new_backend_identity: Some([2; 32]),
}],
expires_at_unix_nanos: 1_780_000_000_000_000_000,
};
let first_id = uuid::Uuid::parse_str("12345678-1234-5678-9abc-def012345678").expect("first uuid should parse");
let second_id = uuid::Uuid::parse_str("22345678-1234-5678-9abc-def012345678").expect("second uuid should parse");
let first = build_intent(first_id, "COLD-A");
let second = build_intent(second_id, "COLD-B");
save_tier_mutation_intent_record(store.clone(), &first)
.await
.expect("first tier mutation intent record should persist");
save_tier_mutation_intent_record(store.clone(), &second)
.await
.expect("second tier mutation intent record should persist");
com::save_config(
store.clone(),
&format!("{TIER_MUTATION_INTENT_RECORD_PREFIX}/00/00/33345678123456789abcdef012345678.json"),
b"{}".to_vec(),
)
.await
.expect("malformed-shard intent record should persist");
com::save_config(
store.clone(),
&format!("{TIER_MUTATION_INTENT_RECORD_PREFIX}/44/44/44445678123456789abcdef012345678.json"),
b"{".to_vec(),
)
.await
.expect("corrupt-json intent record should persist");
let scan = list_tier_mutation_intent_records(store, 100, None)
.await
.expect("tier mutation intent records should scan");
let mut loaded_ids: Vec<_> = scan.intents.into_iter().map(|intent| intent.mutation_id).collect();
loaded_ids.sort();
assert_eq!(scan.scanned, 4);
assert_eq!(scan.failed, 2);
assert_eq!(loaded_ids, vec![first_id, second_id]);
assert!(!scan.truncated);
assert_eq!(scan.next_marker, None);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_intent_record_advance_is_idempotent_in_config_store() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-intent-advance", &[4])).await;
let mutation_id = uuid::Uuid::new_v4();
let intent = TierMutationIntent {
mutation_id,
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest: [3; 32],
affected_targets: vec![TierMutationIntentTarget {
tier_name: "COLD-A".to_string(),
old_backend_identity: Some([1; 32]),
new_backend_identity: Some([2; 32]),
}],
expires_at_unix_nanos: 1_780_000_000_000_000_000,
};
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("prepared tier mutation intent record should persist");
let (loaded_before_advance, stale_etag) = load_tier_mutation_intent_record_with_etag(store.clone(), mutation_id)
.await
.expect("prepared tier mutation intent record should load with etag");
assert_eq!(loaded_before_advance, intent);
assert!(!stale_etag.is_empty());
let (committed, first_advanced) = advance_tier_mutation_intent_record_idempotent(
store.clone(),
mutation_id,
TierMutationIntentState::Committed,
Some("new-etag".to_string()),
)
.await
.expect("first commit should advance the record");
assert!(first_advanced);
assert_eq!(committed.state, TierMutationIntentState::Committed);
assert_eq!(committed.revision, 2);
assert_eq!(committed.committed_config_etag.as_deref(), Some("new-etag"));
let (retried, retry_advanced) = advance_tier_mutation_intent_record_idempotent(
store.clone(),
mutation_id,
TierMutationIntentState::Committed,
Some("new-etag".to_string()),
)
.await
.expect("same commit retry should be idempotent");
assert!(!retry_advanced);
assert_eq!(retried, committed);
let conflict = advance_tier_mutation_intent_record_idempotent(
store.clone(),
mutation_id,
TierMutationIntentState::Committed,
Some("other-etag".to_string()),
)
.await
.expect_err("conflicting commit retry should fail closed");
assert!(matches!(conflict, Error::Io(_)));
assert!(conflict.to_string().contains("committed config etag does not match"));
let mut stale_conflict = intent;
stale_conflict
.advance_idempotent(TierMutationIntentState::Committed, Some("other-etag".to_string()))
.expect("stale conflicting intent should advance locally");
let stale_save = save_tier_mutation_intent_record_if_current(store.clone(), &stale_conflict, &stale_etag)
.await
.expect_err("stale etag must fail closed instead of overwriting the committed record");
assert!(matches!(stale_save, Error::PreconditionFailed));
let loaded = load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("conflicting retry must not overwrite the durable record");
assert_eq!(loaded, committed);
let abort_id = uuid::Uuid::new_v4();
let abort_intent = TierMutationIntent {
mutation_id: abort_id,
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest: [4; 32],
affected_targets: vec![TierMutationIntentTarget {
tier_name: "COLD-B".to_string(),
old_backend_identity: Some([1; 32]),
new_backend_identity: Some([2; 32]),
}],
expires_at_unix_nanos: 1_780_000_000_000_000_000,
};
save_tier_mutation_intent_record(store.clone(), &abort_intent)
.await
.expect("prepared abort intent record should persist");
let (aborted, first_abort_advanced) =
advance_tier_mutation_intent_record_idempotent(store.clone(), abort_id, TierMutationIntentState::Aborted, None)
.await
.expect("first abort should advance the record");
assert!(first_abort_advanced);
assert_eq!(aborted.state, TierMutationIntentState::Aborted);
assert_eq!(aborted.revision, 2);
assert_eq!(aborted.committed_config_etag, None);
let (aborted_retry, retry_abort_advanced) =
advance_tier_mutation_intent_record_idempotent(store, abort_id, TierMutationIntentState::Aborted, None)
.await
.expect("same abort retry should be idempotent");
assert!(!retry_abort_advanced);
assert_eq!(aborted_retry, aborted);
}
#[cfg(feature = "test-util")]
fn tier_mutation_peer_test_intent(
mutation_id: uuid::Uuid,
tier_name: &str,
candidate_digest: [u8; 32],
) -> TierMutationIntent {
TierMutationIntent {
mutation_id,
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest,
affected_targets: vec![TierMutationIntentTarget {
tier_name: tier_name.to_string(),
old_backend_identity: Some([1; 32]),
new_backend_identity: Some([2; 32]),
}],
expires_at_unix_nanos: i64::MAX,
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_abort_tombstone_rejects_delayed_prepare() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-abort-tombstone", &[4])).await;
register_mock_tier(&store.tier_config_mgr(), "COLD-A").await;
store
.tier_config_mgr()
.read()
.await
.save_tiering_config(store.clone())
.await
.expect("base tier config should persist");
let config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("base tier config metadata should load");
let mutation_id = uuid::Uuid::new_v4();
let mut intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [7; 32]);
intent.old_config_etag = config_info.etag;
intent.expires_at_unix_nanos =
i64::try_from((OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp_nanos())
.expect("future tombstone expiry should fit i64");
let payload = intent.encode().expect("prepare identity should encode");
let mismatch_id = uuid::Uuid::new_v4();
let mut mismatch = intent.clone();
mismatch.mutation_id = mismatch_id;
mismatch.old_config_etag = Some("not-the-current-config-etag".to_string());
let mismatch_error = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
mismatch_id,
&mismatch.encode().expect("proof-mismatch Abort should encode"),
)
.await
.expect_err("a missing-record Abort with the wrong config proof must fail closed");
assert!(matches!(mismatch_error, TierMutationPeerError::AbortProofMismatch));
assert!(matches!(
load_tier_mutation_intent_record(store.clone(), mismatch_id).await,
Err(Error::ConfigNotFound)
));
let aborted = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
mutation_id,
&payload,
)
.await
.expect("missing-record abort should persist a tombstone");
assert!(aborted.applied);
assert_eq!(aborted.state, TierMutationPeerState::Aborted);
let tombstone = load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("abort tombstone should be durable");
assert_eq!(tombstone.state, TierMutationIntentState::Aborted);
assert!(tombstone.same_identity_as(&intent));
let manager = store.tier_config_mgr();
TierConfigMgr::reload_handle(&manager, store.clone())
.await
.expect("reload should retain an unexpired abort tombstone");
let tombstone_after_reload = load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("reload must not clean an unexpired abort tombstone");
assert_eq!(tombstone_after_reload.state, TierMutationIntentState::Aborted);
assert!(tombstone_after_reload.same_identity_as(&intent));
register_mock_tier(&store.tier_config_mgr(), "COLD-B").await;
store
.tier_config_mgr()
.read()
.await
.save_tiering_config(store.clone())
.await
.expect("later config change should persist before idempotent Abort replay");
let retried_abort = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
mutation_id,
&payload,
)
.await
.expect("terminal Abort retry must not depend on the mutable current config proof");
assert!(!retried_abort.applied);
assert_eq!(retried_abort.state, TierMutationPeerState::Aborted);
let delayed = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&payload,
)
.await
.expect("delayed matching prepare should converge on the tombstone");
assert!(!delayed.applied);
assert_eq!(delayed.state, TierMutationPeerState::Aborted);
TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A")
.await
.expect("delayed prepare must not reinstall the runtime block");
let mut conflicting = intent.clone();
conflicting.candidate_digest = [8; 32];
let conflict = handle_tier_mutation_peer_request(
store,
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&conflicting.encode().expect("conflicting prepare should encode"),
)
.await
.expect_err("different identity must not reuse an abort tombstone");
assert!(matches!(conflict, TierMutationPeerError::ConflictingIntent));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_expired_prepare_cannot_recreate_a_cleaned_tombstone() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-expired-replay", &[4])).await;
register_mock_tier(&store.tier_config_mgr(), "COLD-A").await;
let mutation_id = uuid::Uuid::new_v4();
let mut intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [11; 32]);
intent.expires_at_unix_nanos =
i64::try_from((OffsetDateTime::now_utc() - time::Duration::seconds(1)).unix_timestamp_nanos())
.expect("expired timestamp should fit i64");
let payload = intent.encode().expect("expired signed Prepare should still encode");
let error = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&payload,
)
.await
.expect_err("a missing-record expired Prepare must fail closed");
assert!(matches!(error, TierMutationPeerError::ExpiredIntent));
assert!(matches!(
load_tier_mutation_intent_record(store.clone(), mutation_id).await,
Err(Error::ConfigNotFound)
));
TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A")
.await
.expect("expired replay must not install a peer-only runtime fence");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_peer_prepare_waits_for_inflight_reference_lease() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-peer-drain", &[4])).await;
register_mock_tier(&store.tier_config_mgr(), "COLD-A").await;
let old_lease = TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A")
.await
.expect("old tier operation lease should be available");
let mutation_id = uuid::Uuid::new_v4();
let mut intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [9; 32]);
intent.affected_targets[0].old_backend_identity = Some(old_lease.backend_identity());
let payload = intent.encode().expect("prepare intent should encode");
let prepare_store = store.clone();
let prepare = tokio::spawn(async move {
handle_tier_mutation_peer_request(
prepare_store,
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&payload,
)
.await
});
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A")
.await
.is_err()
{
return;
}
tokio::task::yield_now().await;
}
})
.await
.expect("peer Prepare should install its runtime block");
assert!(
!prepare.is_finished(),
"peer Prepare must not acknowledge while a pre-existing reference creator still holds a lease"
);
drop(old_lease);
let outcome = prepare
.await
.expect("peer Prepare task should join")
.expect("peer Prepare should finish after the lease drains");
assert_eq!(outcome.state, TierMutationPeerState::Prepared);
TierConfigMgr::clear_prepared_mutation_intent_block(&store.tier_config_mgr(), mutation_id)
.await
.expect("test should clear the prepared runtime block");
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently() {
// This end-to-end state-machine scenario intentionally keeps many
// durable fixtures live at once. Give its debug future the same
// dedicated stack used by the other large ecstore scenarios so the
// assertions run under the default test command without RUST_MIN_STACK.
std::thread::Builder::new()
.name("tier-mutation-peer-handler-test".to_string())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(2)
.build()
.expect("tier mutation peer handler test runtime should build");
runtime.block_on(tier_mutation_peer_handler_case());
})
.expect("tier mutation peer handler test thread should spawn")
.join()
.expect("tier mutation peer handler test thread should complete");
}
#[cfg(feature = "test-util")]
async fn tier_mutation_peer_handler_case() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-peer-handler", &[4])).await;
register_mock_tier(&store.tier_config_mgr(), "COLD-A").await;
let (candidate_digest, config_etag) = {
let tier_config_mgr = store.tier_config_mgr();
let manager = tier_config_mgr.read().await;
let candidate_digest = tier_config_candidate_digest(&manager).expect("peer commit candidate digest should build");
manager
.save_tiering_config(store.clone())
.await
.expect("peer commit config fixture should persist");
let config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("peer commit config fixture should load");
(candidate_digest, config_info.etag.expect("peer commit config should carry an ETag"))
};
let mutation_id = uuid::Uuid::new_v4();
let mut intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", candidate_digest);
intent.old_config_etag = Some(config_etag.clone());
let prepare_payload = intent.encode().expect("prepare intent should encode");
let prepared = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&prepare_payload,
)
.await
.expect("first prepare should create the peer intent");
assert!(prepared.applied);
assert_eq!(prepared.state, TierMutationPeerState::Prepared);
let blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
Ok(_) => panic!("prepared peer mutation should block new tier operation leases"),
Err(err) => err,
};
assert!(
blocked.message.contains("being replaced"),
"prepared peer mutation should reuse the existing blocked-tier error: {blocked}"
);
let retried_prepare = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&prepare_payload,
)
.await
.expect("same prepare retry should be idempotent");
assert!(!retried_prepare.applied);
assert_eq!(retried_prepare.state, TierMutationPeerState::Prepared);
let retried_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
Ok(_) => panic!("prepared retry should keep blocking new tier operation leases"),
Err(err) => err,
};
assert!(
retried_blocked.message.contains("being replaced"),
"prepared retry should keep the existing blocked-tier error: {retried_blocked}"
);
let mismatched_commit = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
b"not-the-current-etag",
)
.await
.expect_err("commit with a mismatched config proof must fail closed");
assert!(matches!(mismatched_commit, TierMutationPeerError::CommitProofMismatch));
register_mock_tier(&store.tier_config_mgr(), "COLD-C").await;
let bad_digest_id = uuid::Uuid::new_v4();
let mut bad_digest_intent = tier_mutation_peer_test_intent(bad_digest_id, "COLD-C", [9; 32]);
bad_digest_intent.old_config_etag = Some(config_etag.clone());
handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
bad_digest_id,
&bad_digest_intent.encode().expect("bad digest prepare intent should encode"),
)
.await
.expect("bad digest prepare should install a prepared intent");
let mismatched_digest_commit = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
bad_digest_id,
config_etag.as_bytes(),
)
.await
.expect_err("a correct ETag with a mismatched candidate digest must fail closed");
assert!(matches!(mismatched_digest_commit, TierMutationPeerError::CommitProofMismatch));
let bad_digest_loaded = load_tier_mutation_intent_record(store.clone(), bad_digest_id)
.await
.expect("mismatched digest must leave the prepared intent durable");
assert_eq!(bad_digest_loaded.state, TierMutationIntentState::Prepared);
let bad_digest_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-C").await {
Ok(_) => panic!("mismatched digest must retain the prepared runtime fence"),
Err(err) => err,
};
assert!(bad_digest_blocked.message.contains("being replaced"), "{bad_digest_blocked}");
handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
bad_digest_id,
&bad_digest_intent.encode().expect("bad digest abort identity should encode"),
)
.await
.expect("the negative digest proof fixture should clean up through abort");
let committed = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
config_etag.as_bytes(),
)
.await
.expect("commit should advance the prepared peer intent");
assert!(committed.applied);
assert_eq!(committed.state, TierMutationPeerState::Committed);
let committed_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
Ok(_) => panic!("committed peer mutation must remain blocked until local reload publishes the config"),
Err(err) => err,
};
assert!(
committed_blocked.message.contains("being replaced"),
"committed peer mutation should keep the existing blocked-tier error: {committed_blocked}"
);
let retried_commit = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
config_etag.as_bytes(),
)
.await
.expect("same commit retry should be idempotent");
assert!(!retried_commit.applied);
assert_eq!(retried_commit.state, TierMutationPeerState::Committed);
let retried_commit_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
Ok(_) => panic!("committed retry must keep the tier blocked until local reload"),
Err(err) => err,
};
assert!(
retried_commit_blocked.message.contains("being replaced"),
"committed retry should keep the existing blocked-tier error: {retried_commit_blocked}"
);
let delayed_prepare_retry = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&prepare_payload,
)
.await
.expect("delayed duplicate prepare should report the durable committed state");
assert!(!delayed_prepare_retry.applied);
assert_eq!(delayed_prepare_retry.state, TierMutationPeerState::Committed);
let delayed_prepare_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
Ok(_) => panic!("delayed committed prepare retry must preserve the committed runtime block"),
Err(err) => err,
};
assert!(
delayed_prepare_blocked.message.contains("being replaced"),
"delayed committed prepare retry should keep the existing blocked-tier error: {delayed_prepare_blocked}"
);
let loaded = load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("committed peer intent should remain durable");
assert_eq!(loaded.state, TierMutationIntentState::Committed);
assert_eq!(loaded.committed_config_etag.as_deref(), Some(config_etag.as_str()));
store
.tier_config_mgr()
.read()
.await
.save_tiering_config(store.clone())
.await
.expect("tier config should persist for cleaned intent commit proof");
let tier_config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("tier config object info should load");
let tier_config_etag = tier_config_info.etag.expect("tier config should carry an ETag");
delete_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("simulate another node cleaning the shared committed peer intent");
let cleaned_commit_retry = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
tier_config_etag.as_bytes(),
)
.await
.expect("commit retry after durable cleanup should be idempotently terminal");
assert!(!cleaned_commit_retry.applied);
assert_eq!(cleaned_commit_retry.state, TierMutationPeerState::Committed);
let cleaned_commit_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
Ok(_) => panic!("shared intent cleanup must not clear this node's committed runtime block"),
Err(err) => err,
};
assert!(
cleaned_commit_blocked.message.contains("being replaced"),
"commit retry after shared cleanup should keep the existing blocked-tier error: {cleaned_commit_blocked}"
);
let mismatched_cleaned_commit = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
b"not-the-current-etag",
)
.await
.expect_err("missing intent without a matching committed config ETag must fail closed");
assert!(matches!(mismatched_cleaned_commit, TierMutationPeerError::Store(Error::ConfigNotFound)));
let refresh_store = store.clone();
let refresh_manager = store.tier_config_mgr();
let refresh_worker = tokio::spawn(async move {
TierConfigMgr::refresh_tier_config_handle_with(refresh_manager, refresh_store).await;
});
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(lease) = TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-A").await {
drop(lease);
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("matching cleaned commit should wake the refresh worker and clear the committed fence");
refresh_worker.abort();
let _ = refresh_worker.await;
let abort_id = uuid::Uuid::new_v4();
register_mock_tier(&store.tier_config_mgr(), "COLD-B").await;
store
.tier_config_mgr()
.read()
.await
.save_tiering_config(store.clone())
.await
.expect("abort target tier config should persist");
let abort_config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("abort target config metadata should load");
let abort_config_etag = abort_config_info.etag.expect("abort target config should carry an ETag");
let mut abort_intent = tier_mutation_peer_test_intent(abort_id, "COLD-B", [4; 32]);
abort_intent.old_config_etag = Some(abort_config_etag);
let abort_prepare_payload = abort_intent.encode().expect("abort prepare intent should encode");
handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
abort_id,
&abort_prepare_payload,
)
.await
.expect("abort target prepare should create the peer intent");
let abort_blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-B").await {
Ok(_) => panic!("abort target prepare should block new tier operation leases"),
Err(err) => err,
};
assert!(
abort_blocked.message.contains("being replaced"),
"abort target prepare should reuse the existing blocked-tier error: {abort_blocked}"
);
let aborted = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
abort_id,
&abort_prepare_payload,
)
.await
.expect("abort should advance the prepared peer intent");
assert!(aborted.applied);
assert_eq!(aborted.state, TierMutationPeerState::Aborted);
TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-B")
.await
.expect("durable abort must clear the peer runtime fence immediately");
let retried_abort = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
abort_id,
&abort_prepare_payload,
)
.await
.expect("same abort retry should be idempotent before recovery cleanup");
assert!(!retried_abort.applied);
assert_eq!(retried_abort.state, TierMutationPeerState::Aborted);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn late_abort_after_config_commit_keeps_fence_until_commit_recovery() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-late-abort", &[4])).await;
register_mock_tier(&store.tier_config_mgr(), "COLD-A").await;
store
.tier_config_mgr()
.read()
.await
.save_tiering_config(store.clone())
.await
.expect("base tier config should persist");
let base_config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("base tier config metadata should load");
let base_etag = base_config_info.etag.expect("base tier config should carry an ETag");
register_mock_tier(&store.tier_config_mgr(), "COLD-B").await;
let candidate_digest = {
let manager = store.tier_config_mgr();
let manager = manager.read().await;
tier_config_candidate_digest(&manager).expect("candidate digest should build")
};
let mutation_id = uuid::Uuid::new_v4();
let mut intent = tier_mutation_peer_test_intent(mutation_id, "COLD-B", candidate_digest);
intent.old_config_etag = Some(base_etag.clone());
let prepare_payload = intent.encode().expect("late abort prepare intent should encode");
handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&prepare_payload,
)
.await
.expect("prepare should install the runtime fence");
store
.tier_config_mgr()
.read()
.await
.save_tiering_config(store.clone())
.await
.expect("candidate tier config should persist before the late abort");
let committed_config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("committed tier config metadata should load");
let committed_etag = committed_config_info
.etag
.expect("committed tier config should carry an ETag");
assert_ne!(committed_etag, base_etag);
let late_abort = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Abort,
mutation_id,
&prepare_payload,
)
.await
.expect_err("an abort after the candidate config commit must fail closed");
assert!(matches!(late_abort, TierMutationPeerError::AbortProofMismatch));
let prepared = load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("rejected late abort must retain the prepared intent");
assert_eq!(prepared.state, TierMutationIntentState::Prepared);
let blocked = match TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-B").await {
Ok(_) => panic!("rejected late abort must retain the runtime fence"),
Err(err) => err,
};
assert!(blocked.message.contains("being replaced"), "{blocked}");
let committed = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
committed_etag.as_bytes(),
)
.await
.expect("matching commit should converge the rejected late abort fixture");
assert!(committed.applied);
assert_eq!(committed.state, TierMutationPeerState::Committed);
let refresh_store = store.clone();
let refresh_manager = store.tier_config_mgr();
let refresh_worker = tokio::spawn(async move {
TierConfigMgr::refresh_tier_config_handle_with(refresh_manager, refresh_store).await;
});
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(lease) = TierConfigMgr::acquire_operation_lease(&store.tier_config_mgr(), "COLD-B").await {
drop(lease);
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("commit recovery should publish before clearing the late-abort fence");
refresh_worker.abort();
let _ = refresh_worker.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn missing_record_commit_promotes_prepared_fence_until_worker_publish() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-missing-record-commit", &[4]))
.await;
register_mock_tier(&store.tier_config_mgr(), "COLD-A").await;
let tier_config_mgr = store.tier_config_mgr();
let candidate_digest = {
let manager = tier_config_mgr.read().await;
let digest = tier_config_candidate_digest(&manager).expect("candidate digest should build");
manager
.save_tiering_config(store.clone())
.await
.expect("candidate config should persist");
digest
};
let config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE),
&ObjectOptions::default(),
)
.await
.expect("candidate config metadata should load");
let config_etag = config_info.etag.expect("candidate config should carry an ETag");
let mutation_id = uuid::Uuid::new_v4();
let intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", candidate_digest);
handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&intent.encode().expect("prepare intent should encode"),
)
.await
.expect("prepare should install the runtime fence");
delete_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("simulate shared intent cleanup before the local commit arrives");
let committed = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
config_etag.as_bytes(),
)
.await
.expect("matching commit after shared cleanup should be terminal");
assert!(!committed.applied);
assert_eq!(committed.state, TierMutationPeerState::Committed);
let blocked = match TierConfigMgr::acquire_operation_lease(&tier_config_mgr, "COLD-A").await {
Ok(_) => panic!("the promoted committed fence must block old-generation leases"),
Err(err) => err,
};
assert!(blocked.message.contains("being replaced"), "{blocked}");
let refresh_store = store.clone();
let refresh_manager = tier_config_mgr.clone();
let refresh_worker = tokio::spawn(async move {
TierConfigMgr::refresh_tier_config_handle_with(refresh_manager, refresh_store).await;
});
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(lease) = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, "COLD-A").await {
drop(lease);
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the commit notification should drive publish and clear the promoted fence");
refresh_worker.abort();
let _ = refresh_worker.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_peer_handler_rejects_conflicting_prepare_without_overwrite() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-peer-conflict", &[4])).await;
let mutation_id = uuid::Uuid::new_v4();
let intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [3; 32]);
let prepare_payload = intent.encode().expect("prepare intent should encode");
handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&prepare_payload,
)
.await
.expect("first prepare should create the peer intent");
let conflicting = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [4; 32]);
let conflicting_payload = conflicting.encode().expect("conflicting intent should encode");
let conflict = handle_tier_mutation_peer_request(
store.clone(),
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&conflicting_payload,
)
.await
.expect_err("conflicting prepare must fail closed");
assert!(matches!(conflict, TierMutationPeerError::ConflictingIntent));
let loaded = load_tier_mutation_intent_record(store, mutation_id)
.await
.expect("conflicting prepare must not overwrite the first record");
assert_eq!(loaded.candidate_digest, [3; 32]);
assert_eq!(loaded.state, TierMutationIntentState::Prepared);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_intent_record_scan_paginates_exact_limit() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-mutation-intent-scan-page", &[4])).await;
let build_intent = |mutation_id: uuid::Uuid, tier_name: &str| TierMutationIntent {
mutation_id,
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest: [3; 32],
affected_targets: vec![TierMutationIntentTarget {
tier_name: tier_name.to_string(),
old_backend_identity: Some([1; 32]),
new_backend_identity: Some([2; 32]),
}],
expires_at_unix_nanos: 1_780_000_000_000_000_000,
};
let intent_ids = vec![
uuid::Uuid::parse_str("11345678-1234-5678-9abc-def012345678").expect("first uuid should parse"),
uuid::Uuid::parse_str("22345678-1234-5678-9abc-def012345678").expect("second uuid should parse"),
uuid::Uuid::parse_str("33345678-1234-5678-9abc-def012345678").expect("third uuid should parse"),
];
for (index, mutation_id) in intent_ids.iter().copied().enumerate() {
let intent = build_intent(mutation_id, &format!("COLD-{index}"));
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("tier mutation intent record should persist");
}
let exact_page = list_tier_mutation_intent_records(store.clone(), 3, None)
.await
.expect("exact full page should scan");
assert_eq!(exact_page.scanned, 3);
assert_eq!(exact_page.intents.len(), 3);
assert_eq!(exact_page.failed, 0);
assert!(!exact_page.truncated);
assert_eq!(exact_page.next_marker, None);
let first_page = list_tier_mutation_intent_records(store.clone(), 2, None)
.await
.expect("first page should scan");
assert_eq!(first_page.scanned, 2);
assert_eq!(first_page.intents.len(), 2);
assert_eq!(first_page.failed, 0);
assert!(first_page.truncated);
assert!(first_page.next_marker.is_some());
let second_page = list_tier_mutation_intent_records(store, 2, first_page.next_marker)
.await
.expect("second page should scan");
let mut loaded_ids: Vec<_> = first_page
.intents
.into_iter()
.chain(second_page.intents.into_iter())
.map(|intent| intent.mutation_id)
.collect();
loaded_ids.sort();
assert_eq!(second_page.scanned, 1);
assert_eq!(second_page.failed, 0);
assert!(!second_page.truncated);
assert_eq!(second_page.next_marker, None);
assert_eq!(loaded_ids, intent_ids);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn cancelled_transition_cleanup_recovers_from_its_own_instance_transaction() {
struct ResolverReset(Arc<std::sync::Mutex<Option<std::sync::Weak<crate::store::ECStore>>>>);
impl Drop for ResolverReset {
fn drop(&mut self) {
*self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
}
let temp_a = tempfile::tempdir().expect("create transition store dir a");
let temp_b = tempfile::tempdir().expect("create transition store dir b");
let shutdown_a = CancellationToken::new();
let shutdown_b = CancellationToken::new();
shutdown_a.cancel();
shutdown_b.cancel();
let (ctx_a, store_a, shutdown_a) = without_storage_class_env(build_isolated_test_store_with_shutdown(
temp_a.path(),
"transition-cleanup-context-a",
&[4],
shutdown_a,
))
.await;
let (ctx_b, store_b, shutdown_b) = without_storage_class_env(build_isolated_test_store_with_shutdown(
temp_b.path(),
"transition-cleanup-context-b",
&[4],
shutdown_b,
))
.await;
assert!(shutdown_a.is_cancelled());
assert!(shutdown_b.is_cancelled());
crate::bucket::metadata_sys::init_bucket_metadata_sys(store_a.clone(), Vec::new()).await;
let resolver_target = Arc::new(std::sync::Mutex::new(Some(Arc::downgrade(&store_b))));
let resolver_store = resolver_target.clone();
assert!(
set_object_store_resolver(Arc::new(move || {
resolver_store
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(std::sync::Weak::upgrade)
})),
"the cross-context regression test must install the only process object-store resolver"
);
let _resolver_reset = ResolverReset(resolver_target);
assert!(
runtime_sources::object_store_handle().is_some_and(|store| Arc::ptr_eq(&store, &store_b)),
"the process resolver must deliberately point at store B"
);
let tier_name = "CROSSCTXA";
let backend = register_transition_reconcile_test_tier(&ctx_a.tier_config_mgr(), tier_name).await;
backend.set_put_remote_version(Some(uuid::Uuid::new_v4().to_string())).await;
backend.reject_next_non_empty_remote_version_validation();
let remove_barrier = backend.arm_failing_remove_barrier().await;
let bucket = "transition-cleanup-context-a";
let object = "rejected-candidate.bin";
store_a
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("store A bucket should be created");
let mut reader = PutObjReader::from_vec(b"cross-context rejected transition cleanup".repeat(1024));
let original = store_a
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("store A source object should be written");
let opts = ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("the source object should have an ETag"),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
};
let cleanup_store_barrier = TransitionCleanupStoreBarrier::install();
let transition_store = store_a.clone();
let transition = tokio::spawn(async move { transition_store.transition_object(bucket, object, &opts).await });
cleanup_store_barrier.wait_until_paused().await;
transition.abort();
assert!(
transition
.await
.expect_err("the transition task should observe cancellation")
.is_cancelled()
);
remove_barrier.wait_until_paused().await;
let transaction_counts = (
transition_transaction_record_count(store_a.clone()).await,
transition_transaction_record_count(store_b.clone()).await,
);
assert_eq!(
transaction_counts,
(1, 0),
"the transition transaction must remain only on store A even while the process resolver points at store B"
);
let journal_counts = (
tier_delete_journal_count(store_a.clone()).await,
tier_delete_journal_count(store_b.clone()).await,
);
assert_eq!(
journal_counts,
(0, 0),
"rejected transition uploads must not create a tier-delete journal"
);
assert_eq!(backend.object_count().await, 1, "failed cleanup should retain the remote candidate");
remove_barrier.release();
remove_barrier.wait_until_operation_dropped().await;
let recovered = recover_transition_transaction_records(store_a.clone(), 100, None)
.await
.expect("store A should recover its own cancelled-transition transaction");
assert_eq!(
(recovered.scanned, recovered.recovered, recovered.retained, recovered.failed),
(1, 1, 0, 0)
);
assert_eq!(transition_transaction_record_count(store_a.clone()).await, 0);
assert_eq!(transition_transaction_record_count(store_b.clone()).await, 0);
assert_eq!(tier_delete_journal_count(store_a.clone()).await, 0);
assert_eq!(tier_delete_journal_count(store_b.clone()).await, 0);
assert_eq!(
backend.object_count().await,
0,
"store A recovery should delete the exact remote candidate"
);
assert!(!Arc::ptr_eq(&ctx_a, &ctx_b), "the regression requires two distinct instance contexts");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_retains_active_uploaded_candidate_until_commit() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-active-uploaded", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXACTIVEUPLOADED";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "transition-transaction-active-uploaded-bucket";
let object = "object.bin";
let payload = b"active Uploaded ownership must survive recovery until local commit".repeat(1024);
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("source bucket should be created");
let mut reader = PutObjReader::from_vec(payload.clone());
let source = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
let barrier = TransitionUploadedCommitBarrier::install(bucket, object);
let transition_store = store.clone();
let transition = tokio::spawn(async move {
transition_store
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("source object should have an ETag"),
..Default::default()
},
version_id: source.version_id.map(|version_id| version_id.to_string()),
mod_time: source.mod_time,
..Default::default()
},
)
.await
});
barrier.wait_until_paused().await;
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("recovery should inspect the active Uploaded transaction");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 0, 1, 0));
assert_eq!(backend.object_count().await, 1, "active ownership must retain the remote candidate");
assert_eq!(backend.remove_count().await, 0, "active ownership must fence remote DELETE");
assert_eq!(transition_transaction_record_count(store.clone()).await, 1);
barrier.release();
transition
.await
.expect("transition task should join")
.expect("transition should commit after the barrier is released");
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
assert_eq!(backend.remove_count().await, 0);
let mut body = Vec::new();
store
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("committed tier object should remain readable")
.stream
.read_to_end(&mut body)
.await
.expect("tier object body should drain");
assert_eq!(body, payload);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_deletes_expired_uploaded_remote_candidate() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-recovery", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXRECOVERY";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let exact_version = uuid::Uuid::new_v4().to_string();
let cases = [
(
"exact",
exact_version.clone(),
TransitionRemoteVersion::versioned(exact_version),
TransitionSourceVersionMode::Versioned,
),
(
"suspended-null",
"null".to_string(),
TransitionRemoteVersion::versioned("null"),
TransitionSourceVersionMode::VersionSuspended,
),
(
"known-unversioned",
String::new(),
TransitionRemoteVersion::unversioned(),
TransitionSourceVersionMode::Unversioned,
),
];
let mut expected_removes = Vec::new();
for (case, put_version, remote_version, source_mode) in cases {
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: format!("source-{case}"),
version_id: (source_mode == TransitionSourceVersionMode::Versioned).then(uuid::Uuid::new_v4),
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: source_mode,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1,
})
.expect("transaction should build");
transaction
.advance(transaction.fence(), TransitionTransactionState::Uploaded, Some(remote_version))
.expect("transaction should enter uploaded state");
backend.set_put_remote_version(Some(put_version.clone())).await;
let candidate = bytes::Bytes::from_static(b"orphan candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
expected_removes.push((transaction.remote_object, put_version));
}
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (3, 3, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
let mut actual_removes = backend.remove_versions().await;
actual_removes.sort();
expected_removes.sort();
assert_eq!(actual_removes, expected_removes, "recovery must preserve each remote version shape");
assert_eq!(backend.exact_remove_count(), 2);
assert_eq!(backend.object_count().await, 0);
let replay = recover_transition_transaction_records(store, 100, None)
.await
.expect("replayed recovery should remain idempotent");
assert_eq!((replay.scanned, replay.recovered, replay.retained, replay.failed), (0, 0, 0, 0));
assert_eq!(
backend.remove_versions().await.len(),
3,
"each expired candidate must be deleted only once"
);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_cas_loses_to_active_state_advance() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-recovery-cas", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXRECOVERYCAS";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let remote_version = uuid::Uuid::new_v4().to_string();
let mut uploaded = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Versioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1,
})
.expect("transaction should build");
uploaded
.advance(
uploaded.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::versioned(remote_version.clone())),
)
.expect("transaction should enter uploaded state");
backend.set_put_remote_version(Some(remote_version)).await;
let candidate = bytes::Bytes::from_static(b"CAS-owned transition remote candidate");
backend
.put(
&uploaded.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), &uploaded)
.await
.expect("transaction record should persist");
let barrier = TransitionRecoveryClaimBarrier::install(uploaded.transaction_id);
let recovery_store = store.clone();
let recovery = tokio::spawn(async move { recover_transition_transaction_records(recovery_store, 100, None).await });
barrier.wait_until_paused().await;
let mut active = uploaded.clone();
active
.advance(active.fence(), TransitionTransactionState::LocalCommitStarted, None)
.expect("active owner should advance to local commit");
save_transition_transaction_record_if_current(store.clone(), &uploaded, &active)
.await
.expect("active owner should win the persisted CAS");
barrier.release();
let stats = recovery
.await
.expect("recovery task should join")
.expect("recovery should treat the lost CAS as a retained transaction");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 0, 1, 0));
assert_eq!(
load_transition_transaction_record(store, uploaded.transaction_id)
.await
.expect("newer transaction revision must remain"),
active
);
assert_eq!(backend.object_count().await, 1, "a stale recovery must not delete the candidate");
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_quarantines_missing_required_fields() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"transition-transaction-recovery-corrupt",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXRECOVERYCORRUPT";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let remote_version = uuid::Uuid::new_v4().to_string();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1,
})
.expect("transaction should build");
transaction
.advance(
transaction.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::versioned(remote_version.clone())),
)
.expect("transaction should enter uploaded state");
backend.set_put_remote_version(Some(remote_version)).await;
let candidate = bytes::Bytes::from_static(b"corrupt journal candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
let mut persisted: serde_json::Value = serde_json::from_slice(&transaction.encode().expect("transaction should encode"))
.expect("transaction should be JSON");
persisted["transaction"]
.as_object_mut()
.expect("transaction payload should be an object")
.remove("not_after_unix_nanos");
let path =
transition_transaction_record_object_name(transaction.transaction_id).expect("transaction path should be canonical");
com::save_config(
store.clone(),
&path,
serde_json::to_vec(&persisted).expect("corrupt fixture should encode"),
)
.await
.expect("corrupt transaction fixture should persist");
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("recovery scan should isolate a corrupt record");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 0, 0, 1));
assert_eq!(
transition_transaction_record_count(store).await,
1,
"corrupt evidence must remain quarantined"
);
assert_eq!(backend.object_count().await, 1, "corrupt evidence must never authorize remote DELETE");
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_retries_cleanup_pending_candidate() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-cleanup", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXCLEANUP";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let remote_version = uuid::Uuid::new_v4().to_string();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Versioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transaction should build");
let uploaded_fence = transaction
.advance(
transaction.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::versioned(remote_version.clone())),
)
.expect("transaction should enter uploaded state");
transaction
.mark_cleanup_pending(
uploaded_fence,
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
write_id: transaction.write_id,
remote_object: transaction.remote_object.clone(),
remote_version: transaction.remote_version.clone(),
backend_fingerprint: transaction.backend_fingerprint,
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
},
)
.expect("transaction should enter cleanup pending state");
backend.set_put_remote_version(Some(remote_version.clone())).await;
let candidate = bytes::Bytes::from_static(b"cleanup pending candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 1, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
assert_eq!(
backend.remove_versions().await,
vec![(transaction.remote_object.clone(), remote_version)],
"cleanup pending recovery must retry the exact remote candidate delete"
);
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(backend.object_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_retries_cleanup_pending_unversioned_candidate() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"transition-transaction-cleanup-unversioned",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXCLEANUPUNVERSIONED";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transaction should build");
let uploaded_fence = transaction
.advance(
transaction.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::unversioned()),
)
.expect("transaction should enter uploaded state");
transaction
.mark_cleanup_pending(
uploaded_fence,
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
write_id: transaction.write_id,
remote_object: transaction.remote_object.clone(),
remote_version: transaction.remote_version.clone(),
backend_fingerprint: transaction.backend_fingerprint,
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
},
)
.expect("transaction should enter cleanup pending state");
backend.set_put_remote_version(Some(String::new())).await;
let candidate = bytes::Bytes::from_static(b"cleanup pending unversioned candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 1, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
assert_eq!(
backend.remove_versions().await,
vec![(transaction.remote_object.clone(), String::new())],
"unversioned cleanup must not send a synthetic remote version"
);
assert_eq!(backend.exact_remove_count(), 0);
assert_eq!(backend.object_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_keeps_cleanup_pending_record_when_delete_fails() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-cleanup-fail", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXCLEANUPFAIL";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let remote_version = uuid::Uuid::new_v4().to_string();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Versioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transaction should build");
let uploaded_fence = transaction
.advance(
transaction.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::versioned(remote_version)),
)
.expect("transaction should enter uploaded state");
transaction
.mark_cleanup_pending(
uploaded_fence,
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
write_id: transaction.write_id,
remote_object: transaction.remote_object.clone(),
remote_version: transaction.remote_version.clone(),
backend_fingerprint: transaction.backend_fingerprint,
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
},
)
.expect("transaction should enter cleanup pending state");
let candidate = bytes::Bytes::from_static(b"cleanup pending candidate retained after failure");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
backend.set_remove_failure(true);
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should keep scanning after cleanup failure");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 0, 0, 1));
assert_eq!(
transition_transaction_record_count(store.clone()).await,
1,
"failed cleanup must keep the cleanup-pending transaction for retry"
);
assert_eq!(backend.remove_versions().await, Vec::<(String, String)>::new());
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(backend.object_count().await, 1);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_keeps_cleanup_pending_local_commit() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"transition-transaction-cleanup-committed",
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXCLEANUPCOMMITTED";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let bucket = "transition-transaction-cleanup-committed-bucket";
let object = "object.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"cleanup pending local commit record cleanup".repeat(1024));
let original = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
let opts = ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source object should have etag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
};
store
.transition_object(bucket, object, &opts)
.await
.expect("transition should commit");
let committed = store
.get_object_info(
bucket,
object,
&ObjectOptions {
no_lock: true,
metadata_cache_safe: false,
..Default::default()
},
)
.await
.expect("committed object info should be readable");
let mut remote_parts = committed.transitioned_object.name.rsplit('/');
let write_id = uuid::Uuid::parse_str(remote_parts.next().expect("remote object should contain write id"))
.expect("write id should parse");
let transaction_id = uuid::Uuid::parse_str(remote_parts.next().expect("remote object should contain transaction id"))
.expect("transaction id should parse");
let source = TransitionSourceIdentity {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: original
.mod_time
.expect("source object should have mod_time")
.unix_timestamp_nanos()
.try_into()
.expect("test timestamp should fit i64"),
size: original.size,
etag: original.etag.expect("source object should have etag"),
version_mode: TransitionSourceVersionMode::Unversioned,
};
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id,
owner_epoch: uuid::Uuid::new_v4(),
write_id,
source,
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transaction should build");
transaction
.advance(
transaction.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::known_from_put_response(
committed.transitioned_object.version_id.clone(),
)),
)
.expect("transaction should enter uploaded state");
let local_commit_fence = transaction
.advance(transaction.fence(), TransitionTransactionState::LocalCommitStarted, None)
.expect("transaction should enter local commit state");
transaction
.mark_cleanup_pending(
local_commit_fence,
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
write_id: transaction.write_id,
remote_object: transaction.remote_object.clone(),
remote_version: transaction.remote_version.clone(),
backend_fingerprint: transaction.backend_fingerprint,
decision: TransitionCleanupDecision::SourceReconciledUnchanged {
observed_source: transaction.source.clone(),
},
},
)
.expect("transaction should enter cleanup pending state");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 1, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
assert_eq!(
backend.object_count().await,
1,
"cleanup pending recovery must keep the remote body once local metadata references it"
);
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_drops_record_after_confirmed_local_commit() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-committed", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXCOMMITTED";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let bucket = "transition-transaction-committed-bucket";
let object = "object.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"confirmed local commit record cleanup".repeat(1024));
let original = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
let opts = ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: original.etag.clone().expect("source object should have etag"),
..Default::default()
},
mod_time: original.mod_time,
..Default::default()
};
store
.transition_object(bucket, object, &opts)
.await
.expect("transition should commit");
let committed = store
.get_object_info(
bucket,
object,
&ObjectOptions {
no_lock: true,
metadata_cache_safe: false,
..Default::default()
},
)
.await
.expect("committed object info should be readable");
let mut remote_parts = committed.transitioned_object.name.rsplit('/');
let write_id = uuid::Uuid::parse_str(remote_parts.next().expect("remote object should contain write id"))
.expect("write id should parse");
let transaction_id = uuid::Uuid::parse_str(remote_parts.next().expect("remote object should contain transaction id"))
.expect("transaction id should parse");
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id,
owner_epoch: uuid::Uuid::new_v4(),
write_id,
source: TransitionSourceIdentity {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: original
.mod_time
.expect("source object should have mod_time")
.unix_timestamp_nanos()
.try_into()
.expect("test timestamp should fit i64"),
size: original.size,
etag: original.etag.expect("source object should have etag"),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transaction should build");
transaction
.advance(
transaction.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::known_from_put_response(
committed.transitioned_object.version_id.clone(),
)),
)
.expect("transaction should enter uploaded state");
transaction
.advance(transaction.fence(), TransitionTransactionState::LocalCommitStarted, None)
.expect("transaction should enter local commit state");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
assert_eq!(transition_transaction_record_count(store.clone()).await, 1);
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 1, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
assert_eq!(
backend.object_count().await,
1,
"confirmed local commit recovery must not delete the committed remote body"
);
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_retains_unproven_remote_candidates() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-transaction-unproven", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXUNPROVEN";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let remote_version = uuid::Uuid::new_v4().to_string();
let new_transaction = || {
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "absent-source-bucket".to_string(),
object: "source-object".to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transaction should build")
};
let upload_started = new_transaction();
let mut local_commit_started = new_transaction();
local_commit_started
.advance(
local_commit_started.fence(),
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::versioned(remote_version.clone())),
)
.expect("transaction should enter uploaded state");
local_commit_started
.advance(local_commit_started.fence(), TransitionTransactionState::LocalCommitStarted, None)
.expect("transaction should enter local commit state");
backend.set_put_remote_version(Some(remote_version)).await;
for transaction in [&upload_started, &local_commit_started] {
let candidate = bytes::Bytes::from_static(b"unproven transition remote candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), transaction)
.await
.expect("transaction record should persist");
}
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (2, 0, 2, 0));
assert_eq!(
transition_transaction_record_count(store.clone()).await,
2,
"an upload without completion proof or unproven local commit must remain for authoritative reconcile"
);
assert_eq!(backend.object_count().await, 2, "recovery must not delete an unproven remote candidate");
assert_eq!(backend.remove_count().await, 0);
assert_eq!(backend.exact_remove_count(), 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_handles_provider_recovered_unknown_upload() {
let versioned_remote = uuid::Uuid::new_v4().to_string();
let nil_remote = uuid::Uuid::nil().to_string();
for (case, tier_name, remote_version, should_recover) in [
("missing", "TXPROBEMISSING", None, true),
("unversioned", "TXPROBEUNVERSIONED", Some(String::new()), true),
("versioned", "TXPROBEVERSIONED", Some(versioned_remote), true),
("nil-version", "TXPROBENILVERSION", Some(nil_remote), false),
] {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
&format!("transition-transaction-probe-{case}"),
&[4],
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1,
})
.expect("transaction should build");
transaction
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
.expect("transaction should enter unknown upload outcome state");
if let Some(version) = &remote_version {
backend.set_put_remote_version(Some(version.clone())).await;
let candidate = bytes::Bytes::from_static(b"provider-recovered transition remote candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
}
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
let stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should run");
assert_eq!(
(stats.scanned, stats.recovered, stats.retained, stats.failed),
if should_recover { (1, 1, 0, 0) } else { (1, 0, 1, 0) }
);
assert_eq!(transition_transaction_record_count(store.clone()).await, usize::from(!should_recover));
assert_eq!(
backend.object_count().await,
usize::from(!should_recover),
"case {case}: only a valid provider version state may be recovered destructively"
);
let removed = remote_version
.filter(|_| should_recover)
.map(|version| vec![(transaction.remote_object.clone(), version)])
.unwrap_or_default();
assert_eq!(
backend.remove_versions().await,
removed,
"case {case}: recovery must delete only provider-recovered candidates"
);
assert_eq!(
backend.exact_remove_count(),
usize::from(removed.first().is_some_and(|(_, version)| !version.is_empty()))
);
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn operator_reconcile_deletes_exact_candidate_before_finalizing_record() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-operator-reconcile", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXOPERATOR";
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1,
})
.expect("transaction should build");
transaction
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
.expect("transaction should enter unknown upload outcome state");
let remote_version = uuid::Uuid::new_v4().to_string();
backend.set_put_remote_version(Some(remote_version.clone())).await;
let candidate = bytes::Bytes::from_static(b"operator-confirmed transition candidate");
backend
.put(
&transaction.remote_object,
ReaderImpl::Body(candidate.clone()),
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
)
.await
.expect("mock backend should accept candidate");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
let status = inspect_transition_transaction_for_operator(store.clone(), transaction.transaction_id)
.await
.expect("operator inspection should probe the candidate");
assert_eq!(status.probe, TransitionOperatorProbe::VersionedPresent(remote_version.clone()));
let wrong_version = uuid::Uuid::new_v4().to_string();
let err = delete_transition_candidate_for_operator(store.clone(), transaction.transaction_id, &wrong_version)
.await
.expect_err("a mismatched exact version must fail before deleting a candidate");
assert!(matches!(
err,
TransitionOperatorError::CandidateVersionMismatch {
expected,
actual: TransitionOperatorProbe::VersionedPresent(ref observed),
} if expected == wrong_version && observed == &remote_version
));
assert!(backend.contains(&transaction.remote_object).await);
assert_eq!(backend.exact_remove_count(), 0);
load_transition_transaction_record(store.clone(), transaction.transaction_id)
.await
.expect("an incorrect exact version must retain the transaction journal");
let result = delete_transition_candidate_for_operator(store.clone(), transaction.transaction_id, &remote_version)
.await
.expect("operator-confirmed exact candidate should be deleted");
assert_eq!(result.status.probe, TransitionOperatorProbe::Missing);
assert!(result.journal_observed_after_delete);
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(backend.remove_versions().await, vec![(transaction.remote_object.clone(), remote_version)]);
load_transition_transaction_record(store.clone(), transaction.transaction_id)
.await
.expect("candidate deletion must retain the transaction journal");
finalize_missing_transition_transaction_for_operator(store.clone(), transaction.transaction_id)
.await
.expect("a separately confirmed missing candidate should permit finalization");
assert!(matches!(
load_transition_transaction_record(store, transaction.transaction_id).await,
Err(Error::ConfigNotFound)
));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn operator_finalize_retains_record_without_missing_proof() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-operator-fail-closed", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXOPERATORFAIL";
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: None,
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Unversioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1,
})
.expect("transaction should build");
transaction
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
.expect("transaction should enter unknown upload outcome state");
save_transition_transaction_record(store.clone(), &transaction)
.await
.expect("transaction record should persist");
backend
.set_transition_candidate_probe_override(Some(TransitionCandidateProbe::Unsupported))
.await;
assert!(matches!(
finalize_missing_transition_transaction_for_operator(store.clone(), transaction.transaction_id).await,
Err(TransitionOperatorError::CandidateNotMissing(TransitionOperatorProbe::Unsupported))
));
load_transition_transaction_record(store, transaction.transaction_id)
.await
.expect("an unsupported probe must retain the transaction journal");
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transition_response_loss_persists_unknown_outcome_for_provider_recovery() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-response-loss", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "TXRESPONSELOSS";
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "transition-response-loss-bucket";
let object = "source.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("source bucket should be created");
let payload = b"a response-lost tier PUT must remain recoverable".repeat(1024);
let mut reader = PutObjReader::from_vec(payload.clone());
let source = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("source object should be written");
backend.lose_next_put_response();
let error = store
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("source object should have an ETag"),
..Default::default()
},
version_id: source.version_id.map(|version| version.to_string()),
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect_err("a lost tier PUT response must fail the transition request");
assert!(
matches!(error, StorageError::Io(ref err) if err.kind() == std::io::ErrorKind::ConnectionReset),
"the response-loss error must remain visible to the caller: {error:?}"
);
let records = store
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
TRANSITION_TRANSACTION_RECORD_PREFIX,
None,
None,
10,
false,
None,
false,
)
.await
.expect("transition transaction records should be listable");
assert_eq!(records.objects.len(), 1, "response loss must leave one durable transaction record");
let transaction_id = records.objects[0]
.name
.rsplit('/')
.next()
.and_then(|name| name.strip_suffix(".json"))
.and_then(|name| uuid::Uuid::parse_str(name).ok())
.expect("transaction record name should contain a UUID");
let transaction = load_transition_transaction_record(store.clone(), transaction_id)
.await
.expect("response loss transaction record should load");
assert_eq!(
transaction.state,
TransitionTransactionState::UploadOutcomeUnknown,
"a response-lost PUT must not remain in UploadStarted"
);
assert!(
backend.contains(&transaction.remote_object).await,
"the test backend must retain the remote candidate"
);
backend
.set_transition_candidate_probe_override(Some(TransitionCandidateProbe::Unsupported))
.await;
let unsupported_stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("unsupported provider recovery should fail closed");
assert_eq!(
(
unsupported_stats.scanned,
unsupported_stats.recovered,
unsupported_stats.retained,
unsupported_stats.failed
),
(1, 0, 1, 0),
"an unsupported provider probe must retain the unknown upload"
);
assert_eq!(transition_transaction_record_count(store.clone()).await, 1);
assert!(
backend.contains(&transaction.remote_object).await,
"unsupported recovery must not delete the candidate"
);
assert_eq!(backend.remove_count().await, 0, "unsupported recovery must not attempt cleanup");
backend.set_transition_candidate_probe_override(None).await;
let stats =
recover_transition_transaction_records_at(store.clone(), 100, None, i128::from(transaction.not_after_unix_nanos) + 1)
.await
.expect("provider-authoritative recovery should run");
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (1, 1, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
assert_eq!(backend.object_count().await, 0, "recovery must delete the provider-confirmed candidate");
let op_log = backend.op_log().await;
assert!(
op_log.iter().any(|operation| matches!(operation, MockWarmOp::Probe { .. })),
"response-loss recovery must enter the provider probe branch"
);
assert!(
op_log.iter().any(|operation| matches!(operation, MockWarmOp::Put { .. })),
"response-loss fixture must record that the remote PUT reached the backend"
);
let source_after = store
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("recovery must preserve the local source object");
assert_eq!(source_after.size, i64::try_from(payload.len()).expect("payload length should fit i64"));
}
}