// 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 { endpoint_pools.as_ref().iter().map(|pool| pool.drives_per_set).collect() } fn resolve_startup_pool_defaults(endpoint_pools: &EndpointServerPools) -> Result> { 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> { 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, ) -> 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( pools: Vec>, 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( pools: Vec>, 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( meta: &PoolMeta, pools: Vec>, write_state: &mut PoolMetaWriteState, ) -> Result 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( meta: &PoolMeta, pools: Vec>, replica_state: PoolMetaReplicaState, write_state: &mut PoolMetaWriteState, topology_update: bool, elected_writer: bool, ) -> Result 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(rx: CancellationToken, mut reconcile: F) where F: FnMut() -> Fut, Fut: Future>, { 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, 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, 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> { 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, ) -> Result> { 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, ) -> Result> { 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, 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(name: &str, case: C) where C: FnOnce() -> F + Send + 'static, F: Future + '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 { meta.encode_config_data_for_test().expect("pool metadata should encode") } #[derive(Debug)] struct StartupPoolMetaStorage { read_payload: Vec, read_error: bool, read_without_lock: AtomicBool, wrote_without_lock: AtomicBool, wrote_with_max_parity: AtomicBool, written_payload: Mutex>>, revision: AtomicUsize, pool_meta_write_attempts: AtomicUsize, fail_pool_meta_write_at: AtomicUsize, pool_meta_written_versions: Mutex>, objects: Mutex, String)>>, } impl StartupPoolMetaStorage { fn new(read_payload: Vec) -> 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, _h: HeaderMap, opts: &ObjectOptions, ) -> Result { 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 { 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) -> 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::::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>) -> 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::>(), ) } 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::>(), ) } #[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(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, Arc, 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, Arc, CancellationToken, ) { let pool_layouts = pool_drive_counts .iter() .map(|&drives_per_set| (1, drives_per_set)) .collect::>(); 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, Arc, 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 = 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, } #[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) -> OfflineTestDisks { let disks = set .disks .read() .await .iter() .map(|disk| disk.clone().expect("fault-injection set should start fully online")) .collect::>(); 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, 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, 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 = 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, 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, 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::>(); 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"; async fn seed_decommission_source( store: &Arc, bucket: &str, object: &str, body: Vec, 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, rx: CancellationToken, bucket: &str, object: &str, expected_bucket_incarnation_id: Option, source_changed_exhaustions: Arc, ) -> 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, bucket: &str, object: &str, opts: &ObjectOptions, ) -> Vec { 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, 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, store: &Arc, 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, 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, body: Vec, multipart_split: Option, mod_time: OffsetDateTime, user_defined: HashMap, } async fn seed_data_movement_source_reader( store: &Arc, 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, 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, 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, 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, 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::>(), retry_source_info .parts .iter() .map(|part| (&part.number, &part.checksums)) .collect::>() ); 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::>(), target_before_retry .parts .iter() .map(|part| (&part.number, &part.checksums)) .collect::>() ); }) .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, mod_time, metadata: HashMap| { 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] #[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 ordinary_faults_for_hook = Arc::clone(&ordinary_faults); let fault_bucket = other_bucket.clone(); let _fault_guard = crate::core::pools::DecommissionTestFaultGuard::install(Arc::new( move |stage, bucket, object, attempt, succeeded| { let candidate = succeeded && stage == DECOMMISSION_TEST_FAULT_STAGE_MIGRATE_OBJECT && bucket == fault_bucket.as_str() && object == other_object; if !candidate { return false; } // Keep the fault budget global across any // entry-level re-list; its inner attempt counter // restarts after SourceChanged. ordinary_faults_for_hook .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| { let next_fault = faults.saturating_add(1); (faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1) && attempt == next_fault) .then_some(next_fault) }) .is_ok() }, )); 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::>(), "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::>(), object_names.iter().map(String::as_str).collect::>(), "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::>(); 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::>(); 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::>(); 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::>() .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()); com::save_config(store.pools[target_pool_idx].clone(), &second_page_path, receipt_bytes.clone()) .await .expect("second page receipt should restore"); com::save_config(store.pools[target_pool_idx].clone(), &second_page_path, b"{corrupt".to_vec()) .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) -> 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) -> 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, tier_name: &str, backend_identity: [u8; 32], count: usize, ) -> Vec { 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, bucket: &str, incarnation: uuid::Uuid, prefix: &str, tier_name: &str, backend_identity: [u8; 32], journal_count: usize, ) -> (String, Vec) { 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) { 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::>(); 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::>(); let entries = aborted_entries.iter().map(|(entry, _)| entry.clone()).collect::>(); 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::>() .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::>() .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::>(); 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::>(); 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![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) -> 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>, 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, 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, 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::>(); 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"Enabled".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"Suspended".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"Enabled".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"Enabled".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"Enabled".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::>(); 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::>(); 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 = 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 ®istered { 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"EnabledCOMPLIANCE1".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"Enabled".to_vec(), ) .await .expect("bucket versioning should be enabled"); crate::bucket::metadata_sys::update_in( &ctx, bucket, BUCKET_LIFECYCLE_CONFIG, br#" delete-all-versions Enabled 1true "# .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(), ¤t, &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>>>); 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")); } }