refactor(admin): move site-replication service core out of handlers (#6699)

Mechanical move-only extraction for backlog#1840 PR1+PR4: the site-replication state (load/parse/persist/RMW transaction), repair state machine, peer transport (client cache, DNS resolver, send_peer_* family), retry queue, and the four storage-side hooks move from rustfs/src/admin/handlers/site_replication.rs into the new infra-layer module rustfs/src/site_replication/ ({mod,state,state_lock,identity,transport,retry,repair,hooks}.rs). The admin handler file keeps route registration, all Operation impls, request/response glue, and the in-file test module, and re-exports the moved items so existing paths keep resolving. admin/site_replication_identity.rs and admin/site_replication_state.rs relocate wholesale as identity.rs/state_lock.rs.

Storage access from the moved code goes through a new site_replication consumer module in the root facade (rustfs/src/storage_api.rs), including an s3 shim so the module stays off the direct s3s surface (file count stays at the 215 baseline). The three admin runtime-source wrappers the moved code needs (outbound TLS generation incl. the test atomic, outbound TLS state, runtime port) are reproduced locally; the TLS-generation trio moves out of admin/runtime_sources.rs since site replication was its only consumer. The one non-verbatim rewrite: site_replication_peer_payload inlines encrypt_stream_io in its encrypted branch, which is provably the branch encode_compatible_admin_payload always took for the /minio/admin peer-join wire path.

app/bucket_usecase.rs now imports the three bucket hooks from crate::site_replication, deleting the three app->interface entries from the layer baseline (shrink-only). The peer-client cache test moves with the owner-local SITE_REPLICATION_PEER_CLIENT static into transport.rs (228+1 = 229 tests conserved). New module files are added to the logging-guardrail checked list; the s3_error! line baseline tightens 1620 -> 1619; global-state/config-consumer inventories and ARCHITECTURE.md pointers updated.

Verified: cargo check -p rustfs --all-targets clean; cargo clippy --workspace --all-targets clean; cargo nextest run -p rustfs --lib 3852/3852 passed; make pre-commit green; scripts/check_layer_dependencies.sh green with baseline-only deletions; line-multiset conservation audit over the moved code accounts for every non-verbatim line (visibility bumps, import rewrites, fmt reflow).

Refs rustfs/backlog#1840
This commit is contained in:
Zhengchao An
2026-08-27 09:01:11 +08:00
committed by GitHub
parent 2739330971
commit a6ea4ac8f3
22 changed files with 4821 additions and 4500 deletions
+2
View File
@@ -332,6 +332,8 @@ The binary (`main.rs`) boots in this order:
- **"Where is replication configured?"**
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
`rustfs/src/site_replication/` for the site-replication service subsystem
(state, peer transport, retry queue, repair, hooks),
`ecstore/src/bucket/replication/` for engine
- **"Where do I add a new admin endpoint?"**
@@ -123,7 +123,7 @@ behind narrower contracts.
| Files | Current usage |
|---|---|
| `rustfs/src/admin/handlers/kms_dynamic.rs` | Uses generic `read_config` and `save_config` for dynamic KMS config objects. |
| `rustfs/src/admin/handlers/site_replication.rs` | Uses generic `read_config`, `save_config`, and `delete_config` for site-replication state objects. |
| `rustfs/src/site_replication/state.rs` | Uses generic `read_config`, `save_config`, and `delete_config` (via the root storage facade) for site-replication state objects. |
| `rustfs/src/admin/service/site_replication.rs` | Uses generic `read_config` and `save_config` for site-replication state normalization. |
| `rustfs/src/server/module_switch.rs` | Uses generic `read_config` and `save_config` for module-switch config objects. |
| `crates/iam/src/store/object.rs` | Uses generic `read_config_no_lock`, `read_config_with_metadata`, `save_config`, `save_config_with_opts`, and `delete_config` helper variants for IAM object-store persistence paths. |
+2 -2
View File
@@ -110,11 +110,11 @@ inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and
| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object/get.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. |
| `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. |
| `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. |
| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/admin/handlers/site_replication.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to site-replication handlers. The state RMW transaction holds no process-local mutex — see `rustfs/src/admin/site_replication_state.rs`. |
| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/site_replication/transport.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to the site-replication transport module. The state RMW transaction holds no process-local mutex — see `rustfs/src/site_replication/state_lock.rs`. |
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. |
| `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. |
| `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. |
| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/admin/runtime_sources.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to admin runtime-source tests. |
| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/site_replication/mod.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to site-replication transport tests. |
| `TEST_REMAINING_FAILURES` | `rustfs/src/startup_iam.rs` | Test or fixture state | IAM startup retry injection state stays private to debug/test startup code. |
| `CAPACITY_DIRTY_SCOPE_ENV`, `CAPACITY_DIRTY_SCOPE_INIT`, `GLOBAL_ENV`, function-local `INIT` | `rustfs/src/app/*_test.rs` | Test or fixture state | App integration test fixture state stays private to the owning test modules. |
File diff suppressed because it is too large Load Diff
-2
View File
@@ -23,8 +23,6 @@ pub(crate) mod route_policy;
pub mod router;
pub(crate) mod runtime_sources;
pub mod service;
pub mod site_replication_identity;
pub(crate) mod site_replication_state;
pub(crate) mod storage_api;
pub mod utils;
+1 -26
View File
@@ -36,10 +36,8 @@ pub(crate) use crate::runtime_sources::{
};
use rustfs_config::server_config::Config;
use rustfs_kms::KmsServiceManager;
use rustfs_tls_runtime::{GlobalPublishedOutboundTlsState, TlsGeneration};
use rustfs_tls_runtime::GlobalPublishedOutboundTlsState;
use std::sync::Arc;
#[cfg(test)]
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::RwLock;
pub(crate) fn default_admin_usecase() -> DefaultAdminUsecase {
@@ -116,29 +114,6 @@ pub(crate) fn current_or_init_kms_runtime_service_manager() -> Arc<KmsServiceMan
.unwrap_or_else(rustfs_kms::init_global_kms_service_manager)
}
#[cfg(test)]
static TEST_OUTBOUND_TLS_GENERATION: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
pub(crate) fn set_test_outbound_tls_generation(generation: u64) {
root_runtime_sources::set_test_outbound_tls_generation(generation);
TEST_OUTBOUND_TLS_GENERATION.store(generation, Ordering::Relaxed);
}
pub(crate) fn current_outbound_tls_generation() -> TlsGeneration {
root_runtime_sources::current_outbound_tls_generation().unwrap_or_else(empty_outbound_tls_generation)
}
#[cfg(test)]
fn empty_outbound_tls_generation() -> TlsGeneration {
TlsGeneration(TEST_OUTBOUND_TLS_GENERATION.load(Ordering::Relaxed))
}
#[cfg(not(test))]
fn empty_outbound_tls_generation() -> TlsGeneration {
TlsGeneration(0)
}
pub(crate) async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState {
if let Some(state) = root_runtime_sources::current_outbound_tls_state().await {
return state;
+3 -3
View File
@@ -13,11 +13,11 @@
// limitations under the License.
use crate::admin::runtime_sources::{AppContext, current_app_context, current_object_store_handle_for_context};
use crate::admin::site_replication_identity::{
use crate::admin::storage_api::error::Error as StorageError;
use crate::site_replication::identity::{
deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with,
};
use crate::admin::site_replication_state::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock_on};
use crate::admin::storage_api::error::Error as StorageError;
use crate::site_replication::state_lock::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock_on};
use crate::storage::storage_api::{read_config_no_lock, save_config_no_lock};
use rustfs_madmin::PeerInfo;
use s3s::{S3Error, S3ErrorCode, S3Result};
+2 -4
View File
@@ -445,8 +445,8 @@ pub(crate) mod replication {
pub(crate) use super::ecstore_bucket::replication::{
OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, is_site_replication_role,
merge_incoming_replication_config, replication_target_arn_deployment_id, site_replication_rule_deployment_id,
REPLICATION_WRITABLE_FIELDS, assign_site_replication_rule_priorities, merge_incoming_replication_config,
replication_target_arn_deployment_id,
};
pub(crate) type BucketReplicationResyncStatus = super::ecstore_bucket::replication::BucketReplicationResyncStatus;
pub(crate) type BucketStats = super::ecstore_bucket::replication::BucketStats;
@@ -653,8 +653,6 @@ pub(crate) mod replication {
pub(crate) mod target {
pub(crate) use super::ecstore_bucket::target::duration_from_secs_or_nanos;
#[allow(clippy::upper_case_acronyms)]
pub(crate) type ARN = super::ecstore_bucket::target::ARN;
pub(crate) type BucketTarget = super::ecstore_bucket::target::BucketTarget;
pub(crate) type BucketTargetType = super::ecstore_bucket::target::BucketTargetType;
pub(crate) type BucketTargets = super::ecstore_bucket::target::BucketTargets;
+3 -3
View File
@@ -64,9 +64,6 @@ use super::storage_api::bucket_usecase::{
get_validated_store, process_lambda_configurations, process_queue_configurations, process_topic_configurations,
request_context, validate_list_object_unordered_with_delimiter,
};
use crate::admin::handlers::site_replication::{
site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook,
};
use crate::app::object_data_cache::invalidate_object_data_cache_bucket_after_delete;
use crate::app::runtime_sources::{
AppContext, current_app_context, current_encryption_service, current_notification_system,
@@ -75,6 +72,9 @@ use crate::app::runtime_sources::{
use crate::auth::get_condition_values_with_client_info;
use crate::error::ApiError;
use crate::shared_types::RemoteAddr;
use crate::site_replication::{
site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook,
};
use crate::storage::storage_api::lock_bucket_targets_metadata;
use http::StatusCode;
use metrics::counter;
+1
View File
@@ -100,6 +100,7 @@ pub mod runtime_capabilities;
pub(crate) mod runtime_sources;
pub mod server;
pub mod shared_types;
pub(crate) mod site_replication;
pub(crate) mod site_replication_reconcile;
pub(crate) mod startup_audit;
pub(crate) mod startup_auth;
File diff suppressed because it is too large Load Diff
@@ -86,7 +86,7 @@ pub(crate) fn mark_unknown_peer_sync_enabled(peers: &mut BTreeMap<String, PeerIn
}
}
pub(super) fn is_https_endpoint(endpoint: &str) -> bool {
pub(crate) fn is_https_endpoint(endpoint: &str) -> bool {
canonical_endpoint(endpoint).starts_with("https://")
}
+163
View File
@@ -0,0 +1,163 @@
// 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.
//! Site-replication service subsystem (backlog#1840).
//!
//! The parts of site replication that storage-side flows call into — the
//! persisted cluster state and its RMW transaction, the peer HTTP transport,
//! the retry queue, the repair state machine, and the bucket/IAM broadcast
//! hooks — live here in the infra layer. The admin HTTP handlers stay in
//! `crate::admin::handlers::site_replication` and call down into this module;
//! that file re-exports these items so existing paths keep resolving.
//!
//! Storage access goes through the root facade (`crate::storage_api`) and
//! never through the admin or storage interface layers — this module sits
//! below the interface layer and must not import upward.
pub(crate) mod identity;
pub(crate) mod state_lock;
pub(crate) mod hooks;
pub(crate) mod repair;
pub(crate) mod retry;
pub(crate) mod state;
pub(crate) mod transport;
pub(crate) use self::hooks::*;
pub(crate) use self::repair::*;
pub(crate) use self::retry::*;
pub(crate) use self::state::*;
pub(crate) use self::transport::*;
use self::identity::{
canonical_endpoint, deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with,
same_identity_endpoint,
};
use self::state_lock::{SITE_REPLICATION_STATE_PATH, with_site_replication_state_lock};
use crate::auth::constant_time_eq;
use crate::config::get_config_snapshot;
use crate::error::ApiError;
use crate::runtime_sources::{
current_deployment_id, current_endpoints_handle, current_iam_handle, current_object_store_handle, current_region,
};
use crate::storage_api::site_replication::s3::{
Body, BucketLifecycleConfiguration, BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus,
DeleteReplication, DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus,
LifecycleRule, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule,
ReplicationRuleStatus, S3Error, S3ErrorCode, S3Response, S3Result, SourceSelectionCriteria, VersioningConfiguration,
s3_error,
};
#[cfg(test)]
use crate::storage_api::site_replication::save_config as save_admin_config;
use crate::storage_api::site_replication::{
ARN, BUCKET_REPLICATION_CONFIG, BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BucketOperations, BucketOptions, BucketTarget,
BucketTargetSys, BucketTargetType, BucketTargets, Credentials, ECStore, OperatorRuleContract, StorageError,
VersioningApi as _, assign_site_replication_rule_priorities, delete_config_no_lock, deserialize, is_site_replication_role,
lock_bucket_targets_metadata, metadata_sys, read_config as read_admin_config, read_config_no_lock,
replication_target_arn_deployment_id, save_config_no_lock, serialize, site_replication_rule_deployment_id,
with_config_object_read_lock, with_config_object_write_lock,
};
use base64_simd::STANDARD as BASE64_STANDARD;
use base64_simd::URL_SAFE_NO_PAD;
use hmac::{Hmac, Mac};
use http::header::{CONTENT_TYPE, HOST};
use http::{HeaderMap, HeaderValue, Uri};
use hyper::{Method, StatusCode};
use rustfs_config::{DEFAULT_CONSOLE_ADDRESS, DEFAULT_RUSTFS_TLS_PATH, ENV_RUSTFS_CONSOLE_ADDRESS, ENV_RUSTFS_TLS_PATH};
use rustfs_iam::store::{MappedPolicy, UserType, sr_wire_user_type};
use rustfs_iam::sys::SITE_REPLICATOR_SERVICE_ACCOUNT;
use rustfs_madmin::{
AddOrUpdateUserReq, GroupAddRemove, GroupStatus, PeerInfo, PeerSite, ReplicateEditStatus, SITE_REPL_API_VERSION,
SRBucketInfo, SRBucketMeta, SRGroupInfo, SRIAMItem, SRIAMPolicy, SRInfo, SRPolicyMapping, SRRemoveReq, SRResyncOpStatus,
SRRetryStats, SRStateInfo, SyncStatus,
};
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use rustfs_tls_runtime::{GlobalPublishedOutboundTlsState, TlsGeneration};
use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url};
use rustfs_utils::http::get_source_scheme;
use rustls_pki_types::pem::PemObject;
use serde::Deserialize;
use serde::Serialize;
use serde::de::IgnoredAny;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::{Mutex, RwLock};
use tracing::{info, warn};
use url::{Url, form_urlencoded};
use uuid::Uuid;
pub(crate) const LOG_COMPONENT_ADMIN: &str = "admin";
pub(crate) const LOG_SUBSYSTEM_SITE_REPLICATION: &str = "site_replication";
pub(crate) const EVENT_ADMIN_SITE_REPLICATION_STATE: &str = "admin_site_replication_state";
/// Layer-local mirror of `crate::admin::utils::json_response` (the repair
/// executor answers the admin HTTP surface but must not import upward from
/// the infra layer).
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(value)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to serialize response: {e}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Ok(S3Response::with_headers((status, Body::from(data)), headers))
}
// The admin layer's runtime-source wrappers apply fallbacks on top of
// `crate::runtime_sources`; this module reproduces the same fallbacks locally
// (verbatim from `crate::admin::runtime_sources`) so it never imports upward
// into the interface layer.
#[cfg(test)]
static TEST_OUTBOUND_TLS_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(test)]
pub(crate) fn set_test_outbound_tls_generation(generation: u64) {
crate::runtime_sources::set_test_outbound_tls_generation(generation);
TEST_OUTBOUND_TLS_GENERATION.store(generation, std::sync::atomic::Ordering::Relaxed);
}
fn current_outbound_tls_generation() -> TlsGeneration {
crate::runtime_sources::current_outbound_tls_generation().unwrap_or_else(empty_outbound_tls_generation)
}
#[cfg(test)]
fn empty_outbound_tls_generation() -> TlsGeneration {
TlsGeneration(TEST_OUTBOUND_TLS_GENERATION.load(std::sync::atomic::Ordering::Relaxed))
}
#[cfg(not(test))]
fn empty_outbound_tls_generation() -> TlsGeneration {
TlsGeneration(0)
}
async fn current_outbound_tls_state() -> GlobalPublishedOutboundTlsState {
if let Some(state) = crate::runtime_sources::current_outbound_tls_state().await {
return state;
}
crate::runtime_sources::fallback_outbound_tls_runtime_interface()
.state()
.await
}
fn current_runtime_port() -> u16 {
crate::runtime_sources::current_runtime_port().unwrap_or(rustfs_config::DEFAULT_PORT)
}
+845
View File
@@ -0,0 +1,845 @@
// 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::*;
pub(crate) const SITE_REPLICATION_REPAIR_STATE_PATH: &str = "config/site-replication/repair-state.json";
pub(crate) const SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH: &str = "config/site-replication/repair-execution.lock";
pub(crate) const SITE_REPLICATION_REPAIR_OPERATION_LIMIT: usize = 32;
pub(crate) const SITE_REPLICATION_REPAIR_IAM_FAMILY: &str = "iam";
pub(crate) const SITE_REPLICATION_REPAIR_BUCKET_FAMILY: &str = "bucket";
pub(crate) const SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY: &str = "bucket-metadata";
pub(crate) const SITE_REPLICATION_REPAIR_REPLICATION_FAMILY: &str = "replication";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairState {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) operations: BTreeMap<String, SiteReplicationRepairOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairOperation {
pub(crate) operation_id: String,
pub(crate) preflight_token: String,
pub(crate) plan_token: String,
pub(crate) status: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) sites: BTreeMap<String, SiteReplicationRepairSiteStatus>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) created_at: Option<OffsetDateTime>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<OffsetDateTime>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) completed_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairSiteStatus {
pub(crate) deployment_id: String,
pub(crate) name: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) families: BTreeMap<String, SiteReplicationRepairFamilyStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairFamilyStatus {
pub(crate) planned: usize,
pub(crate) succeeded: usize,
pub(crate) failed: usize,
#[serde(default)]
pub(crate) retry_events: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) tasks: Vec<SiteReplicationRepairTaskStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairTaskStatus {
pub(crate) task_id: String,
pub(crate) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct SiteReplicationRepairRequest {
pub(crate) mode: SiteReplicationRepairMode,
#[serde(default)]
pub(crate) preflight_token: Option<String>,
#[serde(default)]
pub(crate) operation_id: Option<String>,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum SiteReplicationRepairMode {
DryRun,
Execute,
}
pub(crate) struct SiteReplicationRepairExecutionRequest {
pub(crate) local_peer: PeerInfo,
pub(crate) preflight_token: String,
pub(crate) operation_id: String,
pub(crate) signing_key: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairPreflight {
pub(crate) mode: &'static str,
pub(crate) status: &'static str,
pub(crate) preflight_token: String,
pub(crate) retry_events: usize,
pub(crate) sites: BTreeMap<String, SiteReplicationRepairSiteStatus>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairOperationResponse {
pub(crate) mode: &'static str,
pub(crate) operation_id: String,
pub(crate) status: String,
pub(crate) sites: BTreeMap<String, SiteReplicationRepairSiteResponse>,
#[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) created_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) completed_at: Option<OffsetDateTime>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairSiteResponse {
pub(crate) deployment_id: String,
pub(crate) name: String,
pub(crate) families: BTreeMap<String, SiteReplicationRepairFamilyResponse>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SiteReplicationRepairFamilyResponse {
pub(crate) planned: usize,
pub(crate) succeeded: usize,
pub(crate) failed: usize,
pub(crate) retry_events: usize,
pub(crate) tasks: Vec<SiteReplicationRepairTaskStatus>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) errors: Vec<String>,
}
pub(crate) async fn load_site_replication_repair_state_from_store(store: Arc<ECStore>) -> S3Result<SiteReplicationRepairState> {
match read_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH).await {
Ok(data) => serde_json::from_slice(&data).map_err(|e| {
S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication repair state: {e}"))
}),
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationRepairState::default()),
Err(err) => Err(S3Error::with_message(
S3ErrorCode::InternalError,
format!("failed to load site replication repair state: {err}"),
)),
}
}
pub(crate) async fn save_site_replication_repair_state_to_store(
store: Arc<ECStore>,
state: &SiteReplicationRepairState,
) -> S3Result<()> {
let data = serde_json::to_vec(state)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair state failed: {e}")))?;
save_config_no_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH, data)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save repair state failed: {e}")))
}
pub(crate) async fn read_site_replication_repair_state() -> S3Result<SiteReplicationRepairState> {
let store =
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
let read_store = store.clone();
with_config_object_read_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move {
load_site_replication_repair_state_from_store(read_store).await
})
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))?
}
pub(crate) async fn update_site_replication_repair_state<T, F>(update: F) -> S3Result<T>
where
T: Send + 'static,
F: FnOnce(&mut SiteReplicationRepairState) -> S3Result<T> + Send + 'static,
{
let store =
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
let read_store = store.clone();
let save_store = store.clone();
with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_STATE_PATH.to_string(), move || async move {
let mut state = load_site_replication_repair_state_from_store(read_store).await?;
let result = update(&mut state)?;
save_site_replication_repair_state_to_store(save_store, &state).await?;
Ok(result)
})
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("lock repair state failed: {e}")))?
}
pub(crate) enum SiteReplicationRepairTask<'a> {
Iam(&'a SRIAMItem),
BucketMake(&'a str),
BucketMetadata(&'a SRBucketMeta),
Replication(&'a str),
}
impl SiteReplicationRepairTask<'_> {
pub(crate) fn family(&self) -> &'static str {
match self {
Self::Iam(_) => SITE_REPLICATION_REPAIR_IAM_FAMILY,
Self::BucketMake(_) => SITE_REPLICATION_REPAIR_BUCKET_FAMILY,
Self::BucketMetadata(_) => SITE_REPLICATION_REPAIR_BUCKET_METADATA_FAMILY,
Self::Replication(_) => SITE_REPLICATION_REPAIR_REPLICATION_FAMILY,
}
}
pub(crate) fn path(&self) -> &str {
match self {
Self::Iam(_) => "/rustfs/admin/v3/site-replication/peer/iam-item",
Self::BucketMake(path) | Self::Replication(path) => path,
Self::BucketMetadata(_) => "/rustfs/admin/v3/site-replication/peer/bucket-meta",
}
}
pub(crate) fn id(&self) -> S3Result<String> {
let payload = match self {
Self::Iam(item) => serde_json::to_vec(item),
Self::BucketMake(_) | Self::Replication(_) => serde_json::to_vec(&serde_json::json!({})),
Self::BucketMetadata(item) => serde_json::to_vec(item),
}
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair task failed: {err}")))?;
let mut digest = Sha256::new();
digest.update(self.family().as_bytes());
digest.update([0]);
digest.update(self.path().as_bytes());
digest.update([0]);
digest.update(payload);
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize()))
}
pub(crate) async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result<Vec<u8>> {
match self {
Self::Iam(item) => {
send_peer_admin_request_with_client(
&transport.client,
&transport.connection,
self.path(),
access_key,
secret_key,
item,
)
.await
}
Self::BucketMetadata(item) => {
send_peer_admin_request_with_client(
&transport.client,
&transport.connection,
self.path(),
access_key,
secret_key,
item,
)
.await
}
Self::BucketMake(_) | Self::Replication(_) => {
send_peer_admin_request_with_client(
&transport.client,
&transport.connection,
self.path(),
access_key,
secret_key,
&serde_json::json!({}),
)
.await
}
}
}
}
pub(crate) fn site_replication_repair_tasks(plan: &SiteReplicationBootstrapPlan) -> Vec<(usize, SiteReplicationRepairTask<'_>)> {
let mut tasks = Vec::with_capacity(
plan.iam_items.len() + plan.bucket_make_ops.len() + plan.bucket_items.len() + plan.bucket_configure_ops.len(),
);
tasks.extend(
plan.iam_items
.iter()
.enumerate()
.map(|(index, item)| (index, SiteReplicationRepairTask::Iam(item))),
);
tasks.extend(
plan.bucket_make_ops
.iter()
.enumerate()
.map(|(index, path)| (index, SiteReplicationRepairTask::BucketMake(path))),
);
tasks.extend(
plan.bucket_items
.iter()
.enumerate()
.map(|(index, item)| (index, SiteReplicationRepairTask::BucketMetadata(item))),
);
tasks.extend(
plan.bucket_configure_ops
.iter()
.enumerate()
.map(|(index, path)| (index, SiteReplicationRepairTask::Replication(path))),
);
tasks
}
pub(crate) fn site_replication_repair_plan_token(
state: &SiteReplicationState,
plan: &SiteReplicationBootstrapPlan,
) -> S3Result<String> {
let mut digest = Sha256::new();
let snapshot = serde_json::to_vec(&(
&state.name,
&state.service_account_access_key,
&state.peers,
state.updated_at,
state.sync_state_initialized,
))
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize repair snapshot failed: {err}")))?;
digest.update(snapshot);
for (_, task) in site_replication_repair_tasks(plan) {
digest.update(task.id()?.as_bytes());
}
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize()))
}
pub(crate) fn site_replication_repair_preflight_token(
state: &SiteReplicationState,
plan: &SiteReplicationBootstrapPlan,
signing_key: &[u8],
) -> S3Result<String> {
if signing_key.is_empty() {
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"repair signing key is empty".to_string(),
));
}
let mut digest = <Hmac<Sha256> as hmac::digest::KeyInit>::new_from_slice(signing_key)
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?;
digest.update(b"rustfs:site-replication:repair-preflight:v1\0");
digest.update(site_replication_repair_plan_token(state, plan)?.as_bytes());
for event in state
.retry_queue
.iter()
.filter(|event| retry_event_replayed_by_bootstrap(event))
{
digest.update(event.id.as_bytes());
digest.update(&[0]);
digest.update(event.peer_deployment_id.as_bytes());
digest.update(&[0]);
digest.update(event.path.as_bytes());
digest.update(&[0]);
}
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes()))
}
pub(crate) fn site_replication_repair_task_checkpoint_id(
signing_key: &[u8],
peer_deployment_id: &str,
task: &SiteReplicationRepairTask<'_>,
) -> S3Result<String> {
let mut digest = <Hmac<Sha256> as hmac::digest::KeyInit>::new_from_slice(signing_key)
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid repair signing key".to_string()))?;
digest.update(b"rustfs:site-replication:repair-task:v1\0");
digest.update(peer_deployment_id.as_bytes());
digest.update(&[0]);
digest.update(task.id()?.as_bytes());
Ok(URL_SAFE_NO_PAD.encode_to_string(digest.finalize().into_bytes()))
}
pub(crate) fn site_replication_repair_sites(
state: &SiteReplicationState,
local_peer: &PeerInfo,
plan: &SiteReplicationBootstrapPlan,
signing_key: &[u8],
) -> S3Result<BTreeMap<String, SiteReplicationRepairSiteStatus>> {
let mut planned = BTreeMap::new();
let mut family_paths = BTreeMap::<String, BTreeSet<String>>::new();
for (_, task) in site_replication_repair_tasks(plan) {
let family = task.family().to_string();
let family_status = planned
.entry(task.family().to_string())
.or_insert_with(SiteReplicationRepairFamilyStatus::default);
family_status.planned += 1;
family_paths.entry(family).or_default().insert(task.path().to_string());
}
let mut sites = BTreeMap::new();
for peer in state.peers.values().filter(|peer| {
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
}) {
let mut families = planned.clone();
for (_, task) in site_replication_repair_tasks(plan) {
let family = families
.get_mut(task.family())
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task family is missing".to_string()))?;
family.tasks.push(SiteReplicationRepairTaskStatus {
task_id: site_replication_repair_task_checkpoint_id(signing_key, &peer.deployment_id, &task)?,
status: "planned".to_string(),
error: None,
});
}
for (family, status) in &mut families {
status.retry_events = state
.retry_queue
.iter()
.filter(|event| {
event.peer_deployment_id == peer.deployment_id
&& retry_event_replayed_by_bootstrap(event)
&& family_paths.get(family).is_some_and(|paths| paths.contains(&event.path))
})
.count();
}
sites.insert(
peer.deployment_id.clone(),
SiteReplicationRepairSiteStatus {
deployment_id: peer.deployment_id.clone(),
name: peer.name.clone(),
families,
},
);
}
Ok(sites)
}
pub(crate) fn update_site_replication_repair_task(
operation: &mut SiteReplicationRepairOperation,
deployment_id: &str,
family: &str,
family_index: usize,
result: Result<(), &str>,
) -> S3Result<()> {
let site = operation
.sites
.get_mut(deployment_id)
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?;
let family_status = site
.families
.get_mut(family)
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?;
if family_status.succeeded != family_index {
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"repair operation task checkpoint is invalid".to_string(),
));
}
let task_status = family_status.tasks.get_mut(family_index).ok_or_else(|| {
S3Error::with_message(S3ErrorCode::InternalError, "repair operation task checkpoint is missing".to_string())
})?;
family_status.failed = 0;
family_status.errors.clear();
match result {
Ok(()) => {
family_status.succeeded = family_status.succeeded.saturating_add(1);
task_status.status = "succeeded".to_string();
task_status.error = None;
}
Err(error) => {
let error = classify_site_replication_repair_error(error).to_string();
family_status.failed = 1;
family_status.errors.push(error.clone());
task_status.status = "failed".to_string();
task_status.error = Some(error);
}
}
Ok(())
}
pub(crate) fn site_replication_repair_task_pending(
operation: &SiteReplicationRepairOperation,
deployment_id: &str,
family: &str,
family_index: usize,
) -> S3Result<bool> {
let site = operation
.sites
.get(deployment_id)
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation site is missing".to_string()))?;
let family = site
.families
.get(family)
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair operation family is missing".to_string()))?;
if family.succeeded > family_index {
return Ok(false);
}
if family.succeeded < family_index {
return Ok(false);
}
Ok(family.failed == 0)
}
pub(crate) fn prepare_site_replication_repair_retry(operation: &mut SiteReplicationRepairOperation) {
for family in operation.sites.values_mut().flat_map(|site| site.families.values_mut()) {
family.failed = 0;
family.errors.clear();
for task in &mut family.tasks {
match task.status.as_str() {
"succeeded" => task.status = "skipped".to_string(),
"failed" => {
task.status = "planned".to_string();
task.error = None;
}
_ => {}
}
}
}
}
pub(crate) fn classify_site_replication_repair_error(error: &str) -> &'static str {
let error = error.to_ascii_lowercase();
if error.contains("accessdenied")
|| error.contains("signaturedoesnotmatch")
|| error.contains("unauthorized")
|| error.contains("forbidden")
|| error.contains("401")
|| error.contains("403")
{
"authorization-failed"
} else if error.contains("timeout") {
"remote-timeout"
} else if error.contains("dns") {
"remote-dns-failed"
} else if error.contains("tls") || error.contains("certificate") {
"remote-tls-failed"
} else if error.contains("connect") {
"remote-connect-failed"
} else {
"remote-operation-failed"
}
}
pub(crate) fn summarize_site_replication_repair_operation(operation: &mut SiteReplicationRepairOperation) {
let failed = operation
.sites
.values()
.flat_map(|site| site.families.values())
.any(|family| family.failed > 0);
let complete = operation
.sites
.values()
.all(|site| site.families.values().all(|family| family.succeeded == family.planned));
operation.status = if complete {
"success"
} else if failed {
"partial"
} else {
"running"
}
.to_string();
operation.updated_at = Some(OffsetDateTime::now_utc());
operation.completed_at = complete.then_some(OffsetDateTime::now_utc());
}
pub(crate) fn site_replication_repair_operation_response(
operation: &SiteReplicationRepairOperation,
) -> SiteReplicationRepairOperationResponse {
SiteReplicationRepairOperationResponse {
mode: "execute",
operation_id: operation.operation_id.clone(),
status: operation.status.clone(),
sites: operation
.sites
.iter()
.map(|(deployment_id, site)| {
(
deployment_id.clone(),
SiteReplicationRepairSiteResponse {
deployment_id: site.deployment_id.clone(),
name: site.name.clone(),
families: site
.families
.iter()
.map(|(family, status)| {
(
family.clone(),
SiteReplicationRepairFamilyResponse {
planned: status.planned,
succeeded: status.succeeded,
failed: status.failed,
retry_events: status.retry_events,
tasks: status.tasks.clone(),
errors: status.errors.clone(),
},
)
})
.collect(),
},
)
})
.collect(),
created_at: operation.created_at,
updated_at: operation.updated_at,
completed_at: operation.completed_at,
}
}
pub(crate) fn prune_site_replication_repair_operations(operations: &mut BTreeMap<String, SiteReplicationRepairOperation>) {
while operations.len() > SITE_REPLICATION_REPAIR_OPERATION_LIMIT {
let Some(oldest) = operations
.iter()
.filter(|(_, operation)| operation.status == "success")
.min_by_key(|(_, operation)| operation.created_at)
.map(|(id, _)| id.clone())
else {
break;
};
operations.remove(&oldest);
}
}
pub(crate) async fn persist_site_replication_repair_operation(operation: &SiteReplicationRepairOperation) -> S3Result<()> {
let operation = operation.clone();
update_site_replication_repair_state(move |state| {
if let Some(existing) = state.operations.get(&operation.operation_id)
&& !constant_time_eq(&existing.preflight_token, &operation.preflight_token)
{
return Err(S3Error::with_message(
S3ErrorCode::ClientTokenConflict,
"repair operation ID is already bound to a different preflight".to_string(),
));
}
state.operations.insert(operation.operation_id.clone(), operation);
prune_site_replication_repair_operations(&mut state.operations);
Ok(())
})
.await
}
pub(crate) async fn persist_site_replication_repair_task(
operation: &SiteReplicationRepairOperation,
peer: &PeerInfo,
family: &str,
path: &str,
) -> S3Result<()> {
persist_site_replication_repair_operation(operation).await?;
let family_status = operation
.sites
.get(&peer.deployment_id)
.and_then(|site| site.families.get(family))
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "repair task status is missing".to_string()))?;
let failure = (family_status.failed > 0).then(|| {
family_status
.errors
.first()
.cloned()
.unwrap_or_else(|| "remote-operation-failed".to_string())
});
let peer = peer.clone();
let path = path.to_string();
update_site_replication_state(move |state| {
match failure.as_deref() {
Some(error) => upsert_site_replication_retry_event(&mut state.retry_queue, &peer, &path, error, None),
None => {
dequeue_site_replication_retry_events_including_escalated(&mut state.retry_queue, &peer, &path);
}
}
Ok(())
})
.await
}
pub(crate) fn admit_site_replication_repair_operation(
repair_state: &mut SiteReplicationRepairState,
operation_id: String,
supplied_token: &str,
candidate: SiteReplicationRepairOperation,
) -> S3Result<SiteReplicationRepairOperation> {
if let Some(existing) = repair_state.operations.get(&operation_id) {
if !constant_time_eq(&existing.preflight_token, supplied_token) {
return Err(S3Error::with_message(
S3ErrorCode::ClientTokenConflict,
"repair operation ID is already bound to a different preflight".to_string(),
));
}
if !constant_time_eq(&existing.plan_token, &candidate.plan_token) {
return Err(S3Error::with_message(
S3ErrorCode::PreconditionFailed,
"site replication repair plan changed after partial execution".to_string(),
));
}
return Ok(existing.clone());
}
if repair_state
.operations
.values()
.any(|operation| operation.status == "running")
{
return Err(S3Error::with_message(
S3ErrorCode::ClientTokenConflict,
"another site replication repair is active".to_string(),
));
}
repair_state.operations.insert(operation_id, candidate.clone());
prune_site_replication_repair_operations(&mut repair_state.operations);
Ok(candidate)
}
pub(crate) async fn execute_site_replication_repair(
request: SiteReplicationRepairExecutionRequest,
) -> S3Result<S3Response<(StatusCode, Body)>> {
let store =
current_object_store_handle().ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move {
execute_site_replication_repair_locked(request).await
})
.await
.map_err(|_| {
S3Error::with_message(S3ErrorCode::ClientTokenConflict, "another site replication repair is active".to_string())
})?
}
pub(crate) async fn execute_site_replication_repair_locked(
request: SiteReplicationRepairExecutionRequest,
) -> S3Result<S3Response<(StatusCode, Body)>> {
let state = load_site_replication_state().await?;
if !state.enabled() || state.service_account_access_key.is_empty() {
return Err(s3_error!(InvalidRequest, "site replication is not configured"));
}
let info = build_sr_info(&state, &request.local_peer).await?;
let plan = site_replication_bootstrap_plan(&info)?;
let plan_token = site_replication_repair_plan_token(&state, &plan)?;
let preflight_token = site_replication_repair_preflight_token(&state, &plan, request.signing_key.as_bytes())?;
let sites = site_replication_repair_sites(&state, &request.local_peer, &plan, request.signing_key.as_bytes())?;
let repair_state = read_site_replication_repair_state().await?;
if let Some(existing) = repair_state.operations.get(&request.operation_id) {
if !constant_time_eq(&existing.preflight_token, &request.preflight_token) {
return Err(S3Error::with_message(
S3ErrorCode::ClientTokenConflict,
"repair operation ID is already bound to a different preflight".to_string(),
));
}
if existing.status == "success" {
return json_response(StatusCode::OK, &site_replication_repair_operation_response(existing));
}
if !constant_time_eq(&existing.plan_token, &plan_token) {
return Err(S3Error::with_message(
S3ErrorCode::PreconditionFailed,
"site replication repair plan changed after partial execution".to_string(),
));
}
} else if !constant_time_eq(&request.preflight_token, &preflight_token) {
return Err(S3Error::with_message(
S3ErrorCode::PreconditionFailed,
"site replication repair preflight is stale".to_string(),
));
}
let now = OffsetDateTime::now_utc();
let candidate = SiteReplicationRepairOperation {
operation_id: request.operation_id.clone(),
preflight_token,
plan_token,
status: "running".to_string(),
sites,
created_at: Some(now),
updated_at: Some(now),
completed_at: None,
};
let supplied_token = request.preflight_token;
let operation_id = request.operation_id;
let mut operation = update_site_replication_repair_state(move |repair_state| {
admit_site_replication_repair_operation(repair_state, operation_id, &supplied_token, candidate)
})
.await?;
if operation.status == "success" {
return json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation));
}
let service_account_secret_key = site_replicator_service_account_secret(&state.service_account_access_key).await?;
prepare_site_replication_repair_retry(&mut operation);
operation.status = "running".to_string();
operation.completed_at = None;
operation.updated_at = Some(OffsetDateTime::now_utc());
persist_site_replication_repair_operation(&operation).await?;
let tasks = site_replication_repair_tasks(&plan);
for peer in state.peers.values().filter(|peer| {
peer.deployment_id != request.local_peer.deployment_id
&& !same_identity_endpoint(&peer.endpoint, &request.local_peer.endpoint)
}) {
let transport = match PeerTransport::for_runtime_peer(peer).await {
Ok(transport) => transport,
Err(err) => {
let error = err.to_string();
for (family_index, task) in &tasks {
if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? {
continue;
}
update_site_replication_repair_task(
&mut operation,
&peer.deployment_id,
task.family(),
*family_index,
Err(&error),
)?;
summarize_site_replication_repair_operation(&mut operation);
persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?;
}
continue;
}
};
for (family_index, task) in &tasks {
if !site_replication_repair_task_pending(&operation, &peer.deployment_id, task.family(), *family_index)? {
continue;
}
let result = task
.send(&transport, &state.service_account_access_key, &service_account_secret_key)
.await;
let error = result.err().map(|err| err.to_string());
update_site_replication_repair_task(
&mut operation,
&peer.deployment_id,
task.family(),
*family_index,
match error.as_deref() {
Some(error) => Err(error),
None => Ok(()),
},
)?;
summarize_site_replication_repair_operation(&mut operation);
persist_site_replication_repair_task(&operation, peer, task.family(), task.path()).await?;
}
}
summarize_site_replication_repair_operation(&mut operation);
persist_site_replication_repair_operation(&operation).await?;
json_response(StatusCode::OK, &site_replication_repair_operation_response(&operation))
}
+928
View File
@@ -0,0 +1,928 @@
// 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::*;
pub(crate) const SITE_REPLICATION_RETRY_QUEUE_LIMIT: usize = 256;
pub(crate) const SITE_REPLICATION_RETRY_FAILED_AFTER: u32 = 3;
pub(crate) const SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH: &str = "internal:endpoint-target-refresh";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct SiteReplicationRetryEvent {
pub(crate) id: String,
pub(crate) peer_deployment_id: String,
pub(crate) peer_endpoint: String,
pub(crate) path: String,
pub(crate) retry_count: u32,
pub(crate) failed: bool,
pub(crate) last_error: String,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<OffsetDateTime>,
/// Peer-edit generation whose delivery failed, when the failing send
/// carried one. Settling a *later* success for the same (peer, path) must
/// not erase a failure recorded for a NEWER generation — see
/// [`settle_site_replication_retry_events`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) edit_generation: Option<u64>,
}
pub(crate) fn retry_event_matches(event: &SiteReplicationRetryEvent, peer: &PeerInfo, path: &str) -> bool {
(event.peer_deployment_id == peer.deployment_id || event.peer_endpoint == peer.endpoint) && event.path == path
}
pub(crate) const SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH: &str = "internal:retry-snapshot:iam";
pub(crate) const SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH: &str = "internal:retry-snapshot:bucket-metadata";
pub(crate) fn collapsed_retry_queue_path(path: &str) -> Option<&'static str> {
let base_path = path.split_once('?').map(|(base, _)| base).unwrap_or(path);
match base_path {
"/rustfs/admin/v3/site-replication/peer/iam-item" | SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => {
Some(SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH)
}
"/rustfs/admin/v3/site-replication/peer/bucket-meta" | SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => {
Some(SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH)
}
_ => None,
}
}
pub(crate) fn normalize_collapsed_retry_queue_paths(queue: &mut Vec<SiteReplicationRetryEvent>) -> bool {
let mut changed = false;
let mut normalized: Vec<SiteReplicationRetryEvent> = Vec::with_capacity(queue.len());
for mut event in queue.drain(..) {
if let Some(path) = collapsed_retry_queue_path(&event.path)
&& event.path != path
{
event.path = path.to_string();
changed = true;
}
let duplicate = normalized.iter().position(|existing| {
existing.path == event.path
&& (existing.peer_deployment_id == event.peer_deployment_id || existing.peer_endpoint == event.peer_endpoint)
});
let Some(index) = duplicate else {
normalized.push(event);
continue;
};
changed = true;
let existing = &mut normalized[index];
let event_is_newer = match (event.updated_at, existing.updated_at) {
(Some(event), Some(existing)) => event >= existing,
(Some(_), None) => true,
_ => false,
};
if event_is_newer {
let retry_count = existing.retry_count.max(event.retry_count);
*existing = event;
existing.retry_count = retry_count;
} else {
existing.retry_count = existing.retry_count.max(event.retry_count);
}
existing.failed = existing.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER;
}
*queue = normalized;
changed
}
pub(crate) async fn migrate_collapsed_retry_queue_paths() -> S3Result<()> {
update_site_replication_state_when_changed(|state| {
Ok(if normalize_collapsed_retry_queue_paths(&mut state.retry_queue) {
StateCommit::Changed(())
} else {
StateCommit::Unchanged(())
})
})
.await
}
#[cfg(test)]
pub(crate) fn dequeue_site_replication_retry_events(
queue: &mut Vec<SiteReplicationRetryEvent>,
peer: &PeerInfo,
path: &str,
) -> usize {
settle_site_replication_retry_events(queue, peer, path, None)
}
/// Repair-path settlement: also clears snapshot-escalated entries. Running a
/// repair is the operator's explicit accountability transfer for the
/// possibly-unreplayed deletion the marker records; ordinary delivery
/// successes must not clear it (see [`settle_site_replication_retry_events`]).
pub(crate) fn dequeue_site_replication_retry_events_including_escalated(
queue: &mut Vec<SiteReplicationRetryEvent>,
peer: &PeerInfo,
path: &str,
) -> usize {
let before = queue.len();
let collapsed_path = collapsed_retry_queue_path(path);
queue.retain(|event| {
!retry_event_matches(event, peer, path)
&& !collapsed_path.is_some_and(|collapsed_path| retry_event_matches(event, peer, collapsed_path))
});
before.saturating_sub(queue.len())
}
/// Remove the retry events for (peer, path) that `generation` is entitled to
/// settle. A successful delivery only proves the peer reached the state the
/// delivery carried: while it was in flight another edit can commit, fail its
/// own delivery, and enqueue for the same (peer, path). Erasing that event
/// would leave the peer on the older edit with no retry left, so an event
/// stamped with a NEWER generation survives. `None` settles unconditionally —
/// the broadcast paths that carry no generation, whose retry events live under
/// their own paths and never collide with peer-edit deliveries.
pub(crate) fn settle_site_replication_retry_events(
queue: &mut Vec<SiteReplicationRetryEvent>,
peer: &PeerInfo,
path: &str,
generation: Option<u64>,
) -> usize {
let before = queue.len();
let collapsed_path = collapsed_retry_queue_path(path);
queue.retain(|event| {
if !retry_event_matches(event, peer, path) {
return true;
}
// A wire-path success identifies no IAM or bucket-metadata entity.
// This also protects legacy rows until the startup migration moves
// them under their internal snapshot path.
if collapsed_path.is_some() {
return true;
}
// A snapshot-escalated entry records a possibly-unreplayed deletion.
// Collapsed paths are shared by every entity, so a later successful
// delivery of a DIFFERENT item proves nothing about the deleted one —
// only a repair settles it (dequeue_..._including_escalated).
if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
return true;
}
match (generation, event.edit_generation) {
(Some(settled), Some(failed)) => failed > settled,
_ => false,
}
});
before.saturating_sub(queue.len())
}
pub(crate) fn upsert_site_replication_retry_event(
queue: &mut Vec<SiteReplicationRetryEvent>,
peer: &PeerInfo,
path: &str,
error: &str,
generation: Option<u64>,
) {
let path = collapsed_retry_queue_path(path).unwrap_or(path);
let now = OffsetDateTime::now_utc();
let detail = summarize_peer_error_detail(error);
if let Some(event) = queue.iter_mut().find(|event| retry_event_matches(event, peer, path)) {
event.retry_count = event.retry_count.saturating_add(1);
event.failed = event.retry_count >= SITE_REPLICATION_RETRY_FAILED_AFTER;
event.last_error = detail;
event.updated_at = Some(now);
// Keep the newest generation: an older delivery that fails afterwards
// must not lower the fence and let its own success settle the event.
event.edit_generation = event.edit_generation.max(generation);
return;
}
queue.push(SiteReplicationRetryEvent {
id: Uuid::new_v4().to_string(),
peer_deployment_id: peer.deployment_id.clone(),
peer_endpoint: peer.endpoint.clone(),
path: path.to_string(),
retry_count: 1,
failed: false,
last_error: detail,
updated_at: Some(now),
edit_generation: generation,
});
if queue.len() > SITE_REPLICATION_RETRY_QUEUE_LIMIT {
let overflow = queue.len() - SITE_REPLICATION_RETRY_QUEUE_LIMIT;
queue.drain(0..overflow);
}
}
pub(crate) fn retry_stats_for_state(state: &SiteReplicationState) -> Option<SRRetryStats> {
if state.retry_queue.is_empty() {
return None;
}
Some(SRRetryStats {
pending: state.retry_queue.iter().filter(|event| !event.failed).count(),
failed: state.retry_queue.iter().filter(|event| event.failed).count(),
last_error: state
.retry_queue
.iter()
.rev()
.find_map(|event| (!event.last_error.is_empty()).then(|| event.last_error.clone()))
.unwrap_or_default(),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
})
}
pub(crate) async fn enqueue_site_replication_retry_event(peer: &PeerInfo, path: &str, error: &S3Error) {
enqueue_site_replication_retry_event_for_generation(peer, path, error, None).await
}
pub(crate) async fn enqueue_site_replication_retry_event_for_generation(
peer: &PeerInfo,
path: &str,
error: &S3Error,
generation: Option<u64>,
) {
let peer_owned = peer.clone();
let path_owned = path.to_string();
let error_text = error.to_string();
let result = update_site_replication_state(move |state| {
// A peer that left the state can never drain its entries again
// (remove_sites already pruned them); recording a late failure for it
// would only pollute retry_stats until the queue cap evicts it.
if state.peers.contains_key(&peer_owned.deployment_id) {
upsert_site_replication_retry_event(&mut state.retry_queue, &peer_owned, &path_owned, &error_text, generation);
}
Ok(())
})
.await;
if let Err(err) = result {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
peer = %peer.endpoint,
path,
error = ?err,
"failed to persist site replication retry event"
);
}
}
pub(crate) fn retry_bucket_operation(path: &str) -> Option<String> {
let (base_path, query) = path.split_once('?')?;
if base_path != SITE_REPLICATION_PEER_BUCKET_OPS_PATH {
return None;
}
form_urlencoded::parse(query.as_bytes()).find_map(|(key, value)| (key == "operation").then(|| value.into_owned()))
}
pub(crate) fn retry_event_replayed_by_bootstrap(event: &SiteReplicationRetryEvent) -> bool {
matches!(
retry_bucket_operation(&event.path).as_deref(),
Some(SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION)
)
}
/// Exponential backoff base for the background retry drain, aligned with the
/// reconcile cadence (`site_replication_reconcile::RECONCILE_INTERVAL`).
pub(crate) const SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS: i64 = 600;
/// Backoff ceiling: a permanently failed peer is still probed daily.
pub(crate) const SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS: i64 = 86_400;
/// What the background drain may do for one retry event. Everything not
/// representable here is operator territory (manual repair).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RetryDrainAction {
/// Constant-path IAM item deliveries collapse into one queue entry per
/// peer and their bodies are not persisted; the only faithful replay is
/// the current IAM snapshot from the bootstrap plan.
IamSnapshot,
/// Same collapse for bucket-meta deliveries: replay the bucket metadata
/// snapshot from the bootstrap plan.
BucketMetadataSnapshot,
/// A self-contained bucket op the bootstrap plan can re-derive for its
/// bucket (`make-with-versioning` / `configure-replication`).
BucketOpReplay { operation: String, bucket: String },
/// Re-send the current peer records under a fresh edit generation.
PeerEdit,
}
#[derive(Clone)]
pub(crate) enum RetrySnapshot {
Iam(Vec<SRIAMItem>),
BucketMetadata(Vec<SRBucketMeta>),
}
impl RetrySnapshot {
pub(crate) fn from_plan(action: &RetryDrainAction, plan: &SiteReplicationBootstrapPlan) -> Option<Self> {
match action {
RetryDrainAction::IamSnapshot => Some(Self::Iam(plan.iam_items.clone())),
RetryDrainAction::BucketMetadataSnapshot => Some(Self::BucketMetadata(plan.bucket_items.clone())),
_ => None,
}
}
pub(crate) fn fingerprint(&self) -> S3Result<Vec<Vec<u8>>> {
let mut payloads = match self {
Self::Iam(items) => items.iter().map(serde_json::to_vec).collect::<Result<Vec<_>, _>>(),
Self::BucketMetadata(items) => items.iter().map(serde_json::to_vec).collect::<Result<Vec<_>, _>>(),
}
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize retry snapshot failed: {err}")))?;
payloads.sort_unstable();
Ok(payloads)
}
pub(crate) fn replay_after_change(previous: &Self, fresh: &Self, observed_at: OffsetDateTime) -> Self {
match (previous, fresh) {
(Self::Iam(previous), Self::Iam(fresh)) => {
let fresh_keys: HashSet<IamSnapshotKey> = fresh.iter().filter_map(iam_snapshot_key).collect();
let mut replay = fresh.clone();
for item in previous {
if iam_snapshot_key(item).is_some_and(|key| !fresh_keys.contains(&key)) {
replay.extend(iam_snapshot_tombstones(item, observed_at));
}
}
Self::Iam(replay)
}
(Self::BucketMetadata(previous), Self::BucketMetadata(fresh)) => {
let fresh_keys: HashSet<(&str, &str)> = fresh
.iter()
.map(|item| (item.bucket.as_str(), item.r#type.as_str()))
.collect();
let mut replay = fresh.clone();
for item in previous {
if !fresh_keys.contains(&(item.bucket.as_str(), item.r#type.as_str())) {
replay.push(bucket_metadata_snapshot_tombstone(item, observed_at));
}
}
Self::BucketMetadata(replay)
}
_ => fresh.clone(),
}
}
pub(crate) async fn send(&self, transport: &PeerTransport, access_key: &str, secret_key: &str) -> S3Result<()> {
match self {
Self::Iam(items) => {
for item in items {
SiteReplicationRepairTask::Iam(item)
.send(transport, access_key, secret_key)
.await?;
}
}
Self::BucketMetadata(items) => {
for item in items {
SiteReplicationRepairTask::BucketMetadata(item)
.send(transport, access_key, secret_key)
.await?;
}
}
}
Ok(())
}
}
#[derive(Hash, PartialEq, Eq)]
pub(crate) enum IamSnapshotKey {
Policy(String),
User(String),
Group(String),
PolicyMapping { target: String, user_type: i64, is_group: bool },
}
pub(crate) fn iam_snapshot_key(item: &SRIAMItem) -> Option<IamSnapshotKey> {
match item.r#type.as_str() {
"policy" => Some(IamSnapshotKey::Policy(item.name.clone())),
"iam-user" => item
.iam_user
.as_ref()
.map(|user| IamSnapshotKey::User(user.access_key.clone())),
"group-info" => item
.group_info
.as_ref()
.map(|group| IamSnapshotKey::Group(group.update_req.group.clone())),
"policy-mapping" => item.policy_mapping.as_ref().map(|mapping| IamSnapshotKey::PolicyMapping {
target: mapping.user_or_group.clone(),
user_type: mapping.user_type,
is_group: mapping.is_group,
}),
_ => None,
}
}
pub(crate) fn iam_snapshot_tombstones(item: &SRIAMItem, observed_at: OffsetDateTime) -> Vec<SRIAMItem> {
let mut tombstone = item.clone();
tombstone.updated_at = Some(observed_at);
match item.r#type.as_str() {
"policy" => tombstone.policy = None,
"iam-user" => {
if let Some(user) = tombstone.iam_user.as_mut() {
user.is_delete_req = true;
user.user_req = None;
}
}
"group-info" => {
let Some(group) = tombstone.group_info.as_mut() else {
return Vec::new();
};
group.update_req.is_remove = true;
if group.update_req.members.is_empty() {
return vec![tombstone];
}
let mut delete = tombstone.clone();
if let Some(group) = delete.group_info.as_mut() {
group.update_req.members.clear();
}
return vec![tombstone, delete];
}
"policy-mapping" => {
if let Some(mapping) = tombstone.policy_mapping.as_mut() {
mapping.policy.clear();
}
}
_ => return Vec::new(),
}
vec![tombstone]
}
pub(crate) fn bucket_metadata_snapshot_tombstone(item: &SRBucketMeta, observed_at: OffsetDateTime) -> SRBucketMeta {
SRBucketMeta {
r#type: item.r#type.clone(),
bucket: item.bucket.clone(),
updated_at: Some(observed_at),
expiry_updated_at: Some(observed_at),
api_version: item.api_version.clone(),
derived_rule_contract: item.derived_rule_contract,
..Default::default()
}
}
pub(crate) const SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS: usize = 3;
pub(crate) fn classify_site_replication_retry_event(event: &SiteReplicationRetryEvent) -> Option<RetryDrainAction> {
let snapshot_action = match event.path.as_str() {
SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH => Some(RetryDrainAction::IamSnapshot),
SITE_REPLICATION_RETRY_BUCKET_METADATA_SNAPSHOT_PATH => Some(RetryDrainAction::BucketMetadataSnapshot),
_ => None,
};
if snapshot_action.is_some() && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
return snapshot_action;
}
if event.path.starts_with("internal:") {
// Marker records store payloads in `last_error` (legacy
// pending-endpoint-refresh backup and snapshot liabilities); they are
// not drainable delivery failures.
return None;
}
if event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
// Already snapshot-replayed once for this failure episode; a possible
// deletion cannot be replayed from a snapshot, so re-sending daily
// proves nothing. A new hook failure overwrites the marker.
return None;
}
let base_path = event.path.split_once('?').map(|(base, _)| base).unwrap_or(&event.path);
match base_path {
"/rustfs/admin/v3/site-replication/peer/iam-item" => Some(RetryDrainAction::IamSnapshot),
"/rustfs/admin/v3/site-replication/peer/bucket-meta" => Some(RetryDrainAction::BucketMetadataSnapshot),
SITE_REPLICATION_PEER_EDIT_PATH => Some(RetryDrainAction::PeerEdit),
SITE_REPLICATION_PEER_BUCKET_OPS_PATH => {
let operation = retry_bucket_operation(&event.path)?;
if !matches!(
operation.as_str(),
SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING | SITE_REPLICATION_BUCKET_OP_CONFIGURE_REPLICATION
) {
// Destructive ops (delete-bucket / force-delete-bucket) are
// operator territory: replaying them against a peer whose
// bucket was since recreated is irreversible.
return None;
}
let bucket = retry_bucket_name(&event.path)?;
Some(RetryDrainAction::BucketOpReplay { operation, bucket })
}
_ => None,
}
}
pub(crate) fn retry_bucket_name(path: &str) -> Option<String> {
let (_, query) = path.split_once('?')?;
form_urlencoded::parse(query.as_bytes())
.find_map(|(key, value)| (key == "bucket" && !value.is_empty()).then(|| value.into_owned()))
}
/// A collapsed retry event after a stable snapshot resend is escalated with
/// this marker instead of being cleared: the snapshot contains no task for a
/// failed deletion, so remote absence remains operator-visible. Collapsed
/// failures use an internal queue path so ordinary successes and older nodes
/// cannot settle an unrelated entity's liability.
pub(crate) const SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER: &str = "snapshot replayed; a failed deletion cannot be replayed from a snapshot — run site replication repair or re-deliver to settle";
/// Escalate a collapsed retry event after its snapshot resend succeeded,
/// unless a newer failure was recorded after `snapshot_updated_at` (that
/// failure belongs to a newer local commit the snapshot did not contain and
/// must keep the entry drain-eligible).
pub(crate) fn escalate_site_replication_retry_events_up_to(
queue: &mut Vec<SiteReplicationRetryEvent>,
peer: &PeerInfo,
path: &str,
snapshot_updated_at: Option<OffsetDateTime>,
) -> usize {
let Some(marker_path) = collapsed_retry_queue_path(path) else {
return 0;
};
if path != marker_path {
queue.retain(|event| {
if !retry_event_matches(event, peer, path) {
return true;
}
matches!((event.updated_at, snapshot_updated_at), (Some(current), Some(seen)) if current > seen)
|| matches!((event.updated_at, snapshot_updated_at), (Some(_), None))
});
}
let marker_index = queue.iter().position(|event| retry_event_matches(event, peer, marker_path));
let marker_index = marker_index.unwrap_or_else(|| {
queue.push(SiteReplicationRetryEvent {
id: Uuid::new_v4().to_string(),
peer_deployment_id: peer.deployment_id.clone(),
peer_endpoint: peer.endpoint.clone(),
path: marker_path.to_string(),
updated_at: snapshot_updated_at,
..Default::default()
});
queue.len() - 1
});
let event = &mut queue[marker_index];
let newer_failure_recorded = match (event.updated_at, snapshot_updated_at) {
(Some(current), Some(seen)) => current > seen,
(Some(_), None) => true,
(None, _) => false,
};
if newer_failure_recorded && event.last_error != SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER {
return 0;
}
event.failed = true;
event.retry_count = event.retry_count.max(SITE_REPLICATION_RETRY_FAILED_AFTER);
event.last_error = SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER.to_string();
event.updated_at = Some(OffsetDateTime::now_utc());
1
}
pub(crate) async fn escalate_site_replication_retry_event_up_to(
peer: &PeerInfo,
path: &str,
snapshot_updated_at: Option<OffsetDateTime>,
) {
let peer_owned = peer.clone();
let path_owned = path.to_string();
let result = update_site_replication_state(move |state| {
escalate_site_replication_retry_events_up_to(&mut state.retry_queue, &peer_owned, &path_owned, snapshot_updated_at);
Ok(())
})
.await;
if let Err(err) = result {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
peer = %peer.endpoint,
deployment_id = %peer.deployment_id,
path,
error = ?err,
"failed to escalate site replication retry event"
);
}
}
/// Whether the drain may attempt this event now.
pub(crate) fn site_replication_retry_backoff_elapsed(event: &SiteReplicationRetryEvent, now: OffsetDateTime) -> bool {
let Some(updated_at) = event.updated_at else {
return true;
};
// 600 * 2^8 already exceeds the daily ceiling; capping the shift keeps
// the arithmetic overflow-free for any persisted retry_count.
let exponent = event.retry_count.saturating_sub(1).min(8);
let delay = (SITE_REPLICATION_RETRY_DRAIN_BASE_BACKOFF_SECS << exponent).min(SITE_REPLICATION_RETRY_DRAIN_MAX_BACKOFF_SECS);
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) >= delay
}
/// The subset of the retry queue the background drain is allowed to touch.
pub(crate) fn actionable_site_replication_retry_events(
state: &SiteReplicationState,
now: OffsetDateTime,
) -> Vec<SiteReplicationRetryEvent> {
state
.retry_queue
.iter()
.filter(|event| classify_site_replication_retry_event(event).is_some())
.filter(|event| state.peers.contains_key(&event.peer_deployment_id))
.filter(|event| site_replication_retry_backoff_elapsed(event, now))
.cloned()
.collect()
}
/// Background consumer for the retry queue, run from the reconcile tick.
///
/// Scope: this settles "delivered once and failed" entries whose replay is
/// faithful (bucket ops, peer edits). Collapsed iam-item / bucket-meta
/// entries are snapshot-resent and then *escalated*, not cleared — a failed
/// deletion leaves no task in the snapshot, so remote absence stays unproven
/// until a later delivery or a manual repair. A hook that never fired (crash
/// between the local commit and the send) leaves no entry at all, so the
/// drain is not a full cross-site diff-heal; manual repair remains the
/// authoritative catch-all.
pub(crate) async fn drain_site_replication_retry_queue() {
if let Err(err) = drain_site_replication_retry_queue_inner().await {
warn!(
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
result = "retry_drain_failed",
error = ?err,
"admin site replication state"
);
}
}
pub(crate) async fn drain_site_replication_retry_queue_inner() -> S3Result<()> {
let Some(runtime) = runtime_site_replication_targets().await? else {
return Ok(());
};
let actionable = actionable_site_replication_retry_events(&runtime.state, OffsetDateTime::now_utc());
if actionable.is_empty() {
return Ok(());
}
let Some(store) = current_object_store_handle() else {
return Ok(());
};
if runtime.state.pending_endpoint_refresh.is_some()
|| runtime.state.pending_remove.is_some()
|| runtime.state.pending_rotation.is_some()
{
// The tick-level gate ran before the reconcilers; a multi-step flow
// (endpoint refresh commits its pending marker without the lifecycle
// guard) may have started since. Re-check on the fresh state.
return Ok(());
}
// Serialize against operator repair execution. This does NOT close the
// dry-run -> execute window (dry-run takes no lock): a drain settling a
// replayable bucket-op entry in that window changes the preflight token
// and execute fails safe with "preflight is stale" — the operator
// re-runs the dry-run. Lock order matches repair: lifecycle guard (held
// by the reconcile tick) -> repair execution lock -> state object lock
// inside the send bookkeeping. An operator repair holding the lock makes
// this tick skip after the lock-acquire timeout.
with_config_object_write_lock(store, SITE_REPLICATION_REPAIR_EXECUTION_LOCK_PATH.to_string(), move || async move {
drain_site_replication_retry_queue_locked(runtime, actionable).await
})
.await
.map_err(ApiError::from)?
}
pub(crate) async fn drain_site_replication_retry_queue_locked(
runtime: SiteReplicationRuntime,
events: Vec<SiteReplicationRetryEvent>,
) -> S3Result<()> {
let needs_plan = events
.iter()
.any(|event| !matches!(classify_site_replication_retry_event(event), Some(RetryDrainAction::PeerEdit)));
// The plan is a full local snapshot (buckets + IAM); build it once per
// tick and only when a snapshot resend is actually due.
let plan = if needs_plan {
let info = build_sr_info(&runtime.state, &runtime.local_peer).await?;
Some(site_replication_bootstrap_plan(&info)?)
} else {
None
};
let mut events_by_peer: BTreeMap<String, Vec<SiteReplicationRetryEvent>> = BTreeMap::new();
for event in events {
events_by_peer
.entry(event.peer_deployment_id.clone())
.or_default()
.push(event);
}
let mut settled = 0usize;
let mut failures = 0usize;
for (deployment_id, peer_events) in events_by_peer {
let Some(peer) = runtime.state.peers.get(&deployment_id) else {
continue;
};
if deployment_id == runtime.local_peer.deployment_id
|| same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint)
{
continue;
}
let transport = match PeerTransport::for_runtime_peer(peer).await {
Ok(transport) => transport,
Err(err) => {
// Record the attempt so backoff advances for an unreachable
// peer instead of re-dialing it every tick.
for event in &peer_events {
enqueue_site_replication_retry_event(peer, &event.path, &err).await;
}
failures += peer_events.len();
continue;
}
};
for event in peer_events {
let Some(action) = classify_site_replication_retry_event(&event) else {
continue;
};
match drain_one_site_replication_retry_event(&runtime, peer, &transport, &event, action, plan.as_ref()).await {
Ok(true) => settled += 1,
Ok(false) => {}
Err(_) => failures += 1,
}
}
}
if settled > 0 || failures > 0 {
info!(
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
result = "retry_drain_settled",
settled,
failures,
"admin site replication state"
);
}
Ok(())
}
/// Replay one retry event against its peer. Returns `Ok(true)` when the
/// event was settled (delivered, or provably stale), `Ok(false)` when it was
/// skipped, and `Err` after a failed delivery (already re-queued with an
/// incremented retry count).
pub(crate) async fn drain_one_site_replication_retry_event(
runtime: &SiteReplicationRuntime,
peer: &PeerInfo,
transport: &PeerTransport,
event: &SiteReplicationRetryEvent,
action: RetryDrainAction,
plan: Option<&SiteReplicationBootstrapPlan>,
) -> S3Result<bool> {
let access_key = &runtime.state.service_account_access_key;
let secret_key = &runtime.service_account_secret_key;
match action.clone() {
RetryDrainAction::IamSnapshot | RetryDrainAction::BucketMetadataSnapshot => {
let Some(plan) = plan else {
return Ok(false);
};
let mut current_snapshot = RetrySnapshot::from_plan(&action, plan).expect("snapshot action has a snapshot");
let mut replay = current_snapshot.clone();
for _ in 0..SITE_REPLICATION_RETRY_SNAPSHOT_STABILITY_ATTEMPTS {
let current_fingerprint = current_snapshot.fingerprint()?;
if let Err(err) = replay.send(transport, access_key, secret_key).await {
enqueue_site_replication_retry_event(peer, &event.path, &err).await;
return Err(err);
}
let fresh_info = build_sr_info(&runtime.state, &runtime.local_peer).await?;
let fresh_plan = site_replication_bootstrap_plan(&fresh_info)?;
let fresh_snapshot = RetrySnapshot::from_plan(&action, &fresh_plan).expect("snapshot action has a snapshot");
if fresh_snapshot.fingerprint()? == current_fingerprint {
escalate_site_replication_retry_event_up_to(peer, &event.path, event.updated_at).await;
return Ok(true);
}
replay = RetrySnapshot::replay_after_change(&current_snapshot, &fresh_snapshot, OffsetDateTime::now_utc());
current_snapshot = fresh_snapshot;
}
Ok(false)
}
RetryDrainAction::BucketOpReplay { operation, bucket } => {
let Some(plan) = plan else {
return Ok(false);
};
// Replay from the CURRENT plan, never the recorded path: the
// recorded query can carry an expired one-shot bootstrap token or
// a stale createdAt.
let make_op = operation == SITE_REPLICATION_BUCKET_OP_MAKE_WITH_VERSIONING;
let paths = if make_op {
&plan.bucket_make_ops
} else {
&plan.bucket_configure_ops
};
let tasks: Vec<SiteReplicationRepairTask<'_>> = paths
.iter()
.filter(|path| retry_bucket_name(path).as_deref() == Some(bucket.as_str()))
.map(|path| {
if make_op {
SiteReplicationRepairTask::BucketMake(path)
} else {
SiteReplicationRepairTask::Replication(path)
}
})
.collect();
if tasks.is_empty() {
// The bucket left the plan (deleted, or replication no longer
// configured): the recorded intent is stale, settle it.
dequeue_site_replication_retry_event(peer, &event.path).await;
return Ok(true);
}
for task in &tasks {
if let Err(err) = task.send(transport, access_key, secret_key).await {
enqueue_site_replication_retry_event(peer, &event.path, &err).await;
return Err(err);
}
}
dequeue_site_replication_retry_event(peer, &event.path).await;
Ok(true)
}
RetryDrainAction::PeerEdit => {
// The recorded generation is stale by definition — the receiver
// fences it. Allocate a fresh generation and re-send the current
// peer records (a superset of the failed body; the receiver
// upserts), all inside one state transaction so the fence and the
// bodies agree.
let target_id = peer.deployment_id.clone();
let (generation, bodies) = update_site_replication_state(move |state| {
if !state.peers.contains_key(&target_id) {
return Ok((None, Vec::new()));
}
Ok((Some(next_peer_edit_generation(state)), state.peers.values().cloned().collect::<Vec<_>>()))
})
.await?;
let Some(generation) = generation else {
// Peer left between the snapshot and now; the queue entry was
// already pruned by remove_sites.
return Ok(false);
};
let local_deployment_id = Some(runtime.local_peer.deployment_id.as_str()).filter(|id| !id.is_empty());
let edit_path = peer_edit_path_with_fence(local_deployment_id, generation);
let delivery_fence = local_deployment_id.is_some().then_some(generation);
for body in &bodies {
if let Err(err) = send_peer_admin_request_with_client(
&transport.client,
&transport.connection,
&edit_path,
access_key,
secret_key,
body,
)
.await
{
enqueue_site_replication_retry_event_for_generation(
peer,
SITE_REPLICATION_PEER_EDIT_PATH,
&err,
delivery_fence,
)
.await;
return Err(err);
}
}
dequeue_site_replication_retry_event_for_generation(peer, SITE_REPLICATION_PEER_EDIT_PATH, delivery_fence).await;
Ok(true)
}
}
}
/// Remove a retry event for (peer, path) from the queue on successful delivery.
/// This is a no-op (load + no-op persist skipped) when no matching entry exists,
/// avoiding unnecessary I/O on the common path.
pub(crate) async fn dequeue_site_replication_retry_event(peer: &PeerInfo, path: &str) {
dequeue_site_replication_retry_event_for_generation(peer, path, None).await
}
pub(crate) async fn dequeue_site_replication_retry_event_for_generation(peer: &PeerInfo, path: &str, generation: Option<u64>) {
let result = async {
// Fast path: this sits on every successful hook broadcast, so probe
// with a plain read first and only enter the locked RMW on a hit
// (the transaction re-checks under the lock).
let mut probe = load_site_replication_state().await?;
if settle_site_replication_retry_events(&mut probe.retry_queue, peer, path, generation) == 0 {
return Ok(());
}
let peer_owned = peer.clone();
let path_owned = path.to_string();
update_site_replication_state(move |state| {
settle_site_replication_retry_events(&mut state.retry_queue, &peer_owned, &path_owned, generation);
Ok(())
})
.await?;
Ok::<_, S3Error>(())
}
.await;
if let Err(err) = result {
warn!(
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
peer = %peer.endpoint,
deployment_id = %peer.deployment_id,
path,
error = ?err,
"failed to dequeue site replication retry event"
);
}
}
+593
View File
@@ -0,0 +1,593 @@
// 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::*;
pub(crate) const SITE_REPLICATION_PEER_EDIT_PATH: &str = "/rustfs/admin/v3/site-replication/peer/edit";
/// Peer-edit fencing token, carried as query parameters so a peer that predates
/// the fence simply ignores them (unknown query keys are dropped) and keeps the
/// previous last-writer-wins behaviour.
pub(crate) const SITE_REPLICATION_EDIT_ORIGIN_QUERY: &str = "editOrigin";
pub(crate) const SITE_REPLICATION_EDIT_GENERATION_QUERY: &str = "editGeneration";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct SiteReplicationState {
pub(crate) name: String,
pub(crate) service_account_access_key: String,
#[serde(default, skip_serializing)]
pub(crate) service_account_secret_key: String,
pub(crate) service_account_parent: String,
pub(crate) peers: BTreeMap<String, PeerInfo>,
pub(crate) updated_at: Option<OffsetDateTime>,
pub(crate) resync_status: BTreeMap<String, SRResyncOpStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pending_rotation: Option<PendingRotation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pending_remove: Option<PendingRemove>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pending_endpoint_refresh: Option<PendingEndpointRefresh>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) retry_queue: Vec<SiteReplicationRetryEvent>,
#[serde(default)]
pub(crate) sync_state_initialized: bool,
/// Fencing token for peer-edit delivery, allocated inside the state
/// transaction (the distributed state-object lock). Two nodes of THIS
/// site that accept admin edits concurrently therefore get strictly
/// ordered generations, and a delivery that stalls can be recognised as
/// stale by the receiving site.
#[serde(default)]
pub(crate) edit_generation: u64,
/// Per-origin high-water mark of the peer edits already applied here,
/// keyed by the origin site's deployment id. A delivery whose generation
/// is not above the mark arrived out of order and must not overwrite the
/// newer edit that already landed.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) applied_edit_generations: BTreeMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct PendingEndpointRefresh {
pub(crate) id: String,
pub(crate) peer: PeerInfo,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) remote_peers: BTreeMap<String, PeerInfo>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) acked_deployment_ids: BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct PendingRotation {
pub(crate) id: String,
pub(crate) access_key: String,
pub(crate) parent: String,
pub(crate) new_secret_key: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) secret_candidates: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) peers: BTreeMap<String, PeerInfo>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) acked_deployment_ids: BTreeSet<String>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct PendingRemove {
pub(crate) id: String,
pub(crate) req: SRRemoveReq,
pub(crate) service_account_access_key: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) secret_candidates: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) original_peers: BTreeMap<String, PeerInfo>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) acked_deployment_ids: BTreeSet<String>,
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<OffsetDateTime>,
}
impl SiteReplicationState {
pub(crate) fn enabled(&self) -> bool {
self.peers.len() > 1
}
}
pub(crate) fn parse_site_replication_state(data: &[u8]) -> S3Result<SiteReplicationState> {
let mut state: SiteReplicationState = serde_json::from_slice(data)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?;
state.peers = normalize_peer_map_by_identity(state.peers);
// A peer-edit high-water mark only fences a CURRENT peer. A site that
// leaves drops below two peers, which clears its own state object and
// restarts its generation counter — a mark left over from the previous
// membership must not reject the edits it sends after it rejoins. This
// pruning covers departures THIS site observed; an origin removed
// unilaterally elsewhere stays in this peer map with its mark, and the
// wall-clock floor in `next_peer_edit_generation` is what lifts its
// restarted counter over that mark. Dropping departed origins on load
// also keeps the map bounded.
state
.applied_edit_generations
.retain(|origin, _| state.peers.contains_key(origin));
if !state.sync_state_initialized {
if state.enabled() {
mark_unknown_peer_sync_enabled(&mut state.peers);
}
state.sync_state_initialized = true;
}
Ok(state)
}
pub(crate) async fn load_site_replication_state() -> S3Result<SiteReplicationState> {
let Some(store) = current_object_store_handle() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
match read_admin_config(store, SITE_REPLICATION_STATE_PATH).await {
Ok(data) => parse_site_replication_state(&data),
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()),
Err(err) => Err(S3Error::with_message(
S3ErrorCode::InternalError,
format!("failed to load site replication state: {err}"),
)),
}
}
/// Whether this deployment participates in site replication (two or more
/// peers in the persisted state). Read by the S3 interface layer to gate
/// replication-config edits (MinIO `ErrReplicationDenyEditError` semantics,
/// issue #1948); a state-read failure propagates so the gate fails closed.
pub(crate) async fn site_replication_enabled() -> S3Result<bool> {
Ok(load_site_replication_state().await?.enabled())
}
/// Deployment ids of the remote peers the reconciler derives a
/// `site-repl-<id>` rule for on every bucket (the same peer filter as
/// `build_site_replication_config`); empty when site replication is not
/// enabled. Read by the bucket usecase so an S3 replication-config edit keeps
/// exactly the reconciler-owned rules (issue #1948); a state-read failure
/// propagates so the edit fails closed.
pub(crate) async fn site_replication_edit_context() -> S3Result<(HashSet<String>, OperatorRuleContract)> {
let Some(runtime) = runtime_site_replication_targets().await? else {
// Enabled without a service account is a state this site cannot
// broadcast from either; the peers are still the reconciler's.
let state = load_site_replication_state().await?;
if !state.enabled() {
return Ok((HashSet::new(), OperatorRuleContract::Derived));
}
let peers = remote_peer_deployment_ids(&state, &current_local_runtime_peer(&state));
return Ok((peers, OperatorRuleContract::Legacy));
};
let peers = remote_peer_deployment_ids(&runtime.state, &runtime.local_peer);
let contract = site_replication_operator_rule_contract(&runtime).await;
Ok((peers, contract))
}
/// Whether every remote peer merges replication configs under the derived
/// contract, probed through the peer capability endpoint. A peer that does
/// not (or cannot be asked) pins the cluster to [`OperatorRuleContract::Legacy`]
/// for this edit: consistency across sites wins over keeping the operator's
/// priority values, and the legacy merge keeps their order anyway.
pub(crate) async fn site_replication_operator_rule_contract(runtime: &SiteReplicationRuntime) -> OperatorRuleContract {
let remote_peers: Vec<&PeerInfo> = runtime
.state
.peers
.values()
.filter(|peer| {
peer.deployment_id != runtime.local_peer.deployment_id
&& !same_identity_endpoint(&peer.endpoint, &runtime.local_peer.endpoint)
})
.collect();
let probes = futures::future::join_all(remote_peers.iter().map(|peer| async move {
let transport = PeerTransport::for_runtime_peer(peer).await?;
let (status, body) = send_peer_admin_request_raw_with_client(
&transport.client,
&transport.connection,
SITE_REPLICATION_PEER_DERIVED_RULE_CONTRACT_CAPABILITY_PATH,
&runtime.state.service_account_access_key,
&runtime.service_account_secret_key,
&(),
)
.await?;
peer_capability_response_supported(peer, status, &body)
}))
.await;
operator_rule_contract_from_probes(remote_peers.into_iter().zip(probes))
}
pub(crate) fn operator_rule_contract_from_probes<'a>(
probes: impl IntoIterator<Item = (&'a PeerInfo, S3Result<bool>)>,
) -> OperatorRuleContract {
for (peer, probe) in probes {
match probe {
Ok(true) => {}
Ok(false) => return OperatorRuleContract::Legacy,
Err(err) => {
warn!(
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
result = "derived_rule_contract_probe_failed",
peer = %peer.endpoint,
error = %err,
"admin site replication state"
);
return OperatorRuleContract::Legacy;
}
}
}
OperatorRuleContract::Derived
}
pub(crate) fn remote_peer_deployment_ids(state: &SiteReplicationState, local_peer: &PeerInfo) -> HashSet<String> {
state
.peers
.values()
.filter(|peer| {
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
})
.map(|peer| peer.deployment_id.clone())
.collect()
}
/// Deployment ids of every site in the cluster, this one included: the set
/// a peer's derived rules can name (its rule towards this site carries this
/// site's id). Empty when site replication is not enabled.
pub(crate) async fn site_replication_deployment_ids() -> S3Result<HashSet<String>> {
let state = load_site_replication_state().await?;
if !state.enabled() {
return Ok(HashSet::new());
}
Ok(state.peers.values().map(|peer| peer.deployment_id.clone()).collect())
}
pub(crate) async fn load_site_replication_state_no_lock(store: Arc<ECStore>) -> S3Result<SiteReplicationState> {
match read_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await {
Ok(data) => parse_site_replication_state(&data),
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()),
Err(err) => Err(S3Error::with_message(
S3ErrorCode::InternalError,
format!("failed to load site replication state: {err}"),
)),
}
}
/// Persist-or-clear under an already-held state object lock. Normalizes the
/// peer map exactly once (the historical persist path normalized twice with
/// two full clones — P2-22).
pub(crate) async fn persist_site_replication_state_no_lock(store: Arc<ECStore>, mut state: SiteReplicationState) -> S3Result<()> {
state.peers = normalize_peer_map_by_identity(state.peers);
if state.peers.len() <= 1 && state.pending_rotation.is_none() && state.pending_remove.is_none() {
match delete_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await {
Ok(()) | Err(StorageError::ConfigNotFound) => Ok(()),
Err(err) => Err(S3Error::with_message(S3ErrorCode::InternalError, format!("clear state failed: {err}"))),
}
} else {
let data = serde_json::to_vec(&state)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?;
save_config_no_lock(store, SITE_REPLICATION_STATE_PATH, data)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}")))
}
}
/// What a state transaction closure decided to do with the state it was
/// handed. `Unchanged` skips the write entirely: the ack markers and the
/// pending-clearing paths run on every retry and mostly find their pending id
/// already gone, and the retry queue shares this object — rewriting it byte
/// for byte only makes those misses contend with the writers that do have
/// something to say.
pub(crate) enum StateCommit<T> {
Changed(T),
Unchanged(T),
}
/// The site-replication state RMW transaction: load, mutate, persist — all
/// under the distributed state-object write lock (see
/// crate::site_replication::state_lock). No peer network calls and no other
/// config locks inside `update`; anything that has to talk to a peer belongs
/// between two transactions, with the precondition re-checked inside the
/// second one.
pub(crate) async fn update_site_replication_state<T, F>(update: F) -> S3Result<T>
where
T: Send + 'static,
F: FnOnce(&mut SiteReplicationState) -> S3Result<T> + Send + 'static,
{
update_site_replication_state_when_changed(move |state| update(state).map(StateCommit::Changed)).await
}
/// [`update_site_replication_state`] for closures that may find nothing to
/// do — see [`StateCommit`].
pub(crate) async fn update_site_replication_state_when_changed<T, F>(update: F) -> S3Result<T>
where
T: Send + 'static,
F: FnOnce(&mut SiteReplicationState) -> S3Result<StateCommit<T>> + Send + 'static,
{
with_site_replication_state_lock(move || async move {
let store = current_object_store_handle()
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()))?;
let mut state = load_site_replication_state_no_lock(store.clone()).await?;
match update(&mut state)? {
StateCommit::Changed(result) => {
persist_site_replication_state_no_lock(store, state).await?;
Ok(result)
}
StateCommit::Unchanged(result) => Ok(result),
}
})
.await
}
/// Test-only seeding of the state object. Every production write goes through
/// [`update_site_replication_state`] — this helper is `cfg(test)` so a new
/// call site cannot reintroduce the pre-P1-15 shape (load through one object
/// lock, save through another, with the mutation in between unprotected).
#[cfg(test)]
pub(crate) async fn save_site_replication_state(state: &SiteReplicationState) -> S3Result<()> {
let Some(store) = current_object_store_handle() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let mut normalized = state.clone();
normalized.peers = normalize_peer_map_by_identity(normalized.peers);
let data = serde_json::to_vec(&normalized)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize state failed: {e}")))?;
save_admin_config(store, SITE_REPLICATION_STATE_PATH, data)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("save state failed: {e}")))?;
Ok(())
}
pub(crate) fn request_endpoint(uri: &Uri, headers: &HeaderMap) -> String {
let scheme = get_source_scheme(headers)
.and_then(|value| {
value
.split(',')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
})
.or_else(|| uri.scheme_str().map(str::to_ascii_lowercase))
.unwrap_or_else(|| {
if runtime_tls_enabled() {
"https".to_string()
} else {
"http".to_string()
}
});
let host = headers
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| uri.authority().map(|value| value.as_str().to_string()))
.or_else(|| {
current_endpoints_handle().and_then(|endpoints| {
endpoints
.as_ref()
.iter()
.flat_map(|pool| pool.endpoints.as_ref().iter())
.find(|endpoint| endpoint.is_local)
.map(|endpoint| endpoint.host_port())
})
})
.unwrap_or_else(|| format!("127.0.0.1:{}", current_runtime_port()));
format!("{scheme}://{host}")
}
pub(crate) fn runtime_console_port() -> Option<u16> {
let console_address = get_config_snapshot()
.map(|snapshot| snapshot.console_address.clone())
.unwrap_or_else(|| rustfs_utils::get_env_str(ENV_RUSTFS_CONSOLE_ADDRESS, DEFAULT_CONSOLE_ADDRESS));
let parse_target = if console_address.starts_with(':') {
format!("127.0.0.1{console_address}")
} else {
console_address
};
Url::parse(&format!("http://{parse_target}"))
.ok()
.and_then(|parsed| parsed.port_or_known_default())
}
pub(crate) fn site_replication_local_endpoint(uri: &Uri, headers: &HeaderMap) -> String {
let endpoint = request_endpoint(uri, headers);
match Url::parse(&endpoint) {
Ok(mut parsed) => {
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return request_endpoint(&Uri::from_static("/"), &HeaderMap::new());
}
if parsed.port_or_known_default() == runtime_console_port() && parsed.set_port(Some(current_runtime_port())).is_ok() {
parsed.to_string().trim_end_matches('/').to_string()
} else {
endpoint
}
}
Err(_) => request_endpoint(&Uri::from_static("/"), &HeaderMap::new()),
}
}
pub(crate) fn current_local_runtime_endpoint() -> String {
site_replication_local_endpoint(&Uri::from_static("/"), &HeaderMap::new())
}
pub(crate) fn infer_site_name(endpoint: &str) -> String {
endpoint
.trim_start_matches("http://")
.trim_start_matches("https://")
.split('/')
.next()
.unwrap_or_default()
.split(':')
.next()
.unwrap_or_default()
.to_string()
}
pub(crate) fn stored_peer_tls_settings(stored_peer: Option<&PeerInfo>) -> (bool, String) {
stored_peer
.map(|peer| (peer.skip_tls_verify, peer.ca_cert_pem.clone()))
.unwrap_or_default()
}
/// The local peer record as the given state describes it. Split out of
/// [`current_local_peer`] so a state transaction can rebuild it against the
/// state it just loaded: the request the endpoint came from cannot cross into
/// the transaction closure, but the endpoint itself can.
pub(crate) fn local_peer_at_endpoint(endpoint: String, state: &SiteReplicationState) -> PeerInfo {
let deployment_id = current_deployment_id().unwrap_or_else(|| deployment_id_for_endpoint(&endpoint));
let stored_peer = state.peers.get(&deployment_id);
let (skip_tls_verify, ca_cert_pem) = stored_peer_tls_settings(stored_peer);
PeerInfo {
endpoint: endpoint.clone(),
name: if state.name.is_empty() {
stored_peer
.map(|peer| peer.name.clone())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| infer_site_name(&endpoint))
} else {
state.name.clone()
},
deployment_id,
sync_state: stored_peer.map(|peer| peer.sync_state.clone()).unwrap_or(SyncStatus::Unknown),
default_bandwidth: stored_peer.map(|peer| peer.default_bandwidth.clone()).unwrap_or_default(),
replicate_ilm_expiry: stored_peer.is_some_and(|peer| peer.replicate_ilm_expiry),
object_naming_mode: stored_peer.map(|peer| peer.object_naming_mode.clone()).unwrap_or_default(),
skip_tls_verify,
ca_cert_pem,
api_version: Some(SITE_REPL_API_VERSION.to_string()),
}
}
pub(crate) fn current_local_runtime_peer(state: &SiteReplicationState) -> PeerInfo {
local_peer_at_endpoint(current_local_runtime_endpoint(), state)
}
pub(crate) fn normalize_peer_map_by_identity(peers: BTreeMap<String, PeerInfo>) -> BTreeMap<String, PeerInfo> {
normalize_peer_map_by_identity_with(peers, normalize_peer_info)
}
pub(crate) fn normalize_peer_info(mut peer: PeerInfo) -> PeerInfo {
if peer.deployment_id.is_empty() {
peer.deployment_id = deployment_id_for_endpoint(&peer.endpoint);
}
if peer.name.is_empty() {
peer.name = infer_site_name(&peer.endpoint);
}
if peer.api_version.is_none() {
peer.api_version = Some(SITE_REPL_API_VERSION.to_string());
}
peer
}
pub(crate) async fn site_replicator_service_account_secret(access_key: &str) -> S3Result<String> {
let Some(iam_sys) = current_iam_handle() else {
return Err(s3_error!(InvalidRequest, "iam not init"));
};
iam_sys
.get_site_replicator_service_account_secret(access_key)
.await
.map_err(ApiError::from)
.map_err(Into::into)
}
pub(crate) fn legacy_site_replicator_state_secret(state: &SiteReplicationState) -> Option<String> {
(state.service_account_access_key == SITE_REPLICATOR_SERVICE_ACCOUNT && !state.service_account_secret_key.is_empty())
.then(|| state.service_account_secret_key.clone())
}
pub(crate) fn pending_endpoint_refresh(state: &SiteReplicationState) -> Option<PendingEndpointRefresh> {
state.pending_endpoint_refresh.clone().or_else(|| {
state
.retry_queue
.iter()
.find(|event| event.path == SITE_REPLICATION_ENDPOINT_REFRESH_RETRY_PATH)
.and_then(|event| serde_json::from_str(&event.last_error).ok())
})
}
/// The wall clock in unix nanoseconds, clamped into u64. A pre-1970 (or
/// post-2554) clock yields 0, which makes the hybrid allocation below
/// degrade to the plain `previous + 1` counter — monotone, never panicking.
pub(crate) fn edit_generation_wall_clock() -> u64 {
u64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(0)
}
/// Allocate the next peer-edit generation as a hybrid logical clock:
/// `max(wall clock in unix nanoseconds, previous + 1)`. Called inside the
/// state transaction, so the value is handed out under the distributed
/// state-object lock and two nodes of this site can never take the same one
/// (`previous + 1` keeps the sequence strictly increasing even when two
/// allocations land in one clock tick, and keeps it monotone on a node
/// whose clock stepped backwards mid-lifetime).
///
/// The wall-clock floor is what survives the counter's death. A site
/// removed while unreachable — the receiver never dropped it from its peer
/// map, so the load-time mark pruning in `parse_site_replication_state`
/// never fired — that later rejoins recreates its state object with the
/// counter back at zero. A plain counter would then hand out generations
/// below the receiver's stale high-water mark and every delivery would be
/// silently fenced until the counter caught up. Jumping to wall time clears
/// that mark: every value the deleted lifetime handed out was capped by the
/// wall clock at its own allocation (or by a prior lifetime's cap, applied
/// inductively), so the recreated lifetime's first allocation exceeds them
/// all — while a pre-removal delivery still in flight stays below the new
/// floor and remains correctly fenced. Marks recorded by pre-hybrid
/// receivers (small plain-counter values) sit far below any wall-clock
/// value, so a restarted origin passes those too — the fix needs only the
/// sender upgraded, nothing on the wire or in the receiver changed.
///
/// A wall clock that regresses across a delete/recreate (the recreating
/// node's clock behind the clock that fed the previous lifetime) mints
/// below the stale mark and the origin stays fenced — but only until real
/// time passes the previous lifetime's last allocation, because every later
/// allocation takes the wall-clock floor again (and never longer than
/// [`PEER_EDIT_FENCE_STALENESS_WINDOW_NANOS`]: a regression past the window
/// leaves the mark implausibly distant and the origin runs unfenced
/// immediately). Bounded by the skew,
/// self-healing, and no rollback window beyond the plain counter's: a
/// delivery applies only at or above the receiver's mark, so the one
/// cross-lifetime interleaving that can apply stale content — a
/// pre-removal delivery whose generation lands above everything the
/// regressed new lifetime has minted — required the same straggler landing
/// above the mark under the plain counter, where the recreated counter's
/// low restart made it strictly easier to hit.
pub(crate) fn next_peer_edit_generation(state: &mut SiteReplicationState) -> u64 {
state.edit_generation = edit_generation_wall_clock().max(state.edit_generation.saturating_add(1));
state.edit_generation
}
/// Build the peer-edit request path carrying the fencing token. The bare
/// constant stays the retry-queue key: the query only fences the wire
/// delivery, and a per-generation key would make every retry event unique.
/// Without a local deployment id there is nothing to fence against, so the
/// unstamped path is sent and the receiver keeps its pre-fence behaviour.
pub(crate) fn peer_edit_path_with_fence(origin: Option<&str>, generation: u64) -> String {
let Some(origin) = origin.filter(|origin| !origin.is_empty()) else {
return SITE_REPLICATION_PEER_EDIT_PATH.to_string();
};
let query = form_urlencoded::Serializer::new(String::new())
.append_pair(SITE_REPLICATION_EDIT_ORIGIN_QUERY, origin)
.append_pair(SITE_REPLICATION_EDIT_GENERATION_QUERY, &generation.to_string())
.finish();
format!("{SITE_REPLICATION_PEER_EDIT_PATH}?{query}")
}
@@ -34,12 +34,11 @@
//! Lock order: lifecycle -> bucket operation -> repair admission
//! -> state object lock -> per-bucket metadata.
use crate::admin::storage_api::runtime::ECStore;
use crate::admin::storage_api::s3::{S3Error, S3ErrorCode, S3Result};
use crate::storage::storage_api::with_config_object_write_lock;
use super::{S3Error, S3ErrorCode, S3Result};
use crate::storage_api::site_replication::{ECStore, with_config_object_write_lock};
use std::sync::Arc;
use super::runtime_sources::current_object_store_handle;
use crate::runtime_sources::current_object_store_handle;
/// Config object holding the whole site-replication state, including the
/// retry-event queue. Shared by the typed handler-side accessors and the
File diff suppressed because it is too large Load Diff
+48
View File
@@ -216,6 +216,54 @@ pub(crate) mod server {
}
}
/// Storage surface of the site-replication service module
/// (`crate::site_replication`, backlog#1840): bucket metadata, bucket
/// targets, replication-config primitives, and the config-object lock
/// helpers its state transaction runs on.
pub(crate) mod site_replication {
pub(crate) use super::storage_contracts::{BucketOperations, BucketOptions};
pub(crate) use crate::storage::storage_api::ecstore_bucket::bucket_target_sys::BucketTargetSys;
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata::{
BUCKET_REPLICATION_CONFIG, BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BucketMetadata,
};
pub(crate) use crate::storage::storage_api::ecstore_bucket::replication::{
OperatorRuleContract, assign_site_replication_rule_priorities, is_site_replication_role,
replication_target_arn_deployment_id, site_replication_rule_deployment_id,
};
pub(crate) use crate::storage::storage_api::ecstore_bucket::target::{
ARN, BucketTarget, BucketTargetType, BucketTargets, Credentials,
};
pub(crate) use crate::storage::storage_api::ecstore_bucket::utils::{deserialize, serialize};
pub(crate) use crate::storage::storage_api::ecstore_bucket::versioning::VersioningApi;
#[cfg(test)]
pub(crate) use crate::storage::storage_api::ecstore_config::com::save_config;
pub(crate) use crate::storage::storage_api::{
ECStore, EndpointServerPools, StorageError, delete_config_no_lock, lock_bucket_targets_metadata, read_config,
read_config_no_lock, save_config_no_lock, with_config_object_read_lock, with_config_object_write_lock,
};
pub(crate) mod metadata_sys {
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata_sys::{
capture_bucket_metadata_incarnation, get, get_replication_config, get_versioning_config, list_bucket_targets,
update_if_incarnation,
};
}
/// S3 wire types for the service module, funneled here so the module
/// itself stays off the direct s3s surface (s3s footprint ratchet).
pub(crate) mod s3 {
pub(crate) use s3s::dto::{
BucketLifecycleConfiguration, BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus,
DeleteReplication, DeleteReplicationStatus, Destination, ExistingObjectReplication, ExistingObjectReplicationStatus,
LifecycleRule, ReplicaModifications, ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule,
ReplicationRuleStatus, SourceSelectionCriteria, VersioningConfiguration,
};
pub(crate) use s3s::{Body, S3Error, S3ErrorCode, S3Response, S3Result, s3_error};
}
}
pub(crate) mod startup {
pub(crate) mod heal_control {
#[cfg(test)]
+8
View File
@@ -29,6 +29,14 @@ checked_files=(
"rustfs/src/admin/handlers/kms_keys.rs"
"rustfs/src/admin/handlers/kms_key_lifecycle.rs"
"rustfs/src/admin/handlers/site_replication.rs"
"rustfs/src/site_replication/mod.rs"
"rustfs/src/site_replication/identity.rs"
"rustfs/src/site_replication/state_lock.rs"
"rustfs/src/site_replication/state.rs"
"rustfs/src/site_replication/transport.rs"
"rustfs/src/site_replication/retry.rs"
"rustfs/src/site_replication/repair.rs"
"rustfs/src/site_replication/hooks.rs"
"rustfs/src/admin/handlers/group.rs"
"rustfs/src/admin/handlers/quota.rs"
"rustfs/src/admin/handlers/rebalance.rs"
+7 -1
View File
@@ -38,8 +38,14 @@ cd "$(dirname "$0")/.."
# files, zero new s3s code — the same handler-layer surface redistributed).
# The file counter is split-sensitive; the s3_error! line counter confirms
# no growth (unchanged at 1620).
# 1620 → 1616 on 2026-08-27: backlog#1840 moved the site-replication service
# subsystem to rustfs/src/site_replication/ (s3s access funneled through the
# root storage facade's s3 shim, keeping the file count at 215). The move
# inlined one s3_error! call in transport.rs (+1); measured 1615 on the
# pre-move main (after #6694) and 1616 after, so the slack 1620 baseline is
# retightened to the measured 1616.
S3S_IMPORT_FILES_BASELINE=215
S3_ERROR_LINES_BASELINE=1620
S3_ERROR_LINES_BASELINE=1616
# ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not
# know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming*
# client was extracted to crates/s3-client, where s3s usage is legitimate;
-3
View File
@@ -18,9 +18,6 @@ cycle|app<->infra
cycle|app<->interface
cycle|infra<->interface
dep|rustfs/src/app/admin_usecase.rs|app->interface|crate::server::collect_dependency_readiness_report
dep|rustfs/src/app/bucket_usecase.rs|app->interface|crate::admin::handlers::site_replication::site_replication_bucket_meta_hook
dep|rustfs/src/app/bucket_usecase.rs|app->interface|crate::admin::handlers::site_replication::site_replication_delete_bucket_hook
dep|rustfs/src/app/bucket_usecase.rs|app->interface|crate::admin::handlers::site_replication::site_replication_make_bucket_hook
dep|rustfs/src/cluster_snapshot.rs|infra->interface|crate::server::snapshot_dependency_readiness_report
dep|rustfs/src/runtime_sources.rs|infra->app|crate::app::context
dep|rustfs/src/storage/ecfs_extend.rs|infra->interface|crate::server::cors