From f7df4fa62a35a998f09e45cda28d34eb48cdb6b4 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Thu, 13 Aug 2026 03:26:17 +0800 Subject: [PATCH] fix(versioning): reject suspending versioning while a replication config exists (#6006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PutBucketVersioning with Status=Suspended on a bucket that carries a replication configuration now fails with InvalidBucketState, matching AWS S3 and MinIO. Suspension would start minting null versions that the versioned replication engine can never converge — the state is unreachable on AWS and MinIO, and the nightly acceptance-matrix e2e that tried to exercise it failed every night since it landed (issue #5767). The acceptance-matrix test tail now pins the rejection contract (InvalidBucketState) and verifies a fresh matched PUT still replicates with a real version id after the rejected suspension. --- .../src/replication_extension_test.rs | 66 +++++++++++-------- rustfs/src/app/bucket_usecase.rs | 16 +++++ 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/crates/e2e_test/src/replication_extension_test.rs b/crates/e2e_test/src/replication_extension_test.rs index 2734295f3..68c8f5b4b 100644 --- a/crates/e2e_test/src/replication_extension_test.rs +++ b/crates/e2e_test/src/replication_extension_test.rs @@ -4235,37 +4235,49 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR "tag rule with disabled delete-marker replication created a marker: {tagged_state:?}" ); - set_bucket_versioning(&source_env, source_bucket, BucketVersioningStatus::Suspended).await?; - set_bucket_versioning(&target_env_a, target_bucket_a, BucketVersioningStatus::Suspended).await?; - let null_put = source_client + // AWS S3 and MinIO both reject suspending versioning on a bucket that + // carries a replication configuration (InvalidBucketState): suspension + // would mint null versions that versioned replication can never converge. + let suspend_err = source_client + .put_bucket_versioning() + .bucket(source_bucket) + .versioning_configuration( + VersioningConfiguration::builder() + .status(BucketVersioningStatus::Suspended) + .build(), + ) + .send() + .await + .expect_err("suspending versioning on a replication source must be rejected"); + assert_eq!( + suspend_err.as_service_error().and_then(|error| error.code()), + Some("InvalidBucketState"), + "suspension on a replication source must fail with InvalidBucketState: {suspend_err:?}" + ); + + // The rejected suspension must leave the versioning + replication state + // fully intact: a fresh matched PUT still replicates with a real version. + let post_reject_put = source_client .put_object() .bucket(source_bucket) - .key("prefix/null.txt") - .body(ByteStream::from_static(b"null version")) + .key("prefix/after-rejected-suspend.txt") + .body(ByteStream::from_static(b"still replicating")) .send() .await?; - assert!(null_put.version_id().is_none(), "suspended source PUT must create a null version"); - wait_for_replication_state(&target_client_a, target_bucket_a, "null version did not replicate", |state| { - state - .iter() - .any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && !entry.delete_marker) - }) - .await?; - let null_delete = source_client - .delete_object() - .bucket(source_bucket) - .key("prefix/null.txt") - .send() - .await?; - assert!( - null_delete.version_id().is_none(), - "suspended source DELETE must create a null delete marker" - ); - wait_for_replication_state(&target_client_a, target_bucket_a, "null delete marker did not replicate", |state| { - state - .iter() - .any(|entry| entry.key == "prefix/null.txt" && entry.version_id == "null" && entry.delete_marker) - }) + let post_reject_version_id = post_reject_put + .version_id() + .ok_or("PUT after rejected suspension omitted version ID")? + .to_string(); + wait_for_replication_state( + &target_client_a, + target_bucket_a, + "replication stopped after rejected versioning suspension", + |state| { + state + .iter() + .any(|entry| entry.key == "prefix/after-rejected-suspend.txt" && entry.version_id == post_reject_version_id) + }, + ) .await?; Ok(()) diff --git a/rustfs/src/app/bucket_usecase.rs b/rustfs/src/app/bucket_usecase.rs index 8f045dbfe..ec8f07414 100644 --- a/rustfs/src/app/bucket_usecase.rs +++ b/rustfs/src/app/bucket_usecase.rs @@ -738,6 +738,22 @@ async fn validate_bucket_versioning_update(bucket: &str, config: &VersioningConf Err(StorageError::ConfigNotFound) => {} Err(err) => return Err(ApiError::from(err).into()), } + // AWS S3 and MinIO both refuse to suspend versioning while a replication + // configuration exists: suspension would start minting null versions that + // the replication engine (versioned by contract) can never converge. + if config.suspended() { + match metadata_sys::get_replication_config(bucket).await { + Ok(_) => { + return Err(S3Error::with_message( + S3ErrorCode::InvalidBucketState, + "A replication configuration is present on this bucket, bucket wide versioning cannot be suspended." + .to_string(), + )); + } + Err(StorageError::ConfigNotFound) => {} + Err(err) => return Err(ApiError::from(err).into()), + } + } Ok(()) }