// 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::bucket::{ metadata::{BUCKET_TABLE_RESERVED_PREFIX, table_bucket_catalog_metadata_prefix}, utils::is_meta_bucketname, }; use crate::error::is_err_bucket_not_found; use crate::runtime::sources as runtime_sources; use crate::set_disk::get_lock_acquire_timeout; use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp}; use crate::storage_api_contracts::namespace::NamespaceLocking as _; use futures::stream::{self, StreamExt}; use rustfs_policy::policy::BucketPolicy; use std::collections::BTreeMap; use std::future::Future; const DELETED_BUCKETS_PREFIX: &str = ".deleted"; const SCANNER_BUCKET_LIST_SET_CONCURRENCY: usize = 4; fn scanner_bucket_list_set_concurrency(set_count: usize) -> usize { set_count.clamp(1, SCANNER_BUCKET_LIST_SET_CONCURRENCY) } fn should_override_created_from_metadata(created: OffsetDateTime) -> bool { created != OffsetDateTime::UNIX_EPOCH } fn validate_table_bucket_delete_allowed( bucket: &str, table_bucket_enabled: bool, table_catalog_metadata_exists: bool, ) -> Result<()> { if table_bucket_enabled && table_catalog_metadata_exists { return Err(StorageError::BucketNotEmpty(bucket.to_string())); } Ok(()) } async fn table_catalog_metadata_exists(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result { let local_disks = runtime_sources::local_disks_in(ctx).await; for disk in local_disks.iter() { let Some(bucket_path) = disk.get_bucket_path_for_io_if_local(bucket) else { continue; }; let catalog_path = bucket_path?.join(BUCKET_TABLE_RESERVED_PREFIX); if has_xlmeta_files(&catalog_path).await? { return Ok(true); } } Ok(false) } async fn validate_table_bucket_delete_guard(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<()> { let table_bucket_enabled = metadata_sys::get_in(ctx, bucket) .await .is_ok_and(|metadata| metadata.table_bucket_enabled()); if table_bucket_enabled { validate_table_bucket_delete_allowed(bucket, true, table_catalog_metadata_exists(ctx, bucket).await?)?; } Ok(()) } fn bucket_delete_metadata_cleanup_prefixes(bucket: &str) -> [String; 2] { [ table_bucket_catalog_metadata_prefix(bucket), format!("{BUCKET_META_PREFIX}/{bucket}"), ] } fn bucket_deleted_marker_prefix(bucket: &str) -> String { format!("{BUCKET_META_PREFIX}/{DELETED_BUCKETS_PREFIX}/{bucket}") } fn bucket_deleted_marker_volume(bucket: &str) -> String { format!("{RUSTFS_META_BUCKET}/{}", bucket_deleted_marker_prefix(bucket)) } pub(crate) async fn await_bucket_namespace_operation( guard: Option<&rustfs_lock::NamespaceLockGuard>, bucket: &str, operation: &'static str, future: F, ) -> Result where F: Future>, { let Some(guard) = guard else { return future.await; }; if guard.is_lock_lost() { return Err(StorageError::other(format!( "bucket namespace lock was lost before {operation}: {bucket}" ))); } tokio::select! { biased; _ = guard.lock_lost_notified() => Err(StorageError::other(format!( "bucket namespace lock was lost during {operation}: {bucket}" ))), result = future => result, } } async fn await_bucket_lifecycle_operation( lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>, namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>, bucket: &str, operation: &'static str, future: F, ) -> Result where F: Future>, { await_bucket_namespace_operation( lifecycle_guard, bucket, operation, await_bucket_namespace_operation(namespace_guard, bucket, operation, future), ) .await } async fn run_bucket_usage_cleanup(guard: Option<&rustfs_lock::NamespaceLockGuard>, bucket: &str, future: F) -> Result<()> where F: Future>, { await_bucket_namespace_operation(guard, bucket, "bucket usage cleanup", future).await } async fn run_physical_bucket_deletion(guard: Option<&rustfs_lock::NamespaceLockGuard>, bucket: &str, future: F) -> Result<()> where F: Future>, { // Fence before polling deletion: the physical namespace may become // invisible at any await point inside the storage operation. list_objects::observe_scanner_namespace_mutations(bucket, 1); await_bucket_namespace_operation(guard, bucket, "physical bucket deletion", future).await } impl ECStore { pub async fn get_bucket_metadata(&self, bucket: &str) -> Result> { let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?; sys.read().await.get(bucket).await } pub async fn get_bucket_policy(&self, bucket: &str) -> Result<(BucketPolicy, OffsetDateTime)> { let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?; sys.read().await.get_bucket_policy(bucket).await } pub async fn get_bucket_policy_raw(&self, bucket: &str) -> Result<(String, OffsetDateTime)> { let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?; sys.read().await.get_bucket_policy_raw(bucket).await } pub async fn restricts_public_bucket_access(&self, bucket: &str) -> Result { let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?; let (config, _) = sys.read().await.get_public_access_block_config(bucket).await?; Ok(config.restrict_public_buckets.unwrap_or(false)) } pub async fn update_bucket_metadata_config(&self, bucket: &str, config_file: &str, data: Vec) -> Result { metadata_sys::update_in(&self.ctx, bucket, config_file, data).await } pub async fn bucket_incarnation_id(&self, bucket: &str) -> Result { metadata_sys::get_cached_bucket_incarnation_id_in(&self.ctx, bucket).await } pub async fn bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result { metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await } /// The object commit path acquires this sentinel before bucket metadata and /// exact-object namespace locks. pub(crate) async fn acquire_bucket_lifecycle_read_lock(&self, bucket: &str) -> Result { let lock = self.new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT).await?; lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(|err| match err { rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => { StorageError::NamespaceLockQuorumUnavailable { mode: "bucket_lifecycle_read", bucket: bucket.to_string(), object: BUCKET_LIFECYCLE_LOCK_OBJECT.to_string(), required, achieved, } } other => StorageError::Lock(other), }) } /// Acquire the bucket lifecycle read lock and validate the bucket /// incarnation against `expected`, memoizing the validation while this /// node keeps continuous read-lock coverage (see [`super::bucket_fence`]). /// /// Semantics are identical to the pre-existing per-PUT /// `acquire_bucket_lifecycle_read_lock` + from-disk /// `validate_bucket_incarnation` pair: the first PUT in a coverage window /// performs exactly that authoritative disk validation; overlapping PUTs /// reuse its result, which is sound because bucket deletion/recreation /// requires the lifecycle WRITE lock and therefore cannot have run while /// any read guard was continuously held. pub(crate) async fn acquire_bucket_incarnation_fence( &self, bucket: &str, expected: uuid::Uuid, ) -> Result { let inner = self.acquire_bucket_lifecycle_read_lock(bucket).await?; let pieces = super::bucket_fence::FencePieces { registry: self.bucket_fence_registry.clone(), inner, }; let registration = pieces.enter(bucket); let current = match registration.memoized { Some(current) => current, None => match metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await { Ok(current) => { // Never memoize under lost coverage: a granted lifecycle // write lock could already have changed the incarnation. if !pieces.lock_lost() { pieces.memoize(bucket, current); } current } Err(err) => { pieces.abandon(bucket, registration.token); return Err(err); } }, }; if current != expected { pieces.abandon(bucket, registration.token); return Err(StorageError::BucketNotFound(bucket.to_string())); } Ok(pieces.into_guard(bucket, registration.token)) } pub(crate) async fn acquire_bucket_lifecycle_write_lock(&self, bucket: &str) -> Result { let lock = self.new_ns_lock(bucket, BUCKET_LIFECYCLE_LOCK_OBJECT).await?; lock.get_write_lock(get_lock_acquire_timeout()) .await .map_err(|err| match err { rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => { StorageError::NamespaceLockQuorumUnavailable { mode: "bucket_lifecycle_write", bucket: bucket.to_string(), object: BUCKET_LIFECYCLE_LOCK_OBJECT.to_string(), required, achieved, } } other => StorageError::Lock(other), }) } async fn mark_bucket_deleted(&self, bucket: &str) -> Result<()> { let marker_volume = bucket_deleted_marker_volume(bucket); self.peer_sys .make_bucket( marker_volume.as_str(), &MakeBucketOptions { force_create: true, ..Default::default() }, ) .await .map_err(|e| to_object_err(e.into(), vec![bucket]))?; Ok(()) } async fn cleanup_deleted_bucket_metadata( &self, bucket: &str, include_deleted_marker: bool, guard: Option<&rustfs_lock::NamespaceLockGuard>, ) -> Result<()> { for prefix in bucket_delete_metadata_cleanup_prefixes(bucket) { await_bucket_namespace_operation( guard, bucket, "deleted bucket metadata cleanup", self.delete_all(RUSTFS_META_BUCKET, prefix.as_str()), ) .await?; } if include_deleted_marker { let marker_prefix = bucket_deleted_marker_prefix(bucket); await_bucket_namespace_operation( guard, bucket, "deleted bucket marker cleanup", self.delete_all(RUSTFS_META_BUCKET, marker_prefix.as_str()), ) .await?; } await_bucket_namespace_operation( guard, bucket, "deleted bucket metadata cache cleanup", metadata_sys::remove_bucket_metadata_in(&self.ctx, bucket), ) .await?; runtime_sources::delete_bucket_monitor_entry(bucket); Ok(()) } async fn cleanup_bucket_usage(&self, bucket: &str, guard: Option<&rustfs_lock::NamespaceLockGuard>) -> Result<()> { run_bucket_usage_cleanup(guard, bucket, async { crate::data_usage::prepare_bucket_usage_for_namespace_change(bucket, guard).await?; crate::data_usage::remove_bucket_usage_from_backend_with_guard(self, bucket, guard).await }) .await } async fn cleanup_bucket_usage_best_effort(&self, bucket: &str, guard: Option<&rustfs_lock::NamespaceLockGuard>) { if let Err(err) = self.cleanup_bucket_usage(bucket, guard).await { warn!( bucket = %bucket, error = ?err, "bucket data usage cleanup deferred to scanner reconciliation" ); } } async fn rollback_failed_bucket_creation( &self, bucket: &str, lifecycle_guard: Option<&rustfs_lock::NamespaceLockGuard>, namespace_guard: Option<&rustfs_lock::NamespaceLockGuard>, ) { let rollback_opts = DeleteBucketOptions { no_lock: true, no_recreate: true, ..Default::default() }; if let Err(err) = await_bucket_lifecycle_operation(lifecycle_guard, namespace_guard, bucket, "failed bucket creation rollback", async { self.peer_sys .delete_bucket(bucket, &rollback_opts) .await .map_err(|rollback_err| to_object_err(rollback_err.into(), vec![bucket])) }) .await { warn!( bucket = %bucket, error = ?err, "failed bucket creation rollback did not remove every physical bucket volume" ); return; } if let Err(err) = self.cleanup_deleted_bucket_metadata(bucket, false, namespace_guard).await { warn!( bucket = %bucket, error = ?err, "failed bucket creation rollback left internal bucket metadata" ); } } #[instrument(skip(self))] pub(super) async fn handle_make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()> { if !is_meta_bucketname(bucket) && let Err(err) = check_valid_bucket_name_strict(bucket) { return Err(StorageError::BucketNameInvalid(err.to_string())); } // Lock order: bucket lifecycle -> metadata transaction -> exact bucket namespace. let bucket_lifecycle_guard = if !opts.no_lock { Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?) } else { None }; let metadata_transaction_guard = if !opts.no_lock && !is_meta_bucketname(bucket) { Some(metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?) } else { None }; let ns_guard = if !opts.no_lock { let ns_lock = self.new_ns_lock(bucket, bucket).await?; Some( await_bucket_namespace_operation( bucket_lifecycle_guard.as_ref(), bucket, "bucket namespace lock acquisition", async { ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e { rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => { StorageError::NamespaceLockQuorumUnavailable { mode: "write", bucket: bucket.to_string(), object: bucket.to_string(), required, achieved, } } other => StorageError::Lock(other), }) }, ) .await?, ) } else { None }; let existing_bucket_info = match self.peer_sys.get_bucket_info(bucket, &BucketOptions::default()).await { Ok(info) => Some(info), Err(err) => { let err: StorageError = err.into(); if is_err_bucket_not_found(&err) { None } else { return Err(to_object_err(err, vec![bucket])); } } }; let confirmed_missing = existing_bucket_info.is_none(); let existing_metadata = if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) { let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?; if !persisted { metadata = BucketMetadata::new(bucket); metadata.created = existing_bucket_info .as_ref() .and_then(|info| info.created) .unwrap_or(OffsetDateTime::UNIX_EPOCH); } else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() { return Err(Error::other(format!( "bucket incarnation sidecar is missing for new-format metadata: {bucket}" ))); } else if !metadata.bucket_incarnation_sidecar { metadata.bucket_incarnation_id = Uuid::new_v4(); } Some(metadata) } else { None }; let mut meta = existing_metadata.unwrap_or_else(|| { if confirmed_missing && !is_meta_bucketname(bucket) { BucketMetadata::new_with_default_durability(bucket) } else { BucketMetadata::new(bucket) } }); let existing_incarnation_is_authoritative = meta.bucket_incarnation_sidecar; if confirmed_missing || is_meta_bucketname(bucket) { meta.set_created(opts.created_at); if opts.versioning_enabled { meta.versioning_config_xml = crate::bucket::utils::serialize::(&ENABLED_VERSIONING_CONFIG)?; } } // Object Lock enable is one-way, and it must apply to an existing bucket // too. Site replication replays make-with-versioning carrying the // source's lockEnabled against a destination bucket that already exists; // gating this on `confirmed_missing` returned success while leaving the // replica unlocked, so replicated versions could be deleted without the // retention the source enforces. let lock_newly_enabled = opts.lock_enabled && !meta.lock_enabled; if opts.lock_enabled { meta.lock_enabled = true; meta.object_lock_config_xml = crate::bucket::utils::serialize::(&ENABLED_OBJECT_LOCK_CONFIG)?; meta.versioning_config_xml = crate::bucket::utils::serialize::(&ENABLED_VERSIONING_CONFIG)?; } let metadata_persisted_before_physical = confirmed_missing && !is_meta_bucketname(bucket) && opts.lock_enabled; if metadata_persisted_before_physical { metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta.clone()).await?; if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) || metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) || ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) { return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}"))); } } if confirmed_missing && !is_meta_bucketname(bucket) { // Fence every scanner cycle that could have observed the namespace // before physical creation. Creation may become visible even when a // later metadata write or namespace-lock check fails. crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1); self.cleanup_bucket_usage(bucket, ns_guard.as_ref()).await?; } if let Err(err) = await_bucket_lifecycle_operation( bucket_lifecycle_guard.as_ref(), ns_guard.as_ref(), bucket, "physical bucket creation", await_bucket_namespace_operation( metadata_transaction_guard.as_ref(), bucket, "bucket creation metadata transaction", async { self.peer_sys .make_bucket(bucket, opts) .await .map_err(|err| to_object_err(err.into(), vec![bucket])) }, ), ) .await { if is_err_bucket_exists(&err) && let Err(heal_err) = self .handle_heal_bucket( bucket, &HealOpts { recreate: true, ..Default::default() }, ) .await { warn!("best-effort bucket heal after BucketExists failed: {heal_err}"); } if !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost()) { error!("make bucket failed: {err}"); self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref()) .await; } return Err(err); }; let metadata_result = async { if metadata_persisted_before_physical { return Ok(()); } if is_meta_bucketname(bucket) { metadata_sys::set_bucket_metadata_in(&self.ctx, meta).await } else if existing_incarnation_is_authoritative && !lock_newly_enabled { metadata_sys::cache_bucket_metadata_in(&self.ctx, meta).await } else { metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta).await } } .await; let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) || metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) || ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()); if let Err(err) = metadata_result { if !metadata_lock_lost { self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref()) .await; } return Err(err); } if metadata_lock_lost { return Err(Error::other(format!("bucket metadata initialization lock was lost: {bucket}"))); } if confirmed_missing && !is_meta_bucketname(bucket) { // A scanner may have sampled the first fence before the bucket // became visible. Fence again after metadata initialization so // that snapshot cannot publish as complete. crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1); } Ok(()) } #[instrument(skip(self))] pub(super) async fn handle_get_bucket_info(&self, bucket: &str, opts: &BucketOptions) -> Result { let mut info = self.peer_sys.get_bucket_info(bucket, opts).await?; if let Ok(sys) = metadata_sys::get_in(&self.ctx, bucket).await { if should_override_created_from_metadata(sys.created) { info.created = Some(sys.created); } info.versioning = sys.versioning(); info.object_locking = sys.object_locking(); } Ok(info) } #[instrument(skip(self))] pub(super) async fn handle_list_bucket(&self, opts: &BucketOptions) -> Result> { // TODO: opts.cached let mut buckets = self.peer_sys.list_bucket(opts).await?; if !opts.no_metadata { for bucket in buckets.iter_mut() { if let Ok(created) = metadata_sys::created_at_in(&self.ctx, &bucket.name).await && should_override_created_from_metadata(created) { bucket.created = Some(created); } } } Ok(buckets) } pub async fn list_bucket_for_scanner(&self, opts: &BucketOptions) -> Result { let sets = self .pools .iter() .flat_map(|pool| { pool.disk_set .iter() .map(|set| (set.pool_index, set.set_index, Arc::clone(set))) }) .collect::>(); let set_count = sets.len(); let deleted = opts.deleted; let cached = opts.cached; let no_metadata = opts.no_metadata; let mut set_listings = stream::iter(sets.into_iter().map(move |(pool_index, set_index, set)| { let opts = BucketOptions { deleted, cached, no_metadata, }; async move { set.list_bucket_for_scanner(&opts) .await .map(|(buckets, complete)| (pool_index, set_index, buckets, complete)) } })) .buffer_unordered(scanner_bucket_list_set_concurrency(set_count)); let mut topology_complete = set_count != 0; let mut bucket_map = BTreeMap::::new(); let mut scoped_buckets = Vec::with_capacity(set_count); while let Some(set_listing) = set_listings.next().await { let (pool_index, set_index, buckets, set_complete) = set_listing?; topology_complete &= set_complete; for bucket in &buckets { bucket_map.entry(bucket.name.clone()).or_insert_with(|| bucket.clone()); } scoped_buckets.push(crate::cluster::rpc::ScannerSetBucketListing { pool_index, set_index, buckets, }); } scoped_buckets.sort_unstable_by_key(|scope| (scope.pool_index, scope.set_index)); let mut listing = crate::cluster::rpc::ScannerBucketListing { buckets: bucket_map.into_values().collect(), set_buckets: scoped_buckets, topology_complete, }; if !opts.no_metadata { for bucket in &mut listing.buckets { if let Ok(created) = metadata_sys::created_at_in(&self.ctx, &bucket.name).await && should_override_created_from_metadata(created) { bucket.created = Some(created); } } let created_by_bucket = listing .buckets .iter() .map(|bucket| (bucket.name.as_str(), bucket.created)) .collect::>(); for scope in &mut listing.set_buckets { for bucket in &mut scope.buckets { if let Some(created) = created_by_bucket.get(bucket.name.as_str()) { bucket.created = *created; } } } } Ok(listing) } #[instrument(skip(self))] pub(super) async fn handle_delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> { if is_meta_bucketname(bucket) { return Err(StorageError::BucketNameInvalid(bucket.to_string())); } if let Err(err) = check_valid_bucket_name(bucket) { return Err(StorageError::BucketNameInvalid(err.to_string())); } let bucket_lifecycle_guard = if !opts.no_lock { Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?) } else { None }; let ns_guard = if !opts.no_lock { let ns_lock = self.new_ns_lock(bucket, bucket).await?; Some( await_bucket_namespace_operation( bucket_lifecycle_guard.as_ref(), bucket, "bucket namespace lock acquisition", async { ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e { rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => { StorageError::NamespaceLockQuorumUnavailable { mode: "write", bucket: bucket.to_string(), object: bucket.to_string(), required, achieved, } } other => StorageError::Lock(other), }) }, ) .await?, ) } else { None }; let sr_mark_delete = opts.srdelete_op == SRBucketDeleteOp::MarkDelete; let sr_purge = opts.srdelete_op == SRBucketDeleteOp::Purge; let sr_delete = sr_mark_delete || sr_purge; let mut delete_opts = opts.clone(); let bucket_exists = match self.peer_sys.get_bucket_info(bucket, &BucketOptions::default()).await { Ok(_) => true, Err(err) => { let storage_err: StorageError = err.into(); if is_err_strict_volume_not_found(&storage_err) && sr_delete { false } else if is_err_strict_volume_not_found(&storage_err) { return Err(StorageError::BucketNotFound(bucket.to_string())); } else { return Err(to_object_err(storage_err, vec![bucket])); } } }; if bucket_exists { validate_table_bucket_delete_guard(&self.ctx, bucket).await?; // Check bucket is empty before deletion (per S3 API spec) // If bucket is not empty (contains actual objects with xl.meta files) and force // is not set, return BucketNotEmpty error. // Note: Empty directories (left after object deletion) should NOT count as objects. if !opts.force { let local_disks = runtime_sources::local_disks_in(&self.ctx).await; for disk in local_disks.iter() { let Some(bucket_path) = disk.get_bucket_path_for_io_if_local(bucket) else { continue; }; let bucket_path = bucket_path?; if has_xlmeta_files(&bucket_path).await? { return Err(StorageError::BucketNotEmpty(bucket.to_string())); } } delete_opts.force_if_empty = true; } } if sr_delete && !bucket_exists { delete_opts.force_if_empty = true; } if sr_mark_delete { await_bucket_lifecycle_operation( bucket_lifecycle_guard.as_ref(), ns_guard.as_ref(), bucket, "bucket delete marker creation", self.mark_bucket_deleted(bucket), ) .await?; } let delete_result = await_bucket_namespace_operation( bucket_lifecycle_guard.as_ref(), bucket, "physical bucket deletion", run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async { self.peer_sys .delete_bucket(bucket, &delete_opts) .await .map_err(|err| to_object_err(err.into(), vec![bucket])) }), ) .await; if let Err(err) = delete_result && (!sr_delete || !is_err_strict_volume_not_found(&err)) { return Err(err); } self.cleanup_bucket_usage_best_effort(bucket, ns_guard.as_ref()).await; if let Err(err) = self .cleanup_deleted_bucket_metadata(bucket, sr_purge, ns_guard.as_ref()) .await { warn!( bucket = %bucket, error = ?err, "physical bucket deletion succeeded but metadata cleanup remains pending" ); } // A scanner may have sampled the first fence before the physical // namespace disappeared. The completion fence invalidates that scan. crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1); Ok(()) } } #[cfg(test)] mod tests { use super::{ SCANNER_BUCKET_LIST_SET_CONCURRENCY, await_bucket_namespace_operation, bucket_delete_metadata_cleanup_prefixes, bucket_deleted_marker_prefix, bucket_deleted_marker_volume, run_bucket_usage_cleanup, run_physical_bucket_deletion, scanner_bucket_list_set_concurrency, should_override_created_from_metadata, validate_table_bucket_delete_allowed, }; use crate::bucket::metadata::table_bucket_catalog_metadata_prefix; use crate::bucket::metadata_sys; use crate::cluster::rpc::peer_s3_client::install_delete_bucket_empty_scan_barrier; use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET}; use crate::error::StorageError; use crate::object_api::{ObjectOptions, PutObjReader}; use crate::runtime::instance::InstanceContext; use crate::storage_api_contracts::{ bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp}, object::{ObjectIO as _, ObjectOperations as _}, }; use crate::store::{ECStore, init_local_disks_with_instance_ctx}; use crate::{ disk::endpoint::Endpoint, layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints}, }; use rustfs_data_usage::{BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DataUsageInfo}; use rustfs_lock::{LocalClient, LockRequest, LockType, NamespaceLock, ObjectKey}; use serial_test::serial; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime}; use time::OffsetDateTime; use tokio::sync::{Notify, OnceCell}; use tokio_util::sync::CancellationToken; use uuid::Uuid; static BUCKET_DELETE_TEST_ENV: OnceCell<(Vec, Arc)> = OnceCell::const_new(); #[tokio::test(start_paused = true)] async fn bucket_namespace_operation_fails_closed_after_lease_expiry() { let ttl = Duration::from_millis(20); let lock = NamespaceLock::new("bucket-operation-test".to_string(), Arc::new(LocalClient::new())); let request = LockRequest::new(ObjectKey::new("bucket", ""), LockType::Exclusive, "test-owner") .with_acquire_timeout(Duration::from_secs(1)) .with_ttl(ttl) .with_refresh_interval(ttl); let guard = lock .acquire_guard(&request) .await .expect("namespace lock acquisition should not fail") .expect("namespace lock should be acquired"); tokio::time::advance(ttl + Duration::from_millis(1)).await; let operation_ran = Arc::new(AtomicBool::new(false)); let operation_ran_for_future = operation_ran.clone(); let result = await_bucket_namespace_operation(Some(&guard), "bucket", "test operation", async move { operation_ran_for_future.store(true, Ordering::SeqCst); Ok(()) }) .await; assert!(result.is_err(), "an expired namespace lease must fence the operation"); assert!( !operation_ran.load(Ordering::SeqCst), "a fenced namespace operation must not poll its mutation future" ); } #[tokio::test] #[serial] async fn physical_bucket_delete_fences_scanner_before_polling_storage() { let generation_before = crate::store::list_objects::scanner_namespace_mutation_generation(); let storage_polled = Arc::new(AtomicBool::new(false)); let storage_polled_for_future = storage_polled.clone(); run_physical_bucket_deletion(None, "generation-order-bucket", async move { assert!( crate::store::list_objects::scanner_namespace_mutation_generation() > generation_before, "scanner generation must advance before physical deletion is polled" ); storage_polled_for_future.store(true, Ordering::SeqCst); Ok(()) }) .await .expect("synthetic physical deletion should succeed"); assert!(storage_polled.load(Ordering::SeqCst)); } #[tokio::test] async fn bucket_usage_cleanup_stops_after_parent_cancellation() { let started = Arc::new(Notify::new()); let started_wait = started.notified(); let release = Arc::new(Notify::new()); let completed = Arc::new(AtomicBool::new(false)); let started_for_cleanup = started.clone(); let release_for_cleanup = release.clone(); let completed_for_cleanup = completed.clone(); let parent = tokio::spawn(run_bucket_usage_cleanup(None, "bucket", async move { started_for_cleanup.notify_one(); release_for_cleanup.notified().await; completed_for_cleanup.store(true, Ordering::SeqCst); Ok(()) })); started_wait.await; parent.abort(); let _ = parent.await; tokio::task::yield_now().await; release.notify_waiters(); tokio::task::yield_now().await; assert!( !completed.load(Ordering::SeqCst), "a cancelled cleanup future must not continue in a detached task" ); } #[tokio::test(start_paused = true)] #[serial] async fn bucket_namespace_operation_stops_in_flight_work_after_lock_loss() { let ttl = Duration::from_millis(20); let lock = NamespaceLock::new("bucket-operation-in-flight-loss-test".to_string(), Arc::new(LocalClient::new())); let request = LockRequest::new(ObjectKey::new("bucket", ""), LockType::Exclusive, "test-owner") .with_acquire_timeout(Duration::from_secs(1)) .with_ttl(ttl) .with_refresh_interval(ttl); let guard = lock .acquire_guard(&request) .await .expect("namespace lock acquisition should not fail") .expect("namespace lock should be acquired"); let operation_started = Arc::new(Notify::new()); let started_wait = operation_started.notified(); let operation_release = Arc::new(Notify::new()); let operation_completed = Arc::new(AtomicBool::new(false)); let started_for_operation = operation_started.clone(); let release_for_operation = operation_release.clone(); let completed_for_operation = operation_completed.clone(); let task = tokio::spawn(async move { await_bucket_namespace_operation(Some(&guard), "bucket", "test operation", async move { started_for_operation.notify_one(); release_for_operation.notified().await; completed_for_operation.store(true, Ordering::SeqCst); Ok(()) }) .await }); started_wait.await; tokio::time::advance(ttl + Duration::from_millis(1)).await; let err = task .await .expect("operation task should join") .expect_err("an operation still running after lease loss must be fenced"); assert!(err.to_string().contains("namespace lock was lost during test operation")); operation_release.notify_waiters(); tokio::task::yield_now().await; assert!( !operation_completed.load(Ordering::SeqCst), "lock loss must stop the old owner before a successor can acquire the namespace" ); } async fn setup_bucket_delete_test_env() -> (Vec, Arc) { BUCKET_DELETE_TEST_ENV .get_or_init(|| async { let temp_dir = std::env::temp_dir().join(format!("rustfs_bucket_delete_test_{}", Uuid::new_v4())); tokio::fs::create_dir_all(&temp_dir) .await .expect("test base directory should be created"); let disk_paths = (0..4) .map(|disk_idx| temp_dir.join(format!("disk{disk_idx}"))) .collect::>(); for disk_path in &disk_paths { tokio::fs::create_dir_all(disk_path) .await .expect("disk directory should be created"); } let mut endpoints = Vec::with_capacity(disk_paths.len()); for (disk_idx, disk_path) in disk_paths.iter().enumerate() { let mut endpoint = Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8")).expect("endpoint should parse"); endpoint.set_pool_index(0); endpoint.set_set_index(0); endpoint.set_disk_index(disk_idx); endpoints.push(endpoint); } let endpoint_pools = EndpointServerPools(vec![PoolEndpoints { legacy: false, set_count: 1, drives_per_set: 4, endpoints: Endpoints::from(endpoints), cmd_line: "bucket-delete-test".to_string(), platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH), }]); let instance_ctx = Arc::new(InstanceContext::new()); init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone()) .await .expect("local disks should initialize"); let ecstore = ECStore::new_with_instance_ctx( "127.0.0.1:0".parse().expect("test address"), endpoint_pools, CancellationToken::new(), instance_ctx, ) .await .expect("ECStore should initialize"); let storage_class = crate::config::storageclass::lookup_config_for_pools_without_env( &rustfs_config::server_config::KVS::new(), &[4], ) .expect("bucket test storage class should match its four-disk pool"); for pool in &ecstore.pools { for set in &pool.disk_set { set.set_test_storage_class_config(storage_class.clone()); } } metadata_sys::init_bucket_metadata_sys(ecstore.clone(), Vec::new()).await; (disk_paths, ecstore) }) .await .clone() } async fn setup_multi_pool_scanner_listing_test_env() -> (tempfile::TempDir, Arc) { let temp_dir = tempfile::tempdir().expect("multi-pool scanner test directory should be created"); let mut pools = Vec::new(); for pool_index in 0..2 { let mut endpoints = Vec::new(); for disk_index in 0..4 { let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}")); tokio::fs::create_dir_all(&disk_path) .await .expect("multi-pool scanner test disk should be created"); let mut endpoint = Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8")).expect("endpoint should parse"); endpoint.set_pool_index(pool_index); endpoint.set_set_index(0); endpoint.set_disk_index(disk_index); endpoints.push(endpoint); } pools.push(PoolEndpoints { legacy: false, set_count: 1, drives_per_set: 4, endpoints: Endpoints::from(endpoints), cmd_line: format!("scanner-listing-pool-{pool_index}"), platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH), }); } let endpoint_pools = EndpointServerPools(pools); let instance_ctx = Arc::new(InstanceContext::new()); init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone()) .await .expect("multi-pool local disks should initialize"); let ecstore = ECStore::new_with_instance_ctx( "127.0.0.1:0".parse().expect("test address"), endpoint_pools, CancellationToken::new(), instance_ctx, ) .await .expect("multi-pool ECStore should initialize"); let storage_class = crate::config::storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[4, 4]) .expect("multi-pool storage class should match both four-disk pools"); for pool in &ecstore.pools { for set in &pool.disk_set { set.set_test_storage_class_config(storage_class.clone()); } } (temp_dir, ecstore) } #[tokio::test] async fn request_metadata_methods_fail_closed_before_instance_initialization() { let (_temp_dir, store) = setup_multi_pool_scanner_listing_test_env().await; let expected = "bucket metadata sys not initialized for this instance"; let errors = [ store.get_bucket_metadata("bucket").await.unwrap_err(), store.get_bucket_policy("bucket").await.unwrap_err(), store.get_bucket_policy_raw("bucket").await.unwrap_err(), store.restricts_public_bucket_access("bucket").await.unwrap_err(), store .update_bucket_metadata_config("bucket", crate::bucket::metadata::BUCKET_POLICY_CONFIG, Vec::new()) .await .unwrap_err(), ]; for error in errors { assert_eq!(error.to_string(), format!("Io error: {expected}")); } } async fn create_bucket_with_object(ecstore: &Arc, bucket: &str, object: &str) { let generation_before_make = ecstore.scanner_namespace_mutation_generation(); ecstore .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); assert_eq!( ecstore.scanner_namespace_mutation_generation(), generation_before_make.saturating_add(2), "successful bucket creation should fence scanner namespace activity before and after creation" ); let generation_before_put = ecstore.scanner_namespace_mutation_generation(); let mut reader = PutObjReader::from_vec(b"delete bucket semantics".to_vec()); ecstore .put_object(bucket, object, &mut reader, &ObjectOptions::default()) .await .expect("object should be written"); assert_eq!( ecstore.scanner_namespace_mutation_generation(), generation_before_put.saturating_add(1), "successful object creation should advance scanner namespace activity" ); ecstore .get_object_info(bucket, object, &ObjectOptions::default()) .await .expect("object should be readable before bucket delete"); } async fn any_disk_path_exists(disk_paths: &[PathBuf], relative_path: impl AsRef) -> bool { for disk_path in disk_paths { if tokio::fs::try_exists(disk_path.join(relative_path.as_ref())) .await .expect("test disk path should be stat-able") { return true; } } false } async fn any_disk_has_object_metadata(disk_paths: &[PathBuf], bucket: &str) -> bool { for disk_path in disk_paths { if super::has_xlmeta_files(&disk_path.join(bucket)) .await .expect("object metadata scan should succeed") { return true; } } false } async fn write_bucket_metadata_marker(disk_paths: &[PathBuf], metadata_prefix: &str) { for disk_path in disk_paths { let marker_path = disk_path.join(metadata_prefix).join("config.json"); tokio::fs::create_dir_all(marker_path.parent().expect("metadata marker path should have a parent")) .await .expect("metadata marker parent should be created"); tokio::fs::write(marker_path, b"bucket metadata") .await .expect("metadata marker should be written"); } } #[test] fn should_not_override_when_metadata_created_is_unix_epoch() { assert!(!should_override_created_from_metadata(OffsetDateTime::UNIX_EPOCH)); } #[test] fn should_override_when_metadata_created_is_valid_time() { let created = OffsetDateTime::from_unix_timestamp(1704067200).expect("valid timestamp"); assert!(should_override_created_from_metadata(created)); } #[test] fn table_bucket_delete_guard_rejects_remaining_catalog_metadata() { let err = validate_table_bucket_delete_allowed("table-bucket", true, true).unwrap_err(); assert!(matches!(err, StorageError::BucketNotEmpty(bucket) if bucket == "table-bucket")); assert!(validate_table_bucket_delete_allowed("table-bucket", true, false).is_ok()); assert!(validate_table_bucket_delete_allowed("regular-bucket", false, true).is_ok()); } #[test] fn bucket_delete_metadata_cleanup_removes_internal_table_catalog_prefix() { let prefixes = bucket_delete_metadata_cleanup_prefixes("analytics"); assert!(prefixes.contains(&table_bucket_catalog_metadata_prefix("analytics"))); assert!(prefixes.contains(&"buckets/analytics".to_string())); } #[test] fn bucket_delete_marker_path_uses_internal_deleted_bucket_metadata_prefix() { assert_eq!(bucket_deleted_marker_prefix("analytics"), "buckets/.deleted/analytics"); assert_eq!( bucket_deleted_marker_volume("analytics"), format!("{RUSTFS_META_BUCKET}/{BUCKET_META_PREFIX}/.deleted/analytics") ); } #[test] fn scanner_bucket_listing_bounds_set_fanout() { assert_eq!(scanner_bucket_list_set_concurrency(0), 1); assert_eq!(scanner_bucket_list_set_concurrency(2), 2); assert_eq!(scanner_bucket_list_set_concurrency(100), SCANNER_BUCKET_LIST_SET_CONCURRENCY); } #[tokio::test] #[serial] async fn scanner_bucket_listing_unions_every_erasure_set() { let (_temp_dir, ecstore) = setup_multi_pool_scanner_listing_test_env().await; let bucket = format!("second-pool-only-{}", Uuid::new_v4().simple()); ecstore.pools[1].disk_set[0] .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created in the second pool only"); let listing = ecstore .list_bucket_for_scanner(&crate::storage_api_contracts::bucket::BucketOptions { no_metadata: true, ..Default::default() }) .await .expect("scanner should enumerate every pool and set"); assert!(listing.topology_complete); assert!(listing.buckets.iter().any(|entry| entry.name == bucket)); assert_eq!(listing.set_buckets.len(), 2); assert!( listing .set_buckets .iter() .find(|scope| scope.pool_index == 0 && scope.set_index == 0) .is_some_and(|scope| scope.buckets.is_empty()) ); assert!( listing .set_buckets .iter() .find(|scope| scope.pool_index == 1 && scope.set_index == 0) .is_some_and(|scope| scope.buckets.iter().any(|entry| entry.name == bucket)) ); } #[tokio::test] async fn scanner_bucket_listing_marks_degraded_set_incomplete() { let (_temp_dir, ecstore) = setup_multi_pool_scanner_listing_test_env().await; let bucket = format!("degraded-set-{}", Uuid::new_v4().simple()); let set = &ecstore.pools[0].disk_set[0]; set.make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created before a disk is removed"); set.disks.write().await[0] = None; let listing = ecstore .list_bucket_for_scanner(&crate::storage_api_contracts::bucket::BucketOptions { no_metadata: true, ..Default::default() }) .await .expect("a degraded set with quorum should still return candidate buckets"); assert!(listing.buckets.iter().any(|entry| entry.name == bucket)); assert!(!listing.topology_complete); } #[tokio::test] async fn scanner_bucket_listing_marks_divergent_disk_views_incomplete() { let (temp_dir, ecstore) = setup_multi_pool_scanner_listing_test_env().await; let bucket = format!("divergent-set-{}", Uuid::new_v4().simple()); ecstore.pools[0].disk_set[0] .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created before disk views diverge"); for disk_index in 0..2 { tokio::fs::remove_dir_all(temp_dir.path().join(format!("pool0-disk{disk_index}")).join(&bucket)) .await .expect("test bucket directory should be removed from a minority disk view"); } let listing = ecstore .list_bucket_for_scanner(&crate::storage_api_contracts::bucket::BucketOptions { no_metadata: true, ..Default::default() }) .await .expect("responsive disks should still produce a scanner candidate listing"); assert!(listing.buckets.iter().all(|entry| entry.name != bucket)); assert!(!listing.topology_complete); } // These tests share one isolated instance and mutate its bucket metadata; // serialize them so their assertions cannot observe each other's operations. #[tokio::test] #[serial] async fn bucket_delete_mark_delete_removes_empty_bucket_and_keeps_deleted_marker() { let (disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-mark-delete-{}", Uuid::new_v4().simple()); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); for disk_path in &disk_paths { tokio::fs::create_dir_all(disk_path.join(&bucket).join("empty-directory/nested/leaf")) .await .expect("empty directory remnant should be created"); } assert!(metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_ok()); let generation_before_delete = ecstore.scanner_namespace_mutation_generation(); ecstore .delete_bucket( &bucket, &DeleteBucketOptions { srdelete_op: SRBucketDeleteOp::MarkDelete, ..Default::default() }, ) .await .expect("MarkDelete should remove an empty bucket"); assert_eq!( ecstore.scanner_namespace_mutation_generation(), generation_before_delete.saturating_add(2), "successful bucket deletion should fence scanner namespace activity before and after deletion" ); assert!( !any_disk_path_exists(&disk_paths, &bucket).await, "MarkDelete should remove the bucket volume" ); assert!( any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await, "MarkDelete should persist the deleted-bucket marker" ); assert!( metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_err(), "deleted bucket metadata must be removed from the local cache" ); let buckets = ecstore .list_bucket(&BucketOptions::default()) .await .expect("bucket listing should succeed after MarkDelete"); assert!(!buckets.iter().any(|info| info.name == bucket)); ecstore .delete_all(RUSTFS_META_BUCKET, &bucket_deleted_marker_prefix(&bucket)) .await .expect("deleted-bucket marker should be removed to simulate a partial failure"); assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await); ecstore .delete_bucket( &bucket, &DeleteBucketOptions { srdelete_op: SRBucketDeleteOp::MarkDelete, ..Default::default() }, ) .await .expect("retried MarkDelete should recreate a missing tombstone"); assert!(any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await); } #[tokio::test] #[serial] async fn bucket_delete_mark_delete_rejects_non_empty_bucket_without_force() { let (disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-mark-delete-non-empty-{}", Uuid::new_v4().simple()); create_bucket_with_object(&ecstore, &bucket, "object.txt").await; let err = ecstore .delete_bucket( &bucket, &DeleteBucketOptions { srdelete_op: SRBucketDeleteOp::MarkDelete, ..Default::default() }, ) .await .expect_err("MarkDelete should reject a non-empty bucket without force"); assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket)); assert!(any_disk_has_object_metadata(&disk_paths, &bucket).await); assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await); } #[tokio::test] #[serial] async fn bucket_delete_mark_delete_rejects_hidden_object_paths_without_force() { let (disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-mark-delete-hidden-{}", Uuid::new_v4().simple()); create_bucket_with_object(&ecstore, &bucket, ".well-known/acme-challenge").await; let mut reader = PutObjReader::from_vec(b"delete bucket semantics".to_vec()); ecstore .put_object(&bucket, ".rustfs.sys/object", &mut reader, &ObjectOptions::default()) .await .expect("second hidden object should be written"); let err = ecstore .delete_bucket( &bucket, &DeleteBucketOptions { srdelete_op: SRBucketDeleteOp::MarkDelete, ..Default::default() }, ) .await .expect_err("MarkDelete should reject a hidden object path without force"); assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket)); assert!(any_disk_has_object_metadata(&disk_paths, &bucket).await); } #[tokio::test] #[serial] async fn bucket_delete_purge_removes_bucket_data_and_internal_metadata() { let (disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-purge-{}", Uuid::new_v4().simple()); let object = "object.txt"; let metadata_prefix = format!("{RUSTFS_META_BUCKET}/{BUCKET_META_PREFIX}/{bucket}"); create_bucket_with_object(&ecstore, &bucket, object).await; write_bucket_metadata_marker(&disk_paths, &metadata_prefix).await; ecstore .mark_bucket_deleted(&bucket) .await .expect("deleted-bucket marker should be created"); assert!(any_disk_path_exists(&disk_paths, &metadata_prefix).await); assert!(any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await); let generation_before_delete = ecstore.scanner_namespace_mutation_generation(); ecstore .delete_bucket( &bucket, &DeleteBucketOptions { force: true, srdelete_op: SRBucketDeleteOp::Purge, ..Default::default() }, ) .await .expect("Purge should force-delete bucket data"); assert_eq!( ecstore.scanner_namespace_mutation_generation(), generation_before_delete.saturating_add(2), "successful bucket purge should fence scanner namespace activity before and after deletion" ); assert!(!any_disk_path_exists(&disk_paths, &bucket).await, "Purge should remove the bucket volume"); assert!( !any_disk_path_exists(&disk_paths, &metadata_prefix).await, "Purge should remove bucket metadata prefix" ); assert!( !any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await, "Purge should remove the deleted-bucket marker" ); assert!( metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_err(), "purged bucket metadata must be removed from the local cache" ); write_bucket_metadata_marker(&disk_paths, &metadata_prefix).await; ecstore .mark_bucket_deleted(&bucket) .await .expect("stale deleted-bucket marker should be recreated"); ecstore .delete_bucket( &bucket, &DeleteBucketOptions { force: true, srdelete_op: SRBucketDeleteOp::Purge, ..Default::default() }, ) .await .expect("retried Purge should remove stale metadata without a bucket volume"); assert!(!any_disk_path_exists(&disk_paths, &metadata_prefix).await); assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await); } /// Site replication replays make-with-versioning carrying the source's /// `lockEnabled` against a destination bucket that already exists. Gating the /// lock enable on `confirmed_missing` returned success while leaving the /// replica unlocked, so replicated versions could be deleted without the /// retention the source enforces. #[tokio::test(flavor = "multi_thread")] #[serial] async fn force_create_enables_object_lock_on_an_existing_bucket() { let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-force-lock-{}", Uuid::new_v4().simple()); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("plain bucket should be created"); assert!( !metadata_sys::get_in(&ecstore.ctx, &bucket) .await .expect("metadata should load") .lock_enabled, "test setup: the bucket must start unlocked" ); ecstore .make_bucket( &bucket, &MakeBucketOptions { force_create: true, lock_enabled: true, ..Default::default() }, ) .await .expect("force create with lock_enabled should succeed on an existing bucket"); let meta = metadata_sys::get_in(&ecstore.ctx, &bucket) .await .expect("metadata should load after the lock enable"); assert!(meta.lock_enabled, "Object Lock must be enabled on the existing bucket"); assert!( !meta.object_lock_config_xml.is_empty(), "the Object Lock configuration must be persisted, not just the flag" ); assert!( !meta.versioning_config_xml.is_empty(), "Object Lock requires versioning, so that must be persisted too" ); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn make_bucket_seeds_new_bucket_durability_override() { temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, None::<&str>)], async { let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-default-durability-{}", Uuid::new_v4().simple()); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("new bucket should be created"); let metadata = metadata_sys::get_in(&ecstore.ctx, &bucket) .await .expect("metadata should load for the new bucket"); assert_eq!( metadata.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(), Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED) ); }) .await; } #[tokio::test(flavor = "multi_thread")] #[serial] async fn force_create_existing_bucket_keeps_durability_override() { let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-force-durability-{}", Uuid::new_v4().simple()); temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, Some("inherit"))], async { ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("plain bucket should be created without a durability override"); }) .await; assert!( metadata_sys::get_in(&ecstore.ctx, &bucket) .await .expect("metadata should load after initial create") .durability_config() .is_none(), "test setup: the existing bucket must start without an override" ); temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, None::<&str>)], async { ecstore .make_bucket( &bucket, &MakeBucketOptions { force_create: true, lock_enabled: true, ..Default::default() }, ) .await .expect("force create should update existing bucket metadata"); }) .await; let metadata = metadata_sys::get_in(&ecstore.ctx, &bucket) .await .expect("metadata should load after force create"); assert!(metadata.lock_enabled, "force create sanity check: Object Lock should be enabled"); assert!( metadata.durability_config().is_none(), "force create must not apply the new-bucket default to existing bucket metadata" ); } /// `DeleteBucket`'s emptiness check is a raw disk scan (`has_xlmeta_files`), /// not an S3-level listing, so "the client drained the bucket" and "the /// bucket is deletable" are two different contracts. Nothing pinned the /// second one, which is how the s3-tests lane ended up failing 219 cases on /// `nuke_prefixed_buckets` while every test body passed. #[tokio::test(flavor = "multi_thread")] #[serial] async fn bucket_delete_succeeds_after_the_last_object_version_is_deleted() { let (disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-delete-after-drain-{}", Uuid::new_v4().simple()); let object = "object.txt"; create_bucket_with_object(&ecstore, &bucket, object).await; ecstore .delete_object(&bucket, object, ObjectOptions::default()) .await .expect("client delete of the only object should succeed"); assert!( !any_disk_has_object_metadata(&disk_paths, &bucket).await, "deleting the last version must not leave xl.meta on disk: DeleteBucket scans the raw \ bucket directory, so residue here is reported to clients as BucketNotEmpty" ); ecstore .delete_bucket(&bucket, &DeleteBucketOptions::default()) .await .expect("DeleteBucket must succeed once the client has drained the bucket"); } #[tokio::test] #[serial] async fn bucket_delete_default_s3_delete_still_rejects_non_empty_bucket() { let (disk_paths, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-s3-delete-{}", Uuid::new_v4().simple()); let object = "object.txt"; create_bucket_with_object(&ecstore, &bucket, object).await; let generation_before_delete = ecstore.scanner_namespace_mutation_generation(); let err = ecstore .delete_bucket(&bucket, &DeleteBucketOptions::default()) .await .expect_err("default S3 DeleteBucket should reject non-empty buckets"); assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket)); assert_eq!( ecstore.scanner_namespace_mutation_generation(), generation_before_delete, "failed bucket deletion must not advance scanner namespace activity" ); assert!( any_disk_has_object_metadata(&disk_paths, &bucket).await, "failed default S3 DeleteBucket must keep object data" ); assert!( metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_ok(), "failed default S3 DeleteBucket must keep metadata cache" ); } #[tokio::test] #[serial] async fn bucket_delete_fences_put_started_after_empty_scan() { let (_, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-delete-empty-scan-race-{}", Uuid::new_v4().simple()); let object = "committed-after-empty-scan"; let payload = b"object started after DeleteBucket empty scan".to_vec(); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let barrier = install_delete_bucket_empty_scan_barrier(); let delete_store = ecstore.clone(); let delete_bucket = bucket.clone(); let delete = tokio::spawn(async move { delete_store .delete_bucket(&delete_bucket, &DeleteBucketOptions::default()) .await }); barrier.wait_until_paused().await; let put_store = ecstore.clone(); let put_bucket = bucket.clone(); let mut put = tokio::spawn(async move { let mut put_reader = PutObjReader::from_vec(payload); put_store .put_object(&put_bucket, object, &mut put_reader, &ObjectOptions::default()) .await }); assert!( tokio::time::timeout(Duration::from_millis(100), &mut put).await.is_err(), "PUT must wait behind the DeleteBucket lifecycle fence" ); barrier.release(); delete .await .expect("DeleteBucket task should join") .expect("DeleteBucket should commit while holding the lifecycle fence"); let err = put .await .expect("PUT task should join") .expect_err("PUT must not recreate an object in the deleted bucket"); assert!(matches!(err, StorageError::BucketNotFound(name) if name == bucket)); } #[tokio::test] #[serial] async fn bucket_recreation_does_not_publish_unverified_usage() { let (_, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-usage-generation-{}", Uuid::new_v4().simple()); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut snapshot = DataUsageInfo { last_update: Some(SystemTime::now()), buckets_count: 1, ..Default::default() }; snapshot.buckets_usage.insert( bucket.clone(), BucketUsageInfo { size: 42, objects_count: 1, versions_count: 1, ..Default::default() }, ); snapshot.usage_snapshot_complete = true; snapshot.bucket_sizes.insert(bucket.clone(), 42); snapshot.calculate_totals(); crate::data_usage::store_data_usage_in_backend(snapshot, ecstore.clone()) .await .expect("usage snapshot should be stored"); ecstore .delete_bucket(&bucket, &DeleteBucketOptions::default()) .await .expect("empty bucket should be deleted"); let deleted = crate::data_usage::load_data_usage_from_backend(ecstore.clone()) .await .expect("usage snapshot should remain readable"); assert!(!deleted.buckets_usage.contains_key(&bucket)); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("same bucket name should be recreated after delete returns"); crate::data_usage::record_bucket_object_write_memory(&bucket, None, 84).await; let mut recreated = crate::data_usage::load_data_usage_from_backend(ecstore.clone()) .await .expect("recreated bucket usage base should load"); crate::data_usage::apply_bucket_usage_memory_overlay(&mut recreated).await; assert!( !recreated.buckets_usage.contains_key(&bucket), "a request-path delta without an authoritative baseline must remain unavailable" ); assert_eq!(crate::data_usage::get_bucket_usage_memory(&bucket).await, None); } #[tokio::test] #[serial] async fn bucket_create_removes_stale_usage_before_physical_creation() { let (_, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-create-stale-usage-{}", Uuid::new_v4().simple()); let mut snapshot = DataUsageInfo { last_update: Some(SystemTime::now()), buckets_count: 1, ..Default::default() }; snapshot.buckets_usage.insert( bucket.clone(), BucketUsageInfo { size: 42, objects_count: 1, versions_count: 1, ..Default::default() }, ); snapshot.usage_snapshot_complete = true; snapshot.bucket_sizes.insert(bucket.clone(), 42); snapshot.calculate_totals(); crate::data_usage::store_data_usage_in_backend(snapshot, ecstore.clone()) .await .expect("stale usage fixture should be stored"); ecstore .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("CreateBucket should succeed"); let persisted = crate::data_usage::load_data_usage_from_backend(ecstore.clone()) .await .expect("usage snapshot should remain readable"); assert!( !persisted.buckets_usage.contains_key(&bucket), "a newly created bucket must not inherit the predecessor generation's usage" ); assert!( ecstore.get_bucket_info(&bucket, &BucketOptions::default()).await.is_ok(), "physical creation should happen after the usage fence succeeds" ); } #[tokio::test] #[serial] async fn failed_create_rollback_does_not_run_unfenced_usage_cleanup() { let (_, ecstore) = setup_bucket_delete_test_env().await; let bucket = format!("bucket-create-rollback-{}", Uuid::new_v4().simple()); ecstore .peer_sys .make_bucket(&bucket, &MakeBucketOptions::default()) .await .expect("the partial-create fixture should expose a physical bucket"); let mut snapshot = DataUsageInfo { last_update: Some(SystemTime::now()), buckets_count: 1, ..Default::default() }; snapshot.buckets_usage.insert( bucket.clone(), BucketUsageInfo { size: 42, objects_count: 1, versions_count: 1, ..Default::default() }, ); snapshot.usage_snapshot_complete = true; snapshot.bucket_sizes.insert(bucket.clone(), 42); snapshot.calculate_totals(); crate::data_usage::store_data_usage_in_backend(snapshot, ecstore.clone()) .await .expect("the usage fixture should be stored"); crate::bucket::metadata::save_bucket_incarnation(ecstore.clone(), &bucket, Uuid::new_v4()) .await .expect("partial create should have an incarnation sidecar"); ecstore.rollback_failed_bucket_creation(&bucket, None, None).await; assert!( ecstore .peer_sys .get_bucket_info(&bucket, &BucketOptions::default()) .await .is_err(), "failed-create rollback should remove the partial physical bucket" ); assert!( crate::bucket::metadata::load_bucket_incarnation(ecstore.clone(), &bucket) .await .expect("rollback sidecar lookup should succeed") .is_none(), "failed-create rollback must remove the orphan incarnation sidecar" ); let persisted = crate::data_usage::load_data_usage_from_backend(ecstore.clone()) .await .expect("the usage snapshot should remain readable"); assert!( persisted.buckets_usage.contains_key(&bucket), "failed-create rollback must not start an unfenced usage cleanup" ); crate::data_usage::store_data_usage_in_backend(DataUsageInfo::default(), ecstore.clone()) .await .expect("the rollback usage fixture should be cleared"); } #[tokio::test] #[serial] async fn bucket_create_fails_closed_when_usage_snapshot_cannot_be_fenced() { let (_, ecstore) = setup_bucket_delete_test_env().await; let deleted_bucket = format!("bucket-delete-corrupt-usage-{}", Uuid::new_v4().simple()); ecstore .make_bucket(&deleted_bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created before corrupting usage"); let usage_path = format!("{BUCKET_META_PREFIX}/{DATA_USAGE_OBJECT_NAME}"); crate::config::com::save_config(ecstore.clone(), &usage_path, b"{".to_vec()) .await .expect("corrupt usage fixture should be stored"); ecstore .delete_bucket(&deleted_bucket, &DeleteBucketOptions::default()) .await .expect("usage snapshot corruption must not block DeleteBucket"); assert!( ecstore .get_bucket_info(&deleted_bucket, &crate::storage_api_contracts::bucket::BucketOptions::default()) .await .is_err(), "successful DeleteBucket must remove the physical bucket" ); let new_bucket = format!("bucket-create-corrupt-usage-{}", Uuid::new_v4().simple()); let create_err = ecstore .make_bucket(&new_bucket, &MakeBucketOptions::default()) .await .expect_err("MakeBucket must fail when stale usage cannot be fenced"); assert!(!create_err.to_string().is_empty(), "the usage snapshot failure should be surfaced"); assert!( ecstore .get_bucket_info(&new_bucket, &crate::storage_api_contracts::bucket::BucketOptions::default()) .await .is_err(), "failed MakeBucket must not expose a new physical bucket" ); let restored = serde_json::to_vec(&DataUsageInfo { last_update: Some(SystemTime::now()), ..Default::default() }) .expect("restored usage fixture should encode"); crate::config::com::save_config(ecstore, &usage_path, restored) .await .expect("usage fixture should be restored after the failure-path test"); } }