fix(replication): refresh targets after site endpoint edits (#4756)

* fix(replication): refresh targets after site endpoint edits

* fix(replication): serialize site bucket lifecycle
This commit is contained in:
Zhengchao An
2026-07-12 05:03:17 +08:00
committed by GitHub
parent af831bde4b
commit 676f2276b4
10 changed files with 1548 additions and 237 deletions
-6
View File
@@ -101,12 +101,6 @@ retries = 2
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
retries = 2
# QUARANTINE: OPEN rustfs#4740 — the embedded S3 test intermittently observes
# BucketNotEmpty immediately after an acknowledged unversioned object delete.
[[profile.ci.overrides]]
filter = 'package(rustfs) & binary(embedded_test) & test(test_embedded_server_basic_s3_operations)'
retries = 2
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
# profile too (see the e2e-reliability test-group note near the top). Not a
# quarantine: no retries, just single-threaded so several 4-disk servers never
+247 -36
View File
@@ -405,6 +405,14 @@ async fn delete_bucket_replication(
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn get_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication", env.url);
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let client = env.create_s3_client();
client
@@ -1315,6 +1323,29 @@ async fn wait_for_site_replication_disabled(
wait_for_site_replication_info(env, |info| !info.enabled && info.sites.is_empty()).await
}
async fn assert_site_replication_bucket_detached(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let targets_response = list_replication_targets_request(env, Some(bucket)).await?;
if targets_response.status() != StatusCode::OK {
return Err(format!("list remote targets failed after site removal: {}", targets_response.status()).into());
}
let targets: Vec<serde_json::Value> = targets_response.json().await?;
if !targets.is_empty() {
return Err(format!("site removal left remote targets for {bucket}: {targets:?}").into());
}
let replication_response = get_bucket_replication(env, bucket).await?;
if replication_response.status() != StatusCode::NOT_FOUND {
let status = replication_response.status();
let body = replication_response.text().await.unwrap_or_default();
return Err(format!("site removal left replication config for {bucket}: {status} {body}").into());
}
Ok(())
}
async fn wait_for_site_replication_info<F>(
env: &RustFSTestEnvironment,
predicate: F,
@@ -2632,10 +2663,10 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
// Site replication rejects initialization unless all but one site is empty, so only
// the source is seeded before joining; the target bucket is created afterwards.
source_client.create_bucket().bucket(source_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
// Seed only the joining site so its synchronous backfill must call the initiating
// site before the add handler has persisted its final enabled state.
target_client.create_bucket().bucket(source_bucket).send().await?;
enable_bucket_versioning(&target_env, source_bucket).await?;
let add_status = site_replication_add(
&source_env,
@@ -2665,11 +2696,9 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
.find(|peer| peer.endpoint == target_env.url)
.ok_or("target peer missing from source site replication info")?;
// Wait for the joined target site to converge: site replication propagates the
// source bucket to the target and auto-configures its replication target. Drive the
// resync against that auto-created target instead of a redundant manual one (which
// now collides — "Remote target already exists").
wait_for_bucket_on_target(&target_client, source_bucket).await?;
// Wait for the initiating site to accept the join callback and configure the
// backfilled bucket before driving resync in the normal source-to-target direction.
wait_for_bucket_on_target(&source_client, source_bucket).await?;
let target_arn = wait_for_remote_target_arn(&source_env, source_bucket).await?;
for idx in 0..32 {
@@ -2739,7 +2768,7 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
#[tokio::test]
#[serial]
async fn test_site_replication_edit_and_status_peer_state_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
@@ -2748,7 +2777,25 @@ async fn test_site_replication_edit_and_status_peer_state_real_dual_node() -> Re
.await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
target_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let mut relay_env = RustFSTestEnvironment::new().await?;
relay_env
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
.await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let relay_client = relay_env.create_s3_client();
let bucket = "site-repl-edit-endpoint";
let baseline_key = "before-edit.txt";
let baseline_payload = b"site replication before endpoint edit".to_vec();
let moved_key = "after-edit.txt";
let moved_payload = b"site replication after endpoint edit".to_vec();
let relayed_key = "after-edit-from-relay.txt";
let relayed_payload = b"site replication after endpoint edit from relay".to_vec();
let add_status = site_replication_add(
&source_env,
@@ -2765,57 +2812,132 @@ async fn test_site_replication_edit_and_status_peer_state_real_dual_node() -> Re
access_key: target_env.access_key.clone(),
secret_key: target_env.secret_key.clone(),
},
PeerSite {
name: "relay-site".to_string(),
endpoint: relay_env.url.clone(),
access_key: relay_env.access_key.clone(),
secret_key: relay_env.secret_key.clone(),
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {:?}", add_status);
let source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
let source_info = wait_for_site_replication_enabled(&source_env, 3).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 3).await?;
let _relay_info = wait_for_site_replication_enabled(&relay_env, 3).await?;
let mut remote_peer = source_info
.sites
.into_iter()
.find(|peer| peer.endpoint == target_env.url)
.ok_or("target peer missing from source site replication info")?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
wait_for_bucket_on_target(&target_client, bucket).await?;
wait_for_bucket_on_target(&relay_client, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(baseline_key)
.body(ByteStream::from(baseline_payload.clone()))
.send()
.await?;
let replicated_baseline = wait_for_object_on_target(&target_client, bucket, baseline_key).await?;
assert_eq!(replicated_baseline, baseline_payload);
let old_target_address = target_env.address.clone();
let new_target_port = RustFSTestEnvironment::find_available_port().await?;
let new_target_address = format!("127.0.0.1:{new_target_port}");
let new_target_url = format!("http://{new_target_address}");
remote_peer.sync_state = SyncStatus::Enable;
remote_peer.endpoint = new_target_url.clone();
let edit_status = site_replication_edit(&source_env, "", &remote_peer).await?;
assert!(edit_status.success, "unexpected site edit result: {:?}", edit_status);
let source_after_sync = wait_for_site_replication_info(&source_env, |info| {
info.sites
.iter()
.any(|peer| peer.endpoint == target_env.url && peer.sync_state == SyncStatus::Enable)
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
})
.await?;
let target_after_sync = wait_for_site_replication_info(&target_env, |info| {
info.sites
.iter()
.any(|peer| peer.endpoint == target_env.url && peer.sync_state == SyncStatus::Enable)
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
})
.await?;
let relay_after_sync = wait_for_site_replication_info(&relay_env, |info| {
info.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
})
.await?;
assert!(
source_after_sync
.sites
.iter()
.any(|peer| peer.endpoint == target_env.url && peer.sync_state == SyncStatus::Enable)
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
);
assert!(
target_after_sync
.sites
.iter()
.any(|peer| peer.endpoint == target_env.url && peer.sync_state == SyncStatus::Enable)
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
);
assert!(
relay_after_sync
.sites
.iter()
.any(|peer| peer.endpoint == new_target_url && peer.sync_state == SyncStatus::Enable)
);
assert_eq!(relay_after_sync.sites.len(), 3);
for (env, unchanged_endpoint) in [(&source_env, &relay_env.address), (&relay_env, &source_env.address)] {
let mut endpoints_replaced = false;
let mut last_targets = Vec::new();
for _ in 0..40 {
let response = list_replication_targets_request(env, Some(bucket)).await?;
if response.status() == StatusCode::OK {
let targets: Vec<serde_json::Value> = response.json().await?;
let mut endpoints = targets
.iter()
.filter_map(|target| target.get("endpoint").and_then(|endpoint| endpoint.as_str()))
.collect::<Vec<_>>();
endpoints.sort_unstable();
let mut expected = vec![new_target_address.as_str(), unchanged_endpoint.as_str()];
expected.sort_unstable();
endpoints_replaced = endpoints == expected;
last_targets = targets;
if endpoints_replaced {
break;
}
}
sleep(Duration::from_millis(250)).await;
}
assert!(
endpoints_replaced,
"site edit did not replace the bucket target endpoint on {}: {last_targets:?}",
env.address
);
}
target_env.stop_server();
let old_endpoint_listener = tokio::net::TcpListener::bind(&old_target_address).await?;
target_env.address = new_target_address;
target_env.url = new_target_url.clone();
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let moved_target_client = target_env.create_s3_client();
let ilm_edit_status = site_replication_edit(&source_env, "enableILMExpiryReplication=true", &PeerInfo::default()).await?;
assert!(ilm_edit_status.success, "unexpected ilm edit result: {:?}", ilm_edit_status);
let source_after_ilm = wait_for_site_replication_info(&source_env, |info| {
info.sites.len() == 2 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
info.sites.len() == 3 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
})
.await?;
let target_after_ilm = wait_for_site_replication_info(&target_env, |info| {
info.sites.len() == 2 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
info.sites.len() == 3 && info.sites.iter().all(|peer| peer.replicate_ilm_expiry)
})
.await?;
assert!(source_after_ilm.sites.iter().all(|peer| peer.replicate_ilm_expiry));
@@ -2823,26 +2945,26 @@ async fn test_site_replication_edit_and_status_peer_state_real_dual_node() -> Re
let status_query = "peer-state=true";
let source_status = wait_for_site_replication_status(&source_env, status_query, |status| {
status.peer_states.len() == 2
status.peer_states.len() == 3
&& status
.peer_states
.values()
.all(|state| state.peers.len() == 2 && state.peers.values().all(|peer| peer.replicate_ilm_expiry))
.all(|state| state.peers.len() == 3 && state.peers.values().all(|peer| peer.replicate_ilm_expiry))
})
.await?;
let target_status = wait_for_site_replication_status(&target_env, status_query, |status| {
status.peer_states.len() == 2
status.peer_states.len() == 3
&& status
.peer_states
.values()
.all(|state| state.peers.len() == 2 && state.peers.values().all(|peer| peer.replicate_ilm_expiry))
.all(|state| state.peers.len() == 3 && state.peers.values().all(|peer| peer.replicate_ilm_expiry))
})
.await?;
assert_eq!(source_status.peer_states.len(), 2);
assert_eq!(target_status.peer_states.len(), 2);
assert!(source_status.peer_states.values().all(|state| state.peers.len() == 2));
assert!(target_status.peer_states.values().all(|state| state.peers.len() == 2));
assert_eq!(source_status.peer_states.len(), 3);
assert_eq!(target_status.peer_states.len(), 3);
assert!(source_status.peer_states.values().all(|state| state.peers.len() == 3));
assert!(target_status.peer_states.values().all(|state| state.peers.len() == 3));
assert!(
source_status
.peer_states
@@ -2856,6 +2978,35 @@ async fn test_site_replication_edit_and_status_peer_state_real_dual_node() -> Re
.all(|state| state.peers.values().all(|peer| peer.replicate_ilm_expiry))
);
source_client
.put_object()
.bucket(bucket)
.key(moved_key)
.body(ByteStream::from(moved_payload.clone()))
.send()
.await?;
relay_client
.put_object()
.bucket(bucket)
.key(relayed_key)
.body(ByteStream::from(relayed_payload.clone()))
.send()
.await?;
let no_old_endpoint_connection = async {
match tokio::time::timeout(Duration::from_secs(3), old_endpoint_listener.accept()).await {
Err(_) => Ok::<(), Box<dyn Error + Send + Sync>>(()),
Ok(Ok((_, peer_address))) => Err(format!("source contacted the old site endpoint from {peer_address}").into()),
Ok(Err(err)) => Err(err.into()),
}
};
let (replicated_after_edit, replicated_from_relay, ()) = tokio::try_join!(
wait_for_object_on_target(&moved_target_client, bucket, moved_key),
wait_for_object_on_target(&moved_target_client, bucket, relayed_key),
no_old_endpoint_connection,
)?;
assert_eq!(replicated_after_edit, moved_payload);
assert_eq!(replicated_from_relay, relayed_payload);
Ok(())
}
@@ -2872,6 +3023,14 @@ async fn test_site_replication_remove_all_real_dual_node() -> Result<(), Box<dyn
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let bucket = "site-repl-remove-all";
let baseline_key = "before-remove.txt";
let baseline_payload = b"site replication before remove all".to_vec();
let post_remove_key = "after-remove.txt";
let racing_bucket = "site-repl-remove-racing-create";
let add_status = site_replication_add(
&source_env,
&[
@@ -2895,14 +3054,28 @@ async fn test_site_replication_remove_all_real_dual_node() -> Result<(), Box<dyn
let _source_info = wait_for_site_replication_enabled(&source_env, 2).await?;
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
let remove_status = site_replication_remove(
&source_env,
&SRRemoveReq {
remove_all: true,
..Default::default()
},
)
.await?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
wait_for_bucket_on_target(&target_client, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(baseline_key)
.body(ByteStream::from(baseline_payload.clone()))
.send()
.await?;
let replicated_baseline = wait_for_object_on_target(&target_client, bucket, baseline_key).await?;
assert_eq!(replicated_baseline, baseline_payload);
let create_racing_bucket = source_client.create_bucket().bucket(racing_bucket).send();
let remove_req = SRRemoveReq {
remove_all: true,
..Default::default()
};
let remove_all = site_replication_remove(&source_env, &remove_req);
let (create_result, remove_status) = tokio::join!(create_racing_bucket, remove_all);
create_result?;
let remove_status = remove_status?;
assert!(
!remove_status.status.is_empty() && remove_status.err_detail.is_empty(),
"unexpected site remove result: {:?}",
@@ -2917,6 +3090,39 @@ async fn test_site_replication_remove_all_real_dual_node() -> Result<(), Box<dyn
assert!(!target_after_remove.enabled);
assert!(target_after_remove.sites.is_empty());
source_client.head_bucket().bucket(bucket).send().await?;
source_client.head_bucket().bucket(racing_bucket).send().await?;
target_client.head_bucket().bucket(bucket).send().await?;
assert_site_replication_bucket_detached(&source_env, bucket).await?;
assert_site_replication_bucket_detached(&source_env, racing_bucket).await?;
match target_client.head_bucket().bucket(racing_bucket).send().await {
Ok(_) => assert_site_replication_bucket_detached(&target_env, racing_bucket).await?,
Err(err) if matches!(err.code(), Some("NoSuchBucket" | "NotFound")) => {}
Err(err) => return Err(err.into()),
}
assert_site_replication_bucket_detached(&target_env, bucket).await?;
source_client
.put_object()
.bucket(bucket)
.key(post_remove_key)
.body(ByteStream::from_static(b"site replication must stay stopped"))
.send()
.await?;
let absence_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match target_client.get_object().bucket(bucket).key(post_remove_key).send().await {
Ok(_) => return Err("object reached the removed site replication target".into()),
Err(err) if matches!(err.code(), Some("NoSuchKey" | "NotFound" | "NoSuchVersion")) => {
if tokio::time::Instant::now() >= absence_deadline {
break;
}
sleep(Duration::from_millis(250)).await;
}
Err(err) => return Err(err.into()),
}
}
Ok(())
}
@@ -3068,7 +3274,12 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
let _target_info = wait_for_site_replication_enabled(&target_env, 2).await?;
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
let versioning = source_client.get_bucket_versioning().bucket(bucket).send().await?;
assert_eq!(
versioning.status(),
Some(&BucketVersioningStatus::Enabled),
"site replication did not enable source bucket versioning"
);
let replication_response = signed_request(
http::Method::GET,
&format!("{}/{bucket}?replication", source_env.url),
+7
View File
@@ -27,6 +27,7 @@ use crate::admin::storage_api::bucket::{
};
use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOptions, MakeBucketOptions};
use crate::admin::storage_api::error::StorageError;
use crate::storage::storage_api::lock_bucket_targets_metadata;
use crate::{
admin::{
auth::validate_admin_request,
@@ -836,6 +837,11 @@ impl Operation for ImportBucketMetadata {
for (bucket_name, metadata) in &bucket_metadatas {
for (config_file, data) in imported_configs_to_persist(metadata) {
let site_replication_item = imported_config_to_site_replication_item(bucket_name, metadata, config_file, &data)?;
let targets_guard = if matches!(config_file, BUCKET_REPLICATION_CONFIG | BUCKET_TARGETS_FILE) {
Some(lock_bucket_targets_metadata(bucket_name).await)
} else {
None
};
if let Err(e) = metadata_sys::update(bucket_name, config_file, data).await {
warn!(
event = EVENT_ADMIN_BUCKET_META_STATE,
@@ -853,6 +859,7 @@ impl Operation for ImportBucketMetadata {
"failed to persist imported bucket metadata for {bucket_name}/{config_file}: {e}"
));
}
drop(targets_guard);
if let Some(item) = site_replication_item
&& let Err(err) = site_replication_bucket_meta_hook(item).await
{
+3
View File
@@ -29,6 +29,7 @@ use crate::admin::utils::read_compatible_admin_body;
use crate::auth::{check_key_valid, get_session_token};
use crate::error::ApiError;
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use crate::storage::storage_api::lock_bucket_targets_metadata;
use http::{HeaderMap, HeaderValue, Uri};
use hyper::{Method, StatusCode};
use matchit::Params;
@@ -431,6 +432,7 @@ impl Operation for SetRemoteTargetHandler {
if remote_target.arn.is_empty() {
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "ARN is empty".to_string()));
}
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
if update {
let Some(mut target) = bucket_target_sys
@@ -577,6 +579,7 @@ impl Operation for RemoveRemoteTargetHandler {
.map_err(ApiError::from)?;
let sys = BucketTargetSys::get();
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
let targets = sys.remove_target(bucket, arn_str).await.map_err(map_bucket_target_error)?;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -598,6 +598,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
SITE_REPLICATION_ADD,
RouteRiskLevel::High,
),
admin(
HttpMethod::Put,
"/rustfs/admin/v3/site-replication/peer/edit-capabilities",
SITE_REPLICATION_OPERATION,
RouteRiskLevel::High,
),
admin(
HttpMethod::Put,
"/rustfs/admin/v3/site-replication/peer/edit",
@@ -272,6 +272,7 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
admin_route(Method::PUT, "/v3/site-replication/peer/bucket-meta"),
admin_route(Method::GET, "/v3/site-replication/peer/idp-settings"),
admin_route(Method::PUT, "/v3/site-replication/edit"),
admin_route(Method::PUT, "/v3/site-replication/peer/edit-capabilities"),
admin_route(Method::PUT, "/v3/site-replication/peer/edit"),
admin_route(Method::PUT, "/v3/site-replication/peer/remove"),
admin_route(Method::PUT, "/v3/site-replication/resync/op"),
@@ -1192,6 +1193,7 @@ fn test_register_routes_cover_representative_admin_paths() {
assert_route(&router, Method::PUT, &admin_path("/v3/site-replication/peer/bucket-meta"));
assert_route(&router, Method::GET, &admin_path("/v3/site-replication/peer/idp-settings"));
assert_route(&router, Method::PUT, &admin_path("/v3/site-replication/edit"));
assert_route(&router, Method::PUT, &admin_path("/v3/site-replication/peer/edit-capabilities"));
assert_route(&router, Method::PUT, &admin_path("/v3/site-replication/peer/edit"));
assert_route(&router, Method::PUT, &admin_path("/v3/site-replication/peer/remove"));
assert_route(&router, Method::PUT, &admin_path("/v3/site-replication/resync/op"));
+3
View File
@@ -40,6 +40,7 @@ use crate::license::license_check;
use crate::server::{
ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, MINIO_ADMIN_PREFIX, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH, is_admin_path,
};
use crate::storage::storage_api::lock_bucket_targets_metadata;
use aws_sdk_s3::primitives::ByteStream as AwsByteStream;
use bytes::Bytes;
use futures::{Stream, StreamExt};
@@ -2136,6 +2137,7 @@ async fn target_client_object_lock_enabled(bucket: &str, target: &BucketTarget)
}
async fn start_replication_resync(bucket: &str, reset: &ReplicationResetStartRequest) -> S3Result<ReplicationResetTarget> {
let targets_guard = lock_bucket_targets_metadata(bucket).await;
let (config, _) = metadata_sys::get_replication_config(bucket).await.map_err(ApiError::from)?;
let resolved_arn = resolve_replication_reset_target_arn(&config, &reset.arn)?;
let mut resolved_reset = reset.clone();
@@ -2149,6 +2151,7 @@ async fn start_replication_resync(bucket: &str, reset: &ReplicationResetStartReq
.await
.map_err(ApiError::from)?;
BucketTargetSys::get().update_all_targets(bucket, Some(&targets)).await;
drop(targets_guard);
let Some(pool) = current_replication_pool_handle() else {
return Err(s3_error!(InternalError, "replication pool is not initialized"));
+5
View File
@@ -71,6 +71,7 @@ use crate::app::runtime_sources::{
use crate::auth::get_condition_values_with_client_info;
use crate::error::ApiError;
use crate::server::RemoteAddr;
use crate::storage::storage_api::lock_bucket_targets_metadata;
use futures::StreamExt;
use http::StatusCode;
use metrics::counter;
@@ -1455,6 +1456,7 @@ impl DefaultBucketUsecase {
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let targets_guard = lock_bucket_targets_metadata(&bucket).await;
let replication_config = match metadata_sys::get_replication_config(&bucket).await {
Ok((config, _)) => Some(config),
Err(StorageError::ConfigNotFound) => None,
@@ -1477,6 +1479,7 @@ impl DefaultBucketUsecase {
}
return Err(err);
}
drop(targets_guard);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context);
@@ -2296,11 +2299,13 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
let targets_guard = lock_bucket_targets_metadata(&bucket).await;
validate_bucket_replication_update(&bucket, &replication_configuration).await?;
let data = serialize_config(&replication_configuration)?;
metadata_sys::update(&bucket, BUCKET_REPLICATION_CONFIG, data)
.await
.map_err(ApiError::from)?;
drop(targets_guard);
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context);
+57 -1
View File
@@ -14,11 +14,34 @@
//! Storage owner-local boundary for ECStore facade and storage contract symbols.
use std::sync::Arc;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, LazyLock};
use rustfs_storage_api as storage_contracts;
use tokio::sync::{Mutex, OwnedMutexGuard};
use tokio_util::sync::CancellationToken;
const BUCKET_TARGETS_METADATA_LOCK_SHARDS: usize = 256;
static BUCKET_TARGETS_METADATA_LOCKS: LazyLock<Vec<Arc<Mutex<()>>>> = LazyLock::new(|| {
(0..BUCKET_TARGETS_METADATA_LOCK_SHARDS)
.map(|_| Arc::new(Mutex::new(())))
.collect()
});
pub(crate) async fn lock_bucket_targets_metadata(bucket: &str) -> OwnedMutexGuard<()> {
BUCKET_TARGETS_METADATA_LOCKS[bucket_targets_metadata_lock_shard(bucket)]
.clone()
.lock_owned()
.await
}
fn bucket_targets_metadata_lock_shard(bucket: &str) -> usize {
let mut hasher = DefaultHasher::new();
bucket.hash(&mut hasher);
hasher.finish() as usize % BUCKET_TARGETS_METADATA_LOCK_SHARDS
}
pub(crate) mod contract {
pub(crate) mod admin {
pub(crate) use super::super::storage_contracts::StorageAdminApi;
@@ -1435,3 +1458,36 @@ pub(crate) async fn store_compression_total_in_backend() {
pub(crate) async fn init_compression_total_memory_from_backend(store: Arc<ECStore>) {
ecstore_data_usage::init_compression_total_memory_from_backend(store).await
}
#[cfg(test)]
mod tests {
use super::{bucket_targets_metadata_lock_shard, lock_bucket_targets_metadata};
use std::time::Duration;
#[tokio::test]
async fn bucket_target_metadata_locks_serialize_only_matching_shards() {
let bucket = "bucket-target-lock";
let other = (0..1000)
.map(|index| format!("other-bucket-{index}"))
.find(|candidate| bucket_targets_metadata_lock_shard(candidate) != bucket_targets_metadata_lock_shard(bucket))
.expect("find bucket on another lock shard");
let guard = lock_bucket_targets_metadata(bucket).await;
assert!(
tokio::time::timeout(Duration::from_millis(20), lock_bucket_targets_metadata(bucket))
.await
.is_err()
);
assert!(
tokio::time::timeout(Duration::from_secs(1), lock_bucket_targets_metadata(&other))
.await
.is_ok()
);
drop(guard);
assert!(
tokio::time::timeout(Duration::from_secs(1), lock_bucket_targets_metadata(bucket))
.await
.is_ok()
);
}
}