mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-18 10:43:15 +00:00
2ecf6b4575
* test(replication): pin the version-fidelity probe contract (red) P1-19 (rustfs/backlog#1675 B2): the supported replication contract is targets that adopt the source version id — a target that mints its own ids silently breaks every version-addressed operation that follows (version deletes, heal re-drives never match), diverging the two sides with no signal. replication-check already captures the probe PUT's response version id but never compares it. Red evidence (current main): against a FakeS3Target with assign_own_version_ids enabled, ?replication-check returns Status "OK" — the drift is invisible. test_replication_check_flags_version_minting_target expects a VersionFidelity phase that fails with the machine-readable code BucketRemoteTargetVersionMismatch, skips the later mutation phases, and still cleans up the probe via the version id the target actually assigned. Test infra: FakeS3Target gains assign_own_version_ids (models a generic S3 service; validated-but-not-mirrored source version headers) and a prefix+max-keys ListObjectVersions implementation (the probe key allocation requires it); stored_versions accessor duplicated from the P1-21 branch (identical code, resolves clean on merge). * fix(replication): probe the version-identity contract in replication-check P1-19 (rustfs/backlog#1675 B2, plan B). Replication only converges on targets that adopt the source version id: version-addressed deletes and heal re-drives address the source id, so a target that mints its own ids silently diverges — nothing surfaced this. replication-check already captured the probe PUT's response version id but never compared it. - The probe PUT now carries the source version as `?versionId=` (the exact shape live replication uses since P0-5, and the only shape MinIO consumes; the internal source-version-id header alone would let the probe pass against targets the real data path drifts on). Reuses ecstore's append_version_id_query through the api facade. - New VersionFidelity phase: the probe PUT's response version id must equal the sent source id. On mismatch the phase fails with the machine-readable extension key `"Code": "BucketRemoteTargetVersionMismatch"` (new optional Code field on phase statuses; Go decoders ignore unknown keys), the overall target fails, the later version-addressed mutation phases are skipped, and cleanup still removes the probe via the id the target actually assigned (with the existing list-based sweep as backstop when the target returns no version id at all). - Runtime half: TargetClient::put_object now returns the assigned version id (mirroring remove_object), and the replication PUT path audits it — every drifting PUT increments rustfs_replication_version_identity_drift_total and the first drift per target ARN logs a structured warning pointing at ?replication-check. The drift judgment is a pure function with an exemption-matrix test (empty / literal "null" / nil-uuid sources carry no contract). - docs/operations/replication-check.md documents the phase and the code. Red -> green: test_replication_check_flags_version_minting_target (fake target with assign_own_version_ids; on main the check reported Status "OK"). The probe's query shape is pinned by a journal assertion (revert of the query hunk alone fails it), probe-level unit tests cover the mismatch/mirror matrix including cleanup addressing the minted id, and the existing success e2e now asserts VersionFidelity OK against a RustFS target. Adversarial review (seven roles): non-blocking; noted follow-ups are the multipart runtime audit (the probe phase already pins the contract) and per-target re-warning after reconfiguration. * fix(e2e): stop the fake target self-deadlocking on version-id minting The assign_own_version_ids flag was read with a fresh `lock(&self.store)` inside two paths that already hold that guard — delete_object's marker-creation branch and create_multipart_upload — and the store mutex is not reentrant, so both hung forever (CI: the fake target's own multipart and delete-marker tests ran >1560s until the job was cancelled). Read the flag from the live guard instead. The replication e2e paths did not catch this: a version-addressed purge DELETE never mints an id, and the probe PUT reads the flag before taking the guard. * chore(test): refresh the nextest replication count invariant The e2e-smoke/e2e-repl-nightly split comment is descriptive metadata (authority: `cargo nextest list`); refresh it to this branch's post-rebase total.
4610 lines
189 KiB
Rust
4610 lines
189 KiB
Rust
// 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::replication_bandwidth_boundary;
|
|
use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt as _};
|
|
use super::replication_config_store::ReplicationConfigStore;
|
|
use super::replication_error_boundary::{Result, is_err_object_not_found, is_err_version_not_found};
|
|
use super::replication_event_sink::{EventArgs, send_event, send_local_event};
|
|
use super::replication_filemeta_boundary::{
|
|
MrfReplicateEntry, NULL_VERSION_ID, REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, ReplicateDecision, ReplicateObjectInfo,
|
|
ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction, ReplicationState, ReplicationStatusType, ReplicationType,
|
|
ReplicationWorkerOperation, VersionPurgeStatusType, get_replication_state, parse_replicate_decision,
|
|
replication_statuses_map, target_reset_header, version_purge_statuses_map,
|
|
};
|
|
use super::replication_lock_boundary::ReplicationLockTiming;
|
|
use super::replication_logging::{EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION_RESYNC};
|
|
use super::replication_metadata_boundary::ReplicationMetadataStore;
|
|
#[cfg(test)]
|
|
use super::replication_msgp_boundary::ReplicationMsgpCodec;
|
|
use super::replication_object_config::{ReplicationConfig, get_replication_config, must_replicate};
|
|
use super::replication_object_decision_boundary::{
|
|
MustReplicateOptions, ReplicationMultipartPartInput, heal_uses_delete_replication_path,
|
|
is_retryable_delete_replication_head_error, is_version_delete_replication, replication_etags_match,
|
|
replication_multipart_complete_actual_size, replication_multipart_part_plan, should_retry_delete_marker_purge,
|
|
};
|
|
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
|
|
use super::replication_resync_boundary::ResyncStatusType;
|
|
use super::replication_resync_boundary::{
|
|
BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus, encode_resync_file, is_version_id_mismatch,
|
|
resync_state_accepts_update, sanitize_resync_error_detail, should_count_head_proxy_failure,
|
|
};
|
|
#[cfg(test)]
|
|
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX, decode_resync_file};
|
|
use super::replication_storage_boundary::{
|
|
AdvancedGetOptions, EcstoreObjectOperations, HTTPRangeSpec, ObjectInfo, ObjectOptions, ObjectToDelete,
|
|
ReplicationDeletedObject, ReplicationObjectIO, ReplicationStorage, StatObjectOptions, WalkOptions,
|
|
};
|
|
use super::replication_target_boundary::{
|
|
PutObjectOptions, PutObjectPartOptions, ReplicationTargetStore, TargetClient, replication_action_for_target_head,
|
|
replication_complete_multipart_options, replication_delete_marker_purge_remove_options, replication_delete_remove_options,
|
|
replication_force_delete_remove_options, replication_object_is_ssec_encrypted, replication_put_object_header_size,
|
|
replication_put_object_options, replication_target_head_is_newer_null_version,
|
|
};
|
|
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
|
use super::runtime_boundary as runtime_sources;
|
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
|
use aws_sdk_s3::operation::head_object::{HeadObjectError, HeadObjectOutput};
|
|
use aws_sdk_s3::primitives::ByteStream;
|
|
use aws_sdk_s3::types::CompletedPart;
|
|
use aws_smithy_types::body::SdkBody;
|
|
use futures::future::join_all;
|
|
use futures::stream::StreamExt;
|
|
use http::HeaderMap;
|
|
use http_body::Frame;
|
|
use http_body_util::StreamBody;
|
|
use metrics::counter;
|
|
#[cfg(test)]
|
|
use rmp_serde;
|
|
use rustfs_s3_types::EventName;
|
|
use rustfs_utils::http::{
|
|
AMZ_TAGGING_DIRECTIVE, SUFFIX_REPLICATION_RESET, SUFFIX_REPLICATION_STATUS, has_internal_suffix, insert_str,
|
|
};
|
|
use rustfs_utils::{DEFAULT_SIP_HASH_KEY, get_env_usize, sip_hash};
|
|
#[cfg(test)]
|
|
use s3s::dto::ReplicationConfiguration;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fmt::Display;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use tokio::io::AsyncRead;
|
|
use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore};
|
|
use tokio::task::{JoinHandle, JoinSet};
|
|
use tokio::time::Duration as TokioDuration;
|
|
use tokio_util::io::ReaderStream;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::{debug, error, instrument, trace, warn};
|
|
|
|
const BACKGROUND_WALKDIR_TIMEOUT: TokioDuration = TokioDuration::from_secs(60);
|
|
const ENV_REPL_RESYNC_MAX_JOBS: &str = "RUSTFS_REPL_RESYNC_MAX_JOBS";
|
|
const DEFAULT_REPL_RESYNC_MAX_JOBS: usize = 2;
|
|
const MAX_REPL_RESYNC_MAX_JOBS: usize = 32;
|
|
use uuid::Uuid;
|
|
|
|
const EVENT_RESYNC_STATUS_UPDATE_SKIPPED: &str = "replication_resync_status_update_skipped";
|
|
const EVENT_RESYNC_OBJECT_PROCESSED: &str = "replication_resync_object_processed";
|
|
const EVENT_RESYNC_RUNTIME_SKIPPED: &str = "replication_resync_runtime_skipped";
|
|
const EVENT_REPLICATION_DELETE_SKIPPED: &str = "replication_delete_skipped";
|
|
const EVENT_REPLICATION_FORCE_DELETE_SKIPPED: &str = "replication_force_delete_skipped";
|
|
const EVENT_RESYNC_TASK_FAILED: &str = "replication_resync_task_failed";
|
|
const EVENT_RESYNC_TARGET_OPERATION_FAILED: &str = "replication_resync_target_operation_failed";
|
|
const EVENT_RESYNC_RUNTIME_CHANNEL_FAILED: &str = "replication_resync_runtime_channel_failed";
|
|
const EVENT_DELETE_MARKER_PURGE_FAILED: &str = "replication_delete_marker_purge_failed";
|
|
const EVENT_DELETE_MARKER_PURGE_MRF: &str = "replication_delete_marker_purge_mrf";
|
|
const METRIC_DELETE_MARKER_PURGE_TOTAL: &str = "rustfs_replication_delete_marker_purge_total";
|
|
const EVENT_REPLICATION_VERSION_IDENTITY_DRIFT: &str = "replication_version_identity_drift";
|
|
const REPLICATION_TARGET_OFFLINE_ERROR_MARKERS: &[&str] = &[
|
|
"dispatch failure",
|
|
"timeouterror",
|
|
"timed out",
|
|
"connection refused",
|
|
"connection reset",
|
|
"connection closed",
|
|
"connection aborted",
|
|
"broken pipe",
|
|
"dns error",
|
|
"failed to lookup address",
|
|
"name or service not known",
|
|
"deadline has elapsed",
|
|
"tcp connect error",
|
|
];
|
|
|
|
const RESYNC_TIME_INTERVAL: TokioDuration = TokioDuration::from_secs(60);
|
|
|
|
static WARNED_MONITOR_UNINIT: std::sync::Once = std::sync::Once::new();
|
|
|
|
fn resync_target_error_detail<E, R>(error: &SdkError<E, R>) -> Option<String>
|
|
where
|
|
E: ProvideErrorMetadata,
|
|
{
|
|
sanitize_resync_error_detail(error.code().unwrap_or(match error {
|
|
SdkError::ConstructionFailure(_) => "failed to construct target request",
|
|
SdkError::TimeoutError(_) => "target request timed out",
|
|
SdkError::DispatchFailure(_) => "target dispatch failed",
|
|
SdkError::ResponseError(_) => "invalid target response",
|
|
SdkError::ServiceError(_) => "target service error",
|
|
_ => "target request failed",
|
|
}))
|
|
}
|
|
|
|
async fn finish_resync_workers(
|
|
worker_txs: Vec<tokio::sync::mpsc::Sender<ReplicateObjectInfo>>,
|
|
results_tx: tokio::sync::mpsc::Sender<TargetReplicationResyncStatus>,
|
|
futures: Vec<JoinHandle<()>>,
|
|
abort: bool,
|
|
) -> bool {
|
|
drop(worker_txs);
|
|
drop(results_tx);
|
|
|
|
if abort {
|
|
for future in &futures {
|
|
future.abort();
|
|
}
|
|
}
|
|
|
|
let mut failed = false;
|
|
for result in join_all(futures).await {
|
|
if let Err(err) = result
|
|
&& !(abort && err.is_cancelled())
|
|
{
|
|
failed = true;
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
error = %err,
|
|
"Replication resync task failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
failed
|
|
}
|
|
|
|
fn has_raw_status(err: &SdkError<HeadObjectError>, status: u16) -> bool {
|
|
err.raw_response().is_some_and(|r| r.status().as_u16() == status)
|
|
}
|
|
|
|
fn is_head_proxy_failure(err: &SdkError<HeadObjectError>) -> bool {
|
|
let (is_not_found, code) = err
|
|
.as_service_error()
|
|
.map(|service_err| (service_err.is_not_found(), service_err.code()))
|
|
.unwrap_or((false, None));
|
|
let raw_status = err.raw_response().map(|resp| resp.status().as_u16());
|
|
should_count_head_proxy_failure(is_not_found, code, raw_status)
|
|
}
|
|
|
|
const METRIC_VERSION_IDENTITY_DRIFT_TOTAL: &str = "rustfs_replication_version_identity_drift_total";
|
|
|
|
/// Targets that already produced a version-identity-drift warning this
|
|
/// process lifetime, by ARN. Deduping is advisory only (the metric still
|
|
/// counts every drifting PUT), so a reconfigured target re-warning only
|
|
/// after a restart is acceptable.
|
|
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
|
|
|
|
/// Runtime half of the P1-19 version-identity contract (the explicit probe
|
|
/// lives in replication-check's VersionFidelity phase): every replication PUT
|
|
/// response reveals whether the target adopted the source version id. A
|
|
/// target minting its own ids silently breaks version-addressed deletes and
|
|
/// heal, so surface it — once per target — instead of letting the divergence
|
|
/// accumulate unseen.
|
|
/// Pure drift judgment: the contract only applies when the source addressed a
|
|
/// real (non-nil) version uuid, and drift means the target answered with
|
|
/// anything else — including nothing at all.
|
|
fn version_identity_drifted(source_version_id: &str, assigned_version_id: Option<&str>) -> bool {
|
|
if source_version_id.is_empty() {
|
|
return false;
|
|
}
|
|
// A nil source uuid travels as the literal "null" (unversioned-source
|
|
// semantics); no identity contract applies to it.
|
|
if Uuid::parse_str(source_version_id).map(|uuid| uuid.is_nil()).unwrap_or(true) {
|
|
return false;
|
|
}
|
|
assigned_version_id != Some(source_version_id)
|
|
}
|
|
|
|
fn audit_target_version_identity(tgt_client: &TargetClient, source_version_id: &str, assigned_version_id: Option<&str>) {
|
|
if !version_identity_drifted(source_version_id, assigned_version_id) {
|
|
return;
|
|
}
|
|
counter!(METRIC_VERSION_IDENTITY_DRIFT_TOTAL).increment(1);
|
|
let mut warned = VERSION_IDENTITY_WARNED_ARNS
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
if warned.insert(tgt_client.arn.clone()) {
|
|
warn!(
|
|
event = EVENT_REPLICATION_VERSION_IDENTITY_DRIFT,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
arn = %tgt_client.arn,
|
|
endpoint = %tgt_client.endpoint,
|
|
sent_version_id = %source_version_id,
|
|
assigned_version_id = assigned_version_id.unwrap_or("<none>"),
|
|
"Replication target does not adopt source version ids; version-addressed replication cannot converge (run ?replication-check for details)"
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn record_proxy_request(bucket: &str, api: &str, is_err: bool) {
|
|
if let Some(stats) = runtime_sources::replication_stats() {
|
|
stats.inc_proxy(bucket, api, is_err).await;
|
|
}
|
|
}
|
|
|
|
async fn head_object_with_proxy_stats(
|
|
source_bucket: &str,
|
|
target_client: &TargetClient,
|
|
target_bucket: &str,
|
|
object: &str,
|
|
version_id: Option<String>,
|
|
) -> std::result::Result<HeadObjectOutput, SdkError<HeadObjectError>> {
|
|
let result = target_client.head_object(target_bucket, object, version_id).await;
|
|
let is_err = result.as_ref().err().is_some_and(is_head_proxy_failure);
|
|
record_proxy_request(source_bucket, "HeadObject", is_err).await;
|
|
result
|
|
}
|
|
|
|
fn is_version_id_format_mismatch(err: &SdkError<HeadObjectError>) -> bool {
|
|
let code = err.as_service_error().and_then(|se| se.code());
|
|
let raw_status = err.raw_response().map(|r| r.status().as_u16());
|
|
is_version_id_mismatch(code, raw_status)
|
|
}
|
|
|
|
fn is_replication_target_offline_error(err: &(impl Display + ?Sized)) -> bool {
|
|
let message = err.to_string().to_ascii_lowercase();
|
|
REPLICATION_TARGET_OFFLINE_ERROR_MARKERS
|
|
.iter()
|
|
.any(|marker| message.contains(marker))
|
|
}
|
|
|
|
async fn mark_replication_target_offline_if_needed(target_client: &Arc<TargetClient>, err: &(impl Display + ?Sized)) {
|
|
if is_replication_target_offline_error(err) {
|
|
ReplicationTargetStore::mark_target_offline(target_client).await;
|
|
}
|
|
}
|
|
|
|
async fn head_object_fallback(
|
|
source_bucket: &str,
|
|
tgt_client: &TargetClient,
|
|
object: &str,
|
|
) -> std::result::Result<Option<HeadObjectOutput>, SdkError<HeadObjectError>> {
|
|
match head_object_with_proxy_stats(source_bucket, tgt_client, &tgt_client.bucket, object, None).await {
|
|
Ok(oi) => Ok(Some(oi)),
|
|
Err(e) if e.as_service_error().is_some_and(|se| se.is_not_found()) || has_raw_status(&e, 404) => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
static RESYNC_WORKER_COUNT: usize = 10;
|
|
|
|
fn resync_status_duration(
|
|
status: ResyncStatusType,
|
|
start_time: Option<OffsetDateTime>,
|
|
now: OffsetDateTime,
|
|
) -> Option<std::time::Duration> {
|
|
if !matches!(
|
|
status,
|
|
ResyncStatusType::ResyncCompleted | ResyncStatusType::ResyncFailed | ResyncStatusType::ResyncCanceled
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
let millis = (now - start_time?).whole_milliseconds();
|
|
if millis < 0 {
|
|
return None;
|
|
}
|
|
|
|
let millis = if millis > i128::from(u64::MAX) {
|
|
u64::MAX
|
|
} else {
|
|
u64::try_from(millis).ok()?
|
|
};
|
|
Some(std::time::Duration::from_millis(millis))
|
|
}
|
|
|
|
type ResyncCancelKey = (String, String, String);
|
|
|
|
fn configured_resync_max_jobs() -> usize {
|
|
bounded_resync_max_jobs(get_env_usize(ENV_REPL_RESYNC_MAX_JOBS, DEFAULT_REPL_RESYNC_MAX_JOBS))
|
|
}
|
|
|
|
fn bounded_resync_max_jobs(value: usize) -> usize {
|
|
value.clamp(1, MAX_REPL_RESYNC_MAX_JOBS)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ReplicationResyncer {
|
|
pub status_map: Arc<RwLock<HashMap<String, BucketReplicationResyncStatus>>>,
|
|
pub worker_size: usize,
|
|
pub(crate) cancel_tokens: Arc<RwLock<HashMap<ResyncCancelKey, CancellationToken>>>,
|
|
resync_admission: Arc<Semaphore>,
|
|
}
|
|
|
|
impl ReplicationResyncer {
|
|
pub async fn new() -> Self {
|
|
Self {
|
|
status_map: Arc::new(RwLock::new(HashMap::new())),
|
|
worker_size: RESYNC_WORKER_COUNT,
|
|
cancel_tokens: Arc::new(RwLock::new(HashMap::new())),
|
|
resync_admission: Arc::new(Semaphore::new(configured_resync_max_jobs())),
|
|
}
|
|
}
|
|
|
|
async fn acquire_resync_admission(&self, cancellation_token: &CancellationToken) -> Option<OwnedSemaphorePermit> {
|
|
tokio::select! {
|
|
permit = self.resync_admission.clone().acquire_owned() => permit.ok(),
|
|
_ = cancellation_token.cancelled() => None,
|
|
}
|
|
}
|
|
|
|
fn cancel_key(opts: &ResyncOpts) -> ResyncCancelKey {
|
|
(opts.bucket.clone(), opts.arn.clone(), opts.resync_id.clone())
|
|
}
|
|
|
|
pub async fn register_cancel_token(&self, opts: &ResyncOpts, token: CancellationToken) -> bool {
|
|
let mut cancel_tokens = self.cancel_tokens.write().await;
|
|
match cancel_tokens.entry(Self::cancel_key(opts)) {
|
|
std::collections::hash_map::Entry::Vacant(entry) => {
|
|
entry.insert(token);
|
|
true
|
|
}
|
|
std::collections::hash_map::Entry::Occupied(_) => false,
|
|
}
|
|
}
|
|
|
|
pub async fn clear_cancel_token(&self, opts: &ResyncOpts) {
|
|
self.cancel_tokens.write().await.remove(&Self::cancel_key(opts));
|
|
}
|
|
|
|
pub async fn cancel(&self, opts: &ResyncOpts) {
|
|
if let Some(token) = self.cancel_tokens.write().await.remove(&Self::cancel_key(opts)) {
|
|
token.cancel();
|
|
}
|
|
}
|
|
|
|
pub async fn mark_status<S>(&self, status: ResyncStatusType, opts: ResyncOpts, obj_layer: Arc<S>) -> Result<()>
|
|
where
|
|
S: ReplicationObjectIO,
|
|
{
|
|
let (bucket_status, status_duration) = {
|
|
let mut status_map = self.status_map.write().await;
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
let bucket_status = if let Some(bucket_status) = status_map.get_mut(&opts.bucket) {
|
|
bucket_status
|
|
} else {
|
|
let mut bucket_status = BucketReplicationResyncStatus::new();
|
|
bucket_status.id = 0;
|
|
status_map.insert(opts.bucket.clone(), bucket_status);
|
|
status_map.get_mut(&opts.bucket).expect("bucket should be in status map")
|
|
};
|
|
|
|
let state = if let Some(state) = bucket_status.targets_map.get_mut(&opts.arn) {
|
|
state
|
|
} else {
|
|
let state = TargetReplicationResyncStatus::new();
|
|
bucket_status.targets_map.insert(opts.arn.clone(), state);
|
|
bucket_status
|
|
.targets_map
|
|
.get_mut(&opts.arn)
|
|
.expect("ARN should be in targets map")
|
|
};
|
|
|
|
if !resync_state_accepts_update(state, &opts) {
|
|
debug!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
incoming_resync_id = %opts.resync_id,
|
|
current_resync_id = %state.resync_id,
|
|
reason = "stale_status_update",
|
|
"Skipped stale resync status update"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
if state.resync_status == ResyncStatusType::ResyncCanceled && status != ResyncStatusType::ResyncCanceled {
|
|
debug!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
incoming_status = %status,
|
|
reason = "canceled_status_is_terminal",
|
|
"Skipped resync status update after cancellation"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
if state.resync_id.is_empty() {
|
|
state.resync_id = opts.resync_id.clone();
|
|
}
|
|
if state.resync_before_date.is_none() {
|
|
state.resync_before_date = opts.resync_before;
|
|
}
|
|
if state.bucket.is_empty() {
|
|
state.bucket = opts.bucket.clone();
|
|
}
|
|
if status == ResyncStatusType::ResyncStarted && state.start_time.is_none() {
|
|
state.start_time = Some(now);
|
|
}
|
|
state.resync_status = status;
|
|
state.last_update = Some(now);
|
|
let status_duration = resync_status_duration(status, state.start_time, now);
|
|
|
|
bucket_status.last_update = Some(now);
|
|
|
|
(bucket_status.clone(), status_duration)
|
|
};
|
|
|
|
save_resync_status(&opts.bucket, &bucket_status, obj_layer.clone()).await?;
|
|
if status != ResyncStatusType::ResyncCanceled {
|
|
let canceled_status = self
|
|
.status_map
|
|
.read()
|
|
.await
|
|
.get(&opts.bucket)
|
|
.filter(|current| {
|
|
current.targets_map.get(&opts.arn).is_some_and(|target| {
|
|
target.resync_id == opts.resync_id && target.resync_status == ResyncStatusType::ResyncCanceled
|
|
})
|
|
})
|
|
.cloned();
|
|
if let Some(canceled_status) = canceled_status {
|
|
save_resync_status(&opts.bucket, &canceled_status, obj_layer).await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
if let Some(stats) = runtime_sources::replication_stats() {
|
|
stats.record_resync_status(&opts.bucket, status, status_duration).await;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn inc_stats(&self, status: &TargetReplicationResyncStatus, opts: ResyncOpts) {
|
|
let mut status_map = self.status_map.write().await;
|
|
let now = OffsetDateTime::now_utc();
|
|
|
|
let bucket_status = if let Some(bucket_status) = status_map.get_mut(&opts.bucket) {
|
|
bucket_status
|
|
} else {
|
|
let mut bucket_status = BucketReplicationResyncStatus::new();
|
|
bucket_status.id = 0;
|
|
status_map.insert(opts.bucket.clone(), bucket_status);
|
|
status_map.get_mut(&opts.bucket).expect("bucket should be in status map")
|
|
};
|
|
|
|
let state = if let Some(state) = bucket_status.targets_map.get_mut(&opts.arn) {
|
|
state
|
|
} else {
|
|
let state = TargetReplicationResyncStatus::new();
|
|
bucket_status.targets_map.insert(opts.arn.clone(), state);
|
|
bucket_status
|
|
.targets_map
|
|
.get_mut(&opts.arn)
|
|
.expect("ARN should be in targets map")
|
|
};
|
|
|
|
if !resync_state_accepts_update(state, &opts) {
|
|
debug!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
incoming_resync_id = %opts.resync_id,
|
|
current_resync_id = %state.resync_id,
|
|
reason = "stale_stats_update",
|
|
"Skipped stale resync stats update"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if state.resync_id.is_empty() {
|
|
state.resync_id = opts.resync_id.clone();
|
|
}
|
|
if state.bucket.is_empty() {
|
|
state.bucket = opts.bucket.clone();
|
|
}
|
|
state.object = status.object.clone();
|
|
state.replicated_count += status.replicated_count;
|
|
state.replicated_size += status.replicated_size;
|
|
state.failed_count += status.failed_count;
|
|
state.failed_size += status.failed_size;
|
|
if state.error.is_none() && status.failed_count > 0 {
|
|
state.error = status.error.as_deref().and_then(sanitize_resync_error_detail);
|
|
}
|
|
state.last_update = Some(now);
|
|
bucket_status.last_update = Some(now);
|
|
}
|
|
|
|
async fn target_has_resync_failures(&self, opts: &ResyncOpts) -> bool {
|
|
self.status_map
|
|
.read()
|
|
.await
|
|
.get(&opts.bucket)
|
|
.and_then(|status| status.targets_map.get(&opts.arn))
|
|
.is_some_and(|status| status.failed_count > 0)
|
|
}
|
|
|
|
pub async fn persist_to_disk<S>(&self, cancel_token: CancellationToken, api: Arc<S>)
|
|
where
|
|
S: ReplicationObjectIO,
|
|
{
|
|
let mut interval = tokio::time::interval(RESYNC_TIME_INTERVAL);
|
|
|
|
let mut last_update_times = HashMap::new();
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = cancel_token.cancelled() => {
|
|
return;
|
|
}
|
|
_ = interval.tick() => {
|
|
|
|
let status_map = self.status_map.read().await;
|
|
|
|
let mut update = false;
|
|
for (bucket, status) in status_map.iter() {
|
|
for target in status.targets_map.values() {
|
|
if target.last_update.is_none() {
|
|
update = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if let Some(last_update) = status.last_update
|
|
&& last_update > *last_update_times.get(bucket).unwrap_or(&OffsetDateTime::UNIX_EPOCH) {
|
|
update = true;
|
|
}
|
|
|
|
if update {
|
|
if let Err(err) = save_resync_status(bucket, status, api.clone()).await {
|
|
error!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
reason = "persist_failed",
|
|
error = %err,
|
|
"Failed to persist resync status"
|
|
);
|
|
} else {
|
|
last_update_times.insert(bucket.clone(), status.last_update.expect("last_update should be set"));
|
|
}
|
|
}
|
|
}
|
|
|
|
interval.reset();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn resync_bucket_mark_status<S: ReplicationObjectIO>(
|
|
&self,
|
|
status: ResyncStatusType,
|
|
opts: ResyncOpts,
|
|
storage: Arc<S>,
|
|
) {
|
|
if let Err(err) = self.mark_status(status, opts.clone(), storage.clone()).await {
|
|
error!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "mark_status_failed",
|
|
error = %err,
|
|
"Failed to update resync status"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[instrument(skip(cancellation_token, storage))]
|
|
pub async fn resync_bucket<S: ReplicationStorage>(
|
|
self: Arc<Self>,
|
|
cancellation_token: CancellationToken,
|
|
storage: Arc<S>,
|
|
heal: bool,
|
|
opts: ResyncOpts,
|
|
) {
|
|
// Check cancellation before starting the scan.
|
|
// NOTE: the previous design waited here on `worker_rx.resubscribe().recv()` to
|
|
// throttle concurrent resyncs, but `resubscribe()` positions the new receiver at
|
|
// the current write-head of the broadcast ring buffer, so all pre-sent bootstrap
|
|
// signals (written in `ReplicationResyncer::new`) are invisible to it. Every
|
|
// spawned task therefore blocked forever, which is why `resync start` reported
|
|
// "started" yet objects never moved. Throttling at this level is also incorrect
|
|
// for broadcast channels (one send unblocks ALL receivers). The inner
|
|
// per-object worker pool (mpsc channels, line ~877) already provides the right
|
|
// concurrency limit.
|
|
if cancellation_token.is_cancelled() {
|
|
return;
|
|
}
|
|
|
|
// Acquire a cluster-wide leader lock for this (bucket, ARN) pair so that only
|
|
// one node runs the resync scan at a time. Without this, every cluster node would
|
|
// scan and replicate every object independently, causing N-fold duplicate traffic.
|
|
let resync_lock_key = ReplicationMetadataStore::resync_lock_key(&opts.bucket, &opts.arn);
|
|
let resync_ns_lock = match storage
|
|
.new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), &resync_lock_key)
|
|
.await
|
|
{
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
warn!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
error = %e,
|
|
reason = "leader_lock_create_failed",
|
|
"Failed to create resync leader lock — skipping resync"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let _resync_leader_guard = match resync_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
|
Ok(g) => g,
|
|
Err(_) => {
|
|
debug!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "leader_lock_held_by_another_node",
|
|
"Another node is already running resync for this bucket/ARN — skipping"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let Some(_resync_admission_permit) = self.acquire_resync_admission(&cancellation_token).await else {
|
|
return;
|
|
};
|
|
|
|
let cfg = match get_replication_config(&opts.bucket).await {
|
|
Ok(cfg) => cfg,
|
|
Err(err) => {
|
|
error!(
|
|
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "replication_config_lookup_failed",
|
|
error = %err,
|
|
"Failed to look up replication config during resync"
|
|
);
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let targets = match ReplicationTargetStore::list_bucket_targets(&opts.bucket).await {
|
|
Ok(targets) => targets,
|
|
Err(err) => {
|
|
debug!(
|
|
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
error = %err,
|
|
reason = "target_list_failed",
|
|
"Failed to list bucket targets during resync"
|
|
);
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let rcfg = ReplicationConfig::new(cfg.clone(), Some(targets));
|
|
if let Err(err) = rcfg.validate() {
|
|
error!(
|
|
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
error = %err,
|
|
reason = "replication_config_invalid",
|
|
"Replication resync config is invalid"
|
|
);
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let target_arns = if let Some(cfg) = cfg {
|
|
cfg.filter_target_arns(&ObjectOpts {
|
|
op_type: ReplicationType::Resync,
|
|
target_arn: opts.arn.clone(),
|
|
..Default::default()
|
|
})
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
if target_arns.len() != 1 {
|
|
error!(
|
|
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "target_arn_missing_from_replication_config",
|
|
"Replication resync target ARN missing from replication config"
|
|
);
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let Some(target_client) = ReplicationTargetStore::remote_target_client(&opts.bucket, &target_arns[0]).await else {
|
|
error!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "target_client_missing",
|
|
"Replication resync target client missing from bucket targets"
|
|
);
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
};
|
|
|
|
if !heal
|
|
&& let Err(e) = self
|
|
.mark_status(ResyncStatusType::ResyncStarted, opts.clone(), storage.clone())
|
|
.await
|
|
{
|
|
error!(
|
|
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "mark_started_failed",
|
|
error = %e,
|
|
"Failed to update resync status"
|
|
);
|
|
}
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(100);
|
|
let walk_failed = Arc::new(AtomicBool::new(false));
|
|
let walk_failed_task = walk_failed.clone();
|
|
let walk_storage = storage.clone();
|
|
let walk_cancellation = cancellation_token.clone();
|
|
let walk_bucket = opts.bucket.clone();
|
|
let walk_arn = opts.arn.clone();
|
|
let walk_task = tokio::spawn(async move {
|
|
if let Err(err) = walk_storage
|
|
.walk(
|
|
walk_cancellation,
|
|
&walk_bucket,
|
|
"",
|
|
tx,
|
|
WalkOptions::default().with_walkdir_timeouts(BACKGROUND_WALKDIR_TIMEOUT),
|
|
)
|
|
.await
|
|
{
|
|
walk_failed_task.store(true, Ordering::Relaxed);
|
|
error!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %walk_bucket,
|
|
arn = %walk_arn,
|
|
reason = "walk_failed",
|
|
error = %err,
|
|
"Replication resync bucket walk failed"
|
|
);
|
|
}
|
|
});
|
|
|
|
let mut worker_txs = Vec::new();
|
|
// mpsc, not broadcast: a lagging broadcast receiver returns Err(Lagged) which
|
|
// would end the collector and silently drop every subsequent worker result.
|
|
let (results_tx, mut results_rx) = tokio::sync::mpsc::channel::<TargetReplicationResyncStatus>(RESYNC_WORKER_COUNT * 4);
|
|
|
|
let opts_clone = opts.clone();
|
|
let self_clone = self.clone();
|
|
|
|
let mut futures = vec![walk_task];
|
|
|
|
let results_fut = tokio::spawn(async move {
|
|
while let Some(st) = results_rx.recv().await {
|
|
self_clone.inc_stats(&st, opts_clone.clone()).await;
|
|
}
|
|
});
|
|
|
|
futures.push(results_fut);
|
|
|
|
for _ in 0..RESYNC_WORKER_COUNT {
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<ReplicateObjectInfo>(100);
|
|
worker_txs.push(tx);
|
|
|
|
let cancel_token = cancellation_token.clone();
|
|
let target_client = target_client.clone();
|
|
let storage = storage.clone();
|
|
let results_tx = results_tx.clone();
|
|
let bucket_name = opts.bucket.clone();
|
|
let target_arn = opts.arn.clone();
|
|
|
|
let f = tokio::spawn(async move {
|
|
while let Some(mut roi) = rx.recv().await {
|
|
if cancel_token.is_cancelled() {
|
|
return;
|
|
}
|
|
|
|
if roi.delete_marker || !roi.version_purge_status.is_empty() {
|
|
let (version_id, dm_version_id) = if roi.version_purge_status.is_empty() {
|
|
(None, roi.version_id)
|
|
} else {
|
|
(roi.version_id, None)
|
|
};
|
|
|
|
let doi = DeletedObjectReplicationInfo {
|
|
delete_object: ReplicationDeletedObject {
|
|
object_name: roi.name.clone(),
|
|
delete_marker_version_id: dm_version_id,
|
|
version_id,
|
|
replication_state: roi.replication_state.clone(),
|
|
delete_marker: roi.delete_marker,
|
|
delete_marker_mtime: roi.mod_time,
|
|
..Default::default()
|
|
},
|
|
bucket: roi.bucket.clone(),
|
|
event_type: REPLICATE_EXISTING_DELETE.to_string(),
|
|
op_type: ReplicationType::ExistingObject,
|
|
target_arn: target_arn.clone(),
|
|
..Default::default()
|
|
};
|
|
replicate_delete(doi, storage.clone()).await;
|
|
} else {
|
|
roi.op_type = ReplicationType::ExistingObject;
|
|
roi.event_type = REPLICATE_EXISTING.to_string();
|
|
replicate_object(roi.clone(), storage.clone()).await;
|
|
}
|
|
|
|
let mut st = TargetReplicationResyncStatus {
|
|
object: roi.name.clone(),
|
|
bucket: roi.bucket.clone(),
|
|
..Default::default()
|
|
};
|
|
|
|
let reset_id = target_client.reset_id.clone();
|
|
|
|
let head_result = head_object_with_proxy_stats(
|
|
&bucket_name,
|
|
target_client.as_ref(),
|
|
&target_client.bucket,
|
|
&roi.name,
|
|
roi.version_id.map(|v| v.to_string()),
|
|
)
|
|
.await;
|
|
let (size, err) = match head_result {
|
|
Ok(_) => {
|
|
st.replicated_count += 1;
|
|
st.replicated_size += roi.size;
|
|
(roi.size, None)
|
|
}
|
|
Err(err) if roi.delete_marker => {
|
|
// Verifying a replicated delete marker: only a
|
|
// definitive 404/NoSuchKey or 405/MethodNotAllowed
|
|
// confirms the marker propagated. Any other
|
|
// (retryable/ambiguous) HEAD error leaves the outcome
|
|
// unverified, so it must count as failed — not as a
|
|
// blanket success (backlog#862 / #799 B13).
|
|
let retryable = {
|
|
let (is_not_found, code) = err
|
|
.as_service_error()
|
|
.map(|se| (se.is_not_found(), se.code()))
|
|
.unwrap_or((false, None));
|
|
is_retryable_delete_replication_head_error(is_not_found, code)
|
|
};
|
|
if retryable {
|
|
st.failed_count += 1;
|
|
(0, Some(err))
|
|
} else {
|
|
st.replicated_count += 1;
|
|
(0, None)
|
|
}
|
|
}
|
|
Err(err) if is_version_id_format_mismatch(&err) => {
|
|
// AWS-style target rejects the RustFS UUID versionId
|
|
// (400). Re-verify without the versionId before
|
|
// concluding the object failed to replicate, instead
|
|
// of counting a well-replicated object as failed.
|
|
match head_object_fallback(&bucket_name, target_client.as_ref(), &roi.name).await {
|
|
Ok(Some(_)) => {
|
|
st.replicated_count += 1;
|
|
st.replicated_size += roi.size;
|
|
(roi.size, None)
|
|
}
|
|
Ok(None) => {
|
|
st.failed_count += 1;
|
|
(0, Some(err))
|
|
}
|
|
Err(e2) => {
|
|
st.failed_count += 1;
|
|
(0, Some(e2))
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
st.failed_count += 1;
|
|
(0, Some(err))
|
|
}
|
|
};
|
|
|
|
if err.is_some() {
|
|
debug!(
|
|
event = EVENT_RESYNC_OBJECT_PROCESSED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
reset_id = %reset_id,
|
|
bucket = %bucket_name,
|
|
object = %roi.name,
|
|
version_id = %roi.version_id.unwrap_or_default(),
|
|
size,
|
|
error = ?err,
|
|
"Processed resync object with verification error"
|
|
);
|
|
} else {
|
|
trace!(
|
|
event = EVENT_RESYNC_OBJECT_PROCESSED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
reset_id = %reset_id,
|
|
bucket = %bucket_name,
|
|
object = %roi.name,
|
|
version_id = %roi.version_id.unwrap_or_default(),
|
|
size,
|
|
"Processed resync object"
|
|
);
|
|
}
|
|
st.error = err.as_ref().and_then(resync_target_error_detail);
|
|
|
|
if cancel_token.is_cancelled() {
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = results_tx.send(st).await {
|
|
error!(
|
|
event = EVENT_RESYNC_RUNTIME_CHANNEL_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket_name,
|
|
reason = "status_channel_send_failed",
|
|
error = %err,
|
|
"Failed to send resync status"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
futures.push(f);
|
|
}
|
|
|
|
while let Some(res) = rx.recv().await {
|
|
if let Some(err) = res.err {
|
|
error!(
|
|
event = EVENT_RESYNC_RUNTIME_CHANNEL_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "object_info_failed",
|
|
error = %err,
|
|
"Failed to receive resync object info"
|
|
);
|
|
cancellation_token.cancel();
|
|
drop(rx);
|
|
let worker_failed = finish_resync_workers(worker_txs, results_tx, futures, false).await;
|
|
if worker_failed {
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "worker_join_failed_after_object_info_error",
|
|
"Replication resync worker cleanup observed task failure"
|
|
);
|
|
}
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
if cancellation_token.is_cancelled() {
|
|
drop(rx);
|
|
finish_resync_workers(worker_txs, results_tx, futures, true).await;
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncCanceled, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let Some(object) = res.item else {
|
|
continue;
|
|
};
|
|
|
|
let roi = match get_heal_replicate_object_info(&object, &rcfg).await {
|
|
Ok(roi) => roi,
|
|
Err(err) => {
|
|
error!(
|
|
event = EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
object = %object.name,
|
|
error = %err,
|
|
"Failed to classify object for replication resync"
|
|
);
|
|
cancellation_token.cancel();
|
|
drop(rx);
|
|
let worker_failed = finish_resync_workers(worker_txs, results_tx, futures, false).await;
|
|
if worker_failed {
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "worker_join_failed_after_classification_error",
|
|
"Replication resync worker cleanup observed task failure"
|
|
);
|
|
}
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
if !roi.existing_obj_resync.must_resync() {
|
|
continue;
|
|
}
|
|
|
|
if cancellation_token.is_cancelled() {
|
|
drop(rx);
|
|
finish_resync_workers(worker_txs, results_tx, futures, true).await;
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncCanceled, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let worker_idx = sip_hash(&roi.name, RESYNC_WORKER_COUNT, &DEFAULT_SIP_HASH_KEY);
|
|
|
|
if let Err(err) = worker_txs[worker_idx].send(roi).await {
|
|
error!(
|
|
event = EVENT_RESYNC_RUNTIME_CHANNEL_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "worker_queue_send_failed",
|
|
error = %err,
|
|
"Failed to send resync object to worker"
|
|
);
|
|
cancellation_token.cancel();
|
|
drop(rx);
|
|
let worker_failed = finish_resync_workers(worker_txs, results_tx, futures, false).await;
|
|
if worker_failed {
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %opts.bucket,
|
|
arn = %opts.arn,
|
|
reason = "worker_join_failed_after_queue_send_error",
|
|
"Replication resync worker cleanup observed task failure"
|
|
);
|
|
}
|
|
self.resync_bucket_mark_status(ResyncStatusType::ResyncFailed, opts.clone(), storage.clone())
|
|
.await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
let worker_failed = finish_resync_workers(worker_txs, results_tx, futures, false).await;
|
|
let target_failed = self.target_has_resync_failures(&opts).await;
|
|
let status = if walk_failed.load(Ordering::Relaxed) || worker_failed || target_failed {
|
|
ResyncStatusType::ResyncFailed
|
|
} else {
|
|
ResyncStatusType::ResyncCompleted
|
|
};
|
|
|
|
self.resync_bucket_mark_status(status, opts.clone(), storage.clone()).await;
|
|
}
|
|
}
|
|
|
|
pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationConfig) -> Result<ReplicateObjectInfo> {
|
|
let mut oi = oi.clone();
|
|
let mut user_defined = (*oi.user_defined).clone();
|
|
let delete_path = heal_uses_delete_replication_path(oi.delete_marker, &oi.version_purge_status);
|
|
let stored_delete_decision = if delete_path && !oi.replication_decision.is_empty() {
|
|
Some(parse_replicate_decision(&oi.bucket, &oi.replication_decision)?)
|
|
} else {
|
|
None
|
|
};
|
|
let has_stored_delete_decision = stored_delete_decision.is_some();
|
|
|
|
if let Some(rc) = rcfg.config.as_ref()
|
|
&& !rc.role.is_empty()
|
|
{
|
|
if oi.version_purge_status_internal.is_none() && !oi.version_purge_status.is_empty() {
|
|
oi.version_purge_status_internal = Some(format!("{}={};", rc.role, oi.version_purge_status.as_str()));
|
|
}
|
|
|
|
if oi.replication_status_internal.is_none() && !oi.replication_status.is_empty() {
|
|
oi.replication_status_internal = Some(format!("{}={};", rc.role, oi.replication_status.as_str()));
|
|
}
|
|
|
|
let keys_to_update: Vec<_> = user_defined
|
|
.iter()
|
|
.filter(|(k, _)| has_internal_suffix(k, SUFFIX_REPLICATION_RESET))
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect();
|
|
|
|
for (k, v) in keys_to_update {
|
|
user_defined.remove(&k);
|
|
user_defined.insert(target_reset_header(rc.role.as_str()), v);
|
|
}
|
|
}
|
|
|
|
let delete_state = if delete_path && !has_stored_delete_decision {
|
|
ReplicationVersioningStore::prefix_state(&oi.bucket, &oi.name).await?
|
|
} else {
|
|
(false, false)
|
|
};
|
|
let dsc = if let Some(decision) = stored_delete_decision {
|
|
decision
|
|
} else if delete_path {
|
|
if !delete_state.0 && !delete_state.1 {
|
|
ReplicateDecision::default()
|
|
} else {
|
|
rcfg.check_delete_for_heal(
|
|
&ObjectToDelete {
|
|
object_name: oi.name.clone(),
|
|
version_id: oi.version_id,
|
|
..Default::default()
|
|
},
|
|
&oi,
|
|
&ObjectOptions {
|
|
versioned: delete_state.0,
|
|
version_suspended: delete_state.1,
|
|
..Default::default()
|
|
},
|
|
)
|
|
}
|
|
} else {
|
|
must_replicate(
|
|
oi.bucket.as_str(),
|
|
&oi.name,
|
|
MustReplicateOptions::new(&user_defined, (*oi.user_tags).clone(), ReplicationType::Heal, false),
|
|
)
|
|
.await
|
|
};
|
|
|
|
let target_statuses = replication_statuses_map(&oi.replication_status_internal.clone().unwrap_or_default());
|
|
let target_purge_statuses = version_purge_statuses_map(&oi.version_purge_status_internal.clone().unwrap_or_default());
|
|
let existing_obj_resync = if delete_path && !has_stored_delete_decision && !delete_state.0 && !delete_state.1 {
|
|
Default::default()
|
|
} else {
|
|
rcfg.resync(oi.clone(), dsc.clone(), &target_statuses).await
|
|
};
|
|
let mut replication_state = oi.replication_state();
|
|
replication_state.replicate_decision_str = dsc.to_string();
|
|
let actual_size = oi.get_actual_size().unwrap_or_default();
|
|
|
|
Ok(ReplicateObjectInfo {
|
|
name: oi.name.clone(),
|
|
size: oi.size,
|
|
actual_size,
|
|
bucket: oi.bucket.clone(),
|
|
version_id: oi.version_id,
|
|
etag: oi.etag.clone(),
|
|
mod_time: oi.mod_time,
|
|
replication_status: oi.replication_status,
|
|
replication_status_internal: oi.replication_status_internal.clone(),
|
|
delete_marker: oi.delete_marker,
|
|
version_purge_status_internal: oi.version_purge_status_internal.clone(),
|
|
version_purge_status: oi.version_purge_status,
|
|
replication_state: Some(replication_state),
|
|
op_type: ReplicationType::Heal,
|
|
event_type: "".to_string(),
|
|
dsc,
|
|
existing_obj_resync,
|
|
target_statuses,
|
|
target_purge_statuses,
|
|
replication_timestamp: None,
|
|
ssec: replication_object_is_ssec_encrypted(&user_defined),
|
|
user_tags: (*oi.user_tags).clone(),
|
|
checksum: oi.checksum.clone(),
|
|
retry_count: 0,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn save_resync_status<S: ReplicationObjectIO>(
|
|
bucket: &str,
|
|
status: &BucketReplicationResyncStatus,
|
|
api: Arc<S>,
|
|
) -> Result<()> {
|
|
let data = encode_resync_file(status)?;
|
|
|
|
let config_file = ReplicationMetadataStore::bucket_resync_file_path(bucket);
|
|
ReplicationConfigStore::save(api, &config_file, data).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicationInfo, storage: Arc<S>) {
|
|
let _ = replicate_delete_with_outcome(dobj, storage).await;
|
|
}
|
|
|
|
pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
|
|
dobj: DeletedObjectReplicationInfo,
|
|
storage: Arc<S>,
|
|
) -> bool {
|
|
if dobj.delete_object.force_delete {
|
|
return replicate_force_delete_to_targets(&dobj, storage).await;
|
|
}
|
|
|
|
let bucket = dobj.bucket.clone();
|
|
let mut source_state_verified = true;
|
|
let version_id = if let Some(version_id) = &dobj.delete_object.delete_marker_version_id {
|
|
Some(version_id.to_owned())
|
|
} else {
|
|
dobj.delete_object.version_id
|
|
};
|
|
|
|
if dobj.delete_object.delete_marker
|
|
&& let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id
|
|
{
|
|
let source_marker_state = storage
|
|
.get_object_info(
|
|
&bucket,
|
|
&dobj.delete_object.object_name,
|
|
&ObjectOptions {
|
|
version_id: Some(delete_marker_version_id.to_string()),
|
|
versioned: ReplicationVersioningStore::prefix_enabled(&bucket, &dobj.delete_object.object_name).await,
|
|
version_suspended: ReplicationVersioningStore::prefix_suspended(&bucket, &dobj.delete_object.object_name)
|
|
.await,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
|
|
match source_marker_state {
|
|
Ok(info) if info.delete_marker && info.version_id == Some(delete_marker_version_id) => {}
|
|
Ok(_) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
version_id = %delete_marker_version_id,
|
|
reason = "source_not_delete_marker",
|
|
"Skipping stale delete-marker replication"
|
|
);
|
|
return true;
|
|
}
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
version_id = %delete_marker_version_id,
|
|
reason = "source_version_missing",
|
|
"Skipping stale delete-marker replication"
|
|
);
|
|
// The marker is gone at the source, but a replica of it may
|
|
// already exist on the targets (a live race, or an MRF
|
|
// purge-intent replay landing here on purpose). Purge instead
|
|
// of just skipping; the result decides whether an MRF replay
|
|
// may acknowledge the entry.
|
|
return purge_stale_delete_marker_targets(&bucket, &dobj).await;
|
|
}
|
|
Err(err) => {
|
|
source_state_verified = false;
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
version_id = %delete_marker_version_id,
|
|
error = %err,
|
|
reason = "source_state_verification_failed",
|
|
"Failed to verify source delete-marker state before replication"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let dsc = match parse_replicate_decision(
|
|
&bucket,
|
|
&dobj
|
|
.delete_object
|
|
.replication_state
|
|
.as_ref()
|
|
.map(|v| v.replicate_decision_str.clone())
|
|
.unwrap_or_default(),
|
|
) {
|
|
Ok(dsc) => dsc,
|
|
Err(err) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %dobj.target_arn,
|
|
error = %err,
|
|
reason = "replicate_decision_parse_failed",
|
|
"Failed to parse replicate decision"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: dobj.delete_object.object_name.clone(),
|
|
version_id,
|
|
delete_marker: dobj.delete_object.delete_marker,
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
let ns_lock = match storage
|
|
.new_ns_lock(&bucket, format!("/[replicate]/{}", dobj.delete_object.object_name).as_str())
|
|
.await
|
|
{
|
|
Ok(ns_lock) => ns_lock,
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %dobj.delete_object.object_name,
|
|
error = %e,
|
|
reason = "ns_lock_unavailable",
|
|
"Skipping replication delete"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: dobj.delete_object.object_name.clone(),
|
|
version_id,
|
|
delete_marker: dobj.delete_object.delete_marker,
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
let _lock_guard = match ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
|
Ok(lock_guard) => lock_guard,
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %dobj.delete_object.object_name,
|
|
error = %e,
|
|
reason = "write_lock_unavailable",
|
|
"Skipping replication delete"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: dobj.delete_object.object_name.clone(),
|
|
version_id,
|
|
delete_marker: dobj.delete_object.delete_marker,
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Initialize replicated infos
|
|
let mut rinfos = ReplicatedInfos {
|
|
replication_timestamp: Some(OffsetDateTime::now_utc()),
|
|
targets: Vec::with_capacity(dsc.targets_map.len()),
|
|
};
|
|
|
|
let mut join_set = JoinSet::new();
|
|
|
|
// Process each target
|
|
let target_arns = dobj.admitted_target_arns();
|
|
let expected_targets = dsc
|
|
.targets_map
|
|
.values()
|
|
.filter(|target| target.replicate && (target_arns.is_empty() || target_arns.iter().any(|arn| arn == &target.arn)))
|
|
.count();
|
|
for tgt_entry in dsc.targets_map.values() {
|
|
// Skip targets that should not be replicated
|
|
if !tgt_entry.replicate {
|
|
continue;
|
|
}
|
|
|
|
// If dobj.TargetArn is not empty string, this is a case of specific target being re-synced.
|
|
if !target_arns.is_empty() && !target_arns.iter().any(|arn| arn == &tgt_entry.arn) {
|
|
continue;
|
|
}
|
|
|
|
// Get the remote target client
|
|
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(&bucket, &tgt_entry.arn).await else {
|
|
debug!(
|
|
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_entry.arn,
|
|
reason = "target_client_missing",
|
|
"Skipping replication delete because target client is unavailable"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: dobj.delete_object.object_name.clone(),
|
|
version_id,
|
|
delete_marker: dobj.delete_object.delete_marker,
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
continue;
|
|
};
|
|
|
|
let dobj_clone = dobj.clone();
|
|
|
|
// Spawn task in the join set
|
|
join_set.spawn(async move { replicate_delete_to_target(&dobj_clone, tgt_client.clone()).await });
|
|
}
|
|
|
|
// Collect all results
|
|
while let Some(result) = join_set.join_next().await {
|
|
match result {
|
|
Ok(tgt_info) => {
|
|
rinfos.targets.push(tgt_info);
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %dobj.delete_object.object_name,
|
|
operation = "replicate_delete",
|
|
error = %e,
|
|
"Replication resync task failed"
|
|
);
|
|
send_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: dobj.delete_object.object_name.clone(),
|
|
version_id,
|
|
delete_marker: dobj.delete_object.delete_marker,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
|
|
|
let requires_delayed_purge = should_retry_delete_marker_purge(&dobj.delete_object);
|
|
|
|
let (replication_status, prev_status) = if !is_version_purge {
|
|
(
|
|
rinfos.replication_status(),
|
|
dobj.delete_object
|
|
.replication_state
|
|
.as_ref()
|
|
.map(|v| v.composite_replication_status())
|
|
.unwrap_or(ReplicationStatusType::Empty),
|
|
)
|
|
} else {
|
|
(
|
|
ReplicationStatusType::from(rinfos.version_purge_status()),
|
|
ReplicationStatusType::from(
|
|
dobj.delete_object
|
|
.replication_state
|
|
.as_ref()
|
|
.map(|v| v.composite_version_purge_status())
|
|
.unwrap_or(VersionPurgeStatusType::Empty),
|
|
),
|
|
)
|
|
};
|
|
|
|
if let Some(stats) = runtime_sources::replication_stats() {
|
|
for tgt in rinfos.targets.iter() {
|
|
if tgt.replication_status != tgt.prev_replication_status {
|
|
stats
|
|
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut drs = get_replication_state(
|
|
&rinfos,
|
|
&dobj.delete_object.replication_state.clone().unwrap_or_default(),
|
|
dobj.delete_object.version_id.map(|v| v.to_string()),
|
|
);
|
|
if replication_status != prev_status {
|
|
drs.replication_timestamp = Some(OffsetDateTime::now_utc());
|
|
}
|
|
|
|
if requires_delayed_purge {
|
|
// Hand the watcher the MERGED replication state: `drs` folds this
|
|
// round's per-target results into the previous state, including the
|
|
// version ids the targets assigned to the markers they just created.
|
|
// Spawning with the pre-merge `dobj` made the purge fall back to a
|
|
// source-derived id, which a target that mints its own ids answers
|
|
// with an idempotent 204 — the intent was then dropped while the
|
|
// real marker stayed behind.
|
|
let bucket_clone = bucket.clone();
|
|
let mut dobj_clone = dobj.clone();
|
|
dobj_clone.delete_object.replication_state = Some(drs.clone());
|
|
let dsc_clone = dsc.clone();
|
|
let storage_clone = storage.clone();
|
|
tokio::spawn(async move {
|
|
watch_and_purge_source_delete_marker(bucket_clone, dobj_clone, dsc_clone, storage_clone).await;
|
|
});
|
|
}
|
|
|
|
let event_name = if replication_status == ReplicationStatusType::Completed {
|
|
EventName::ObjectReplicationComplete.to_string()
|
|
} else {
|
|
EventName::ObjectReplicationFailed.to_string()
|
|
};
|
|
|
|
let state_persisted = match storage
|
|
.delete_object(
|
|
&bucket,
|
|
&dobj.delete_object.object_name,
|
|
ObjectOptions {
|
|
version_id: version_id.map(|v| v.to_string()),
|
|
mod_time: dobj.delete_object.delete_marker_mtime,
|
|
delete_replication: Some(drs),
|
|
versioned: ReplicationVersioningStore::prefix_enabled(&bucket, &dobj.delete_object.object_name).await,
|
|
version_suspended: ReplicationVersioningStore::prefix_suspended(&bucket, &dobj.delete_object.object_name).await,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(object) => {
|
|
send_event(EventArgs {
|
|
event_name,
|
|
bucket_name: bucket.clone(),
|
|
object,
|
|
..Default::default()
|
|
});
|
|
true
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %dobj.target_arn,
|
|
object = %dobj.delete_object.object_name,
|
|
operation = "apply_replication_delete_state",
|
|
error = %e,
|
|
"Replication target operation failed"
|
|
);
|
|
send_event(EventArgs {
|
|
event_name,
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: dobj.delete_object.object_name.clone(),
|
|
version_id,
|
|
delete_marker: dobj.delete_object.delete_marker,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
});
|
|
false
|
|
}
|
|
};
|
|
|
|
replicate_delete_outcome(
|
|
expected_targets,
|
|
rinfos.targets.len(),
|
|
state_persisted,
|
|
source_state_verified,
|
|
&replication_status,
|
|
)
|
|
}
|
|
|
|
/// Whether a delete replication fully succeeded — the MRF replay acknowledges
|
|
/// (drops) an entry exactly when this returns true.
|
|
///
|
|
/// The delayed purge is deliberately NOT an input: holding the outcome hostage
|
|
/// to it (`&& !requires_delayed_purge`) forced `false` for every delete-marker
|
|
/// entry and retained them all in the durable MRF journal forever. Purge
|
|
/// failures persist their own purge-intent entry instead
|
|
/// (`watch_and_purge_source_delete_marker`), and replays of those entries
|
|
/// report purge success through `purge_stale_delete_marker_targets`.
|
|
fn replicate_delete_outcome(
|
|
expected_targets: usize,
|
|
replicated_targets: usize,
|
|
state_persisted: bool,
|
|
source_state_verified: bool,
|
|
replication_status: &ReplicationStatusType,
|
|
) -> bool {
|
|
expected_targets > 0
|
|
&& replicated_targets == expected_targets
|
|
&& state_persisted
|
|
&& source_state_verified
|
|
&& *replication_status == ReplicationStatusType::Completed
|
|
}
|
|
|
|
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
|
storage: &S,
|
|
bucket: &str,
|
|
object_name: &str,
|
|
delete_marker_version_id: Uuid,
|
|
) -> bool {
|
|
match storage
|
|
.get_object_info(
|
|
bucket,
|
|
object_name,
|
|
&ObjectOptions {
|
|
version_id: Some(delete_marker_version_id.to_string()),
|
|
versioned: ReplicationVersioningStore::prefix_enabled(bucket, object_name).await,
|
|
version_suspended: ReplicationVersioningStore::prefix_suspended(bucket, object_name).await,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(info) => !info.delete_marker || info.version_id != Some(delete_marker_version_id),
|
|
Err(err) => is_err_object_not_found(&err) || is_err_version_not_found(&err),
|
|
}
|
|
}
|
|
|
|
/// Which version a delete-marker purge should address on one target.
|
|
///
|
|
/// `None` means do not purge at all: the recorded mapping disagreed across the
|
|
/// dual internal prefixes, and guessing an id could destroy a live version on
|
|
/// the target. `Some(id)` is the exact version the target reported when it
|
|
/// accepted the marker; falling back to a source-derived id is only correct
|
|
/// when the target mirrors source version ids, which a generic S3 target does
|
|
/// not.
|
|
fn delete_marker_purge_version_id(
|
|
state: Option<&ReplicationState>,
|
|
arn: &str,
|
|
delete_marker_version_id: Uuid,
|
|
) -> Option<Option<String>> {
|
|
if state.is_some_and(|state| state.target_delete_marker_version_ids_corrupt) {
|
|
return None;
|
|
}
|
|
let recorded = state.and_then(|state| state.target_delete_marker_version_ids.get(arn).cloned());
|
|
Some(match recorded {
|
|
Some(version_id) => Some(version_id),
|
|
None => target_delete_version_id(delete_marker_version_id, true),
|
|
})
|
|
}
|
|
|
|
/// One purge pass over the eligible targets. Returns the ARNs that must be
|
|
/// retried: the remote DELETE failed, or the target client was unavailable
|
|
/// (e.g. a runtime cache miss). Inconsistent recorded version mappings are a
|
|
/// deliberate refusal — retrying cannot make guessing a version id safe — so
|
|
/// they are logged and excluded from the retry set.
|
|
async fn replicate_delete_marker_purge_to_targets(
|
|
bucket: &str,
|
|
dobj: &DeletedObjectReplicationInfo,
|
|
dsc: &ReplicateDecision,
|
|
retry_arns: Option<&[String]>,
|
|
) -> Vec<String> {
|
|
let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let target_arns = dobj.admitted_target_arns();
|
|
let mut failed_arns = Vec::new();
|
|
for tgt_entry in dsc.targets_map.values() {
|
|
if !tgt_entry.replicate {
|
|
continue;
|
|
}
|
|
if !target_arns.is_empty() && !target_arns.iter().any(|arn| arn == &tgt_entry.arn) {
|
|
continue;
|
|
}
|
|
if let Some(retry_arns) = retry_arns
|
|
&& !retry_arns.iter().any(|arn| arn == &tgt_entry.arn)
|
|
{
|
|
continue;
|
|
}
|
|
// Decide the version first: refusing to guess is a per-target
|
|
// FAILURE, not a silent skip. Reporting it as success would let the
|
|
// watcher and the MRF replay drop the purge intent while the marker
|
|
// is still on the target — the leak stays visible instead (the
|
|
// entry is retained and keeps warning) until an operator repairs
|
|
// the metadata.
|
|
let Some(purge_version_id) = delete_marker_purge_version_id(
|
|
dobj.delete_object.replication_state.as_ref(),
|
|
&tgt_entry.arn,
|
|
delete_marker_version_id,
|
|
) else {
|
|
warn!(
|
|
event = EVENT_DELETE_MARKER_PURGE_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
arn = tgt_entry.arn,
|
|
reason = "recorded_target_version_inconsistent",
|
|
"Delete-marker purge refused: recorded target version metadata is inconsistent"
|
|
);
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "refused").increment(1);
|
|
failed_arns.push(tgt_entry.arn.clone());
|
|
continue;
|
|
};
|
|
|
|
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(bucket, &tgt_entry.arn).await else {
|
|
warn!(
|
|
event = EVENT_DELETE_MARKER_PURGE_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
arn = tgt_entry.arn,
|
|
reason = "target_client_missing",
|
|
"Delete-marker purge attempt failed"
|
|
);
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "failed").increment(1);
|
|
failed_arns.push(tgt_entry.arn.clone());
|
|
continue;
|
|
};
|
|
|
|
match tgt_client
|
|
.remove_object(
|
|
&tgt_client.bucket,
|
|
&dobj.delete_object.object_name,
|
|
purge_version_id,
|
|
replication_delete_marker_purge_remove_options(dobj.delete_object.delete_marker_mtime),
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "purged").increment(1);
|
|
}
|
|
// The marker version is already gone on the target: the purge goal
|
|
// is met. Strict S3 targets 404 here (RustFS/MinIO answer 204);
|
|
// treating it as a failure would retain the intent entry forever.
|
|
Err(error) if matches!(error.code.as_deref(), Some("NoSuchKey" | "NoSuchVersion")) => {
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "purged").increment(1);
|
|
}
|
|
Err(error) => {
|
|
warn!(
|
|
event = EVENT_DELETE_MARKER_PURGE_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
arn = tgt_entry.arn,
|
|
error = %error,
|
|
reason = "target_delete_failed",
|
|
"Delete-marker purge attempt failed"
|
|
);
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "failed").increment(1);
|
|
mark_replication_target_offline_if_needed(&tgt_client, &error).await;
|
|
failed_arns.push(tgt_entry.arn.clone());
|
|
}
|
|
}
|
|
}
|
|
failed_arns
|
|
}
|
|
|
|
const DELETE_MARKER_PURGE_WATCH_ROUNDS: usize = 5;
|
|
const DELETE_MARKER_PURGE_WATCH_INTERVAL: TokioDuration = TokioDuration::from_secs(1);
|
|
|
|
/// Watch the source delete marker for a short window after its replication.
|
|
///
|
|
/// KNOWN NON-DURABLE WINDOW: this task is detached, so a process exit inside
|
|
/// the watch window loses an intent that has not been persisted yet. The
|
|
/// window predates this code (the previous implementation had no durable
|
|
/// channel at all, and no replay half either), so nothing regresses — closing
|
|
/// it needs a write-ahead intent recorded before the parent delete is
|
|
/// acknowledged, which is tracked as follow-up rather than done here: every
|
|
/// delete-marker replication would pay a journal write for a purge that
|
|
/// almost never happens.
|
|
///
|
|
/// If the marker disappears (deleted before or while the replica landed),
|
|
/// purge the replicated marker from the targets, retrying failed targets on
|
|
/// later rounds. When the window drains with targets still dirty, persist the
|
|
/// purge intent as a durable MRF entry so the next startup replays it through
|
|
/// `purge_stale_delete_marker_targets`.
|
|
async fn watch_and_purge_source_delete_marker<S: ReplicationStorage>(
|
|
bucket: String,
|
|
dobj: DeletedObjectReplicationInfo,
|
|
dsc: ReplicateDecision,
|
|
storage: Arc<S>,
|
|
) {
|
|
let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id else {
|
|
return;
|
|
};
|
|
|
|
// `pending` is None until the source marker is observed missing; after the
|
|
// first purge pass it holds the targets that still need a successful purge.
|
|
let mut pending: Option<Vec<String>> = None;
|
|
for round in 0..DELETE_MARKER_PURGE_WATCH_ROUNDS {
|
|
pending = match pending.take() {
|
|
None => {
|
|
if source_delete_marker_missing(&*storage, &bucket, &dobj.delete_object.object_name, delete_marker_version_id)
|
|
.await
|
|
{
|
|
Some(replicate_delete_marker_purge_to_targets(&bucket, &dobj, &dsc, None).await)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
Some(failed_arns) => Some(replicate_delete_marker_purge_to_targets(&bucket, &dobj, &dsc, Some(&failed_arns)).await),
|
|
};
|
|
if matches!(pending.as_deref(), Some([])) {
|
|
return;
|
|
}
|
|
if round + 1 < DELETE_MARKER_PURGE_WATCH_ROUNDS {
|
|
tokio::time::sleep(DELETE_MARKER_PURGE_WATCH_INTERVAL).await;
|
|
}
|
|
}
|
|
if let Some(failed_arns) = pending.filter(|failed_arns| !failed_arns.is_empty()) {
|
|
enqueue_delete_marker_purge_mrf(&dobj, failed_arns).await;
|
|
}
|
|
}
|
|
|
|
/// Shape an exhausted purge intent as a marker-creation delete entry. Replay
|
|
/// reconstructs it with `delete_marker: true`, finds the source marker gone,
|
|
/// and funnels into the stale-marker branch of `replicate_delete_with_outcome`
|
|
/// — which re-runs the purge without touching source state and reports purge
|
|
/// success as the replay outcome.
|
|
fn delete_marker_purge_mrf_entry(dobj: &DeletedObjectReplicationInfo, failed_arns: Vec<String>) -> MrfReplicateEntry {
|
|
let mut entry = dobj.to_mrf_entry();
|
|
entry.delete_marker = true;
|
|
entry.version_id = None;
|
|
entry.retry_count = 0;
|
|
entry.target_arns = failed_arns;
|
|
entry
|
|
}
|
|
|
|
async fn enqueue_delete_marker_purge_mrf(dobj: &DeletedObjectReplicationInfo, failed_arns: Vec<String>) {
|
|
let arns = failed_arns.join(",");
|
|
let miss_reason = match runtime_sources::replication_pool() {
|
|
None => Some("replication_pool_unavailable"),
|
|
Some(pool) => match pool.persist_mrf_entry(delete_marker_purge_mrf_entry(dobj, failed_arns)).await {
|
|
ReplicationQueueAdmission::Queued => None,
|
|
_ => Some("mrf_save_unavailable"),
|
|
},
|
|
};
|
|
match miss_reason {
|
|
None => {
|
|
warn!(
|
|
event = EVENT_DELETE_MARKER_PURGE_MRF,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = dobj.bucket,
|
|
object = dobj.delete_object.object_name,
|
|
arns,
|
|
state = "queued",
|
|
"Delete-marker purge exhausted its watch window; intent persisted to the MRF journal"
|
|
);
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "mrf_queued").increment(1);
|
|
}
|
|
Some(reason) => {
|
|
warn!(
|
|
event = EVENT_DELETE_MARKER_PURGE_MRF,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = dobj.bucket,
|
|
object = dobj.delete_object.object_name,
|
|
arns,
|
|
state = "missed",
|
|
reason,
|
|
"Delete-marker purge intent could not be persisted for retry"
|
|
);
|
|
counter!(METRIC_DELETE_MARKER_PURGE_TOTAL, "state" => "mrf_missed").increment(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The marker vanished at the source while its replication was still pending
|
|
/// (a live race), or this is an MRF purge-intent replay. Any marker already
|
|
/// replicated to a target must still be purged; run bounded retry passes and
|
|
/// report the result so an MRF replay only acknowledges the entry once every
|
|
/// target is clean. Live callers persist a fresh purge intent on failure;
|
|
/// replay callers (`ReplicationType::Heal`) rely on Missed retention instead,
|
|
/// so the journal does not accumulate duplicate entries.
|
|
///
|
|
/// Heal callers retry for the full watch window because the startup MRF
|
|
/// processor runs before bucket metadata (and thus target clients) finishes
|
|
/// initializing — the first pass can see `target_client_missing` and a later
|
|
/// round resolves the client; the replay loop is serial and startup-only, so
|
|
/// blocking it for up to the window per dirty entry is acceptable. Live
|
|
/// callers run on replication workers where a down target would pin a worker
|
|
/// for the whole window, so they attempt once and lean on the durable intent
|
|
/// entry instead.
|
|
async fn purge_stale_delete_marker_targets(bucket: &str, dobj: &DeletedObjectReplicationInfo) -> bool {
|
|
let decision_str = dobj
|
|
.delete_object
|
|
.replication_state
|
|
.as_ref()
|
|
.map(|state| state.replicate_decision_str.clone())
|
|
.unwrap_or_default();
|
|
let dsc = match parse_replicate_decision(bucket, &decision_str) {
|
|
Ok(dsc) => dsc,
|
|
Err(error) => {
|
|
warn!(
|
|
event = EVENT_DELETE_MARKER_PURGE_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket,
|
|
object = dobj.delete_object.object_name,
|
|
error = %error,
|
|
reason = "replicate_decision_parse_failed",
|
|
"Delete-marker purge attempt failed"
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
let rounds = if dobj.op_type == ReplicationType::Heal {
|
|
DELETE_MARKER_PURGE_WATCH_ROUNDS
|
|
} else {
|
|
1
|
|
};
|
|
let mut failed_arns = replicate_delete_marker_purge_to_targets(bucket, dobj, &dsc, None).await;
|
|
for _ in 1..rounds {
|
|
if failed_arns.is_empty() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(DELETE_MARKER_PURGE_WATCH_INTERVAL).await;
|
|
failed_arns = replicate_delete_marker_purge_to_targets(bucket, dobj, &dsc, Some(&failed_arns)).await;
|
|
}
|
|
if failed_arns.is_empty() {
|
|
return true;
|
|
}
|
|
if dobj.op_type != ReplicationType::Heal {
|
|
enqueue_delete_marker_purge_mrf(dobj, failed_arns).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &DeletedObjectReplicationInfo, storage: Arc<S>) -> bool {
|
|
let bucket = &dobj.bucket;
|
|
let object_name = &dobj.delete_object.object_name;
|
|
let admitted_target_arns = dobj.admitted_target_arns();
|
|
|
|
let legacy_target_arns = if admitted_target_arns.is_empty() {
|
|
match get_replication_config(bucket).await {
|
|
Ok(Some(config)) => config.filter_target_arns(&ObjectOpts {
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
}),
|
|
Ok(None) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
reason = "replication_config_missing",
|
|
"Skipping replication force-delete because replication config is missing"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
Vec::new()
|
|
}
|
|
Err(err) => {
|
|
debug!(
|
|
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
error = %err,
|
|
reason = "replication_config_lookup_failed",
|
|
"Skipping replication force-delete because replication config lookup failed"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
Vec::new()
|
|
}
|
|
}
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let ns_lock = match storage
|
|
.new_ns_lock(bucket, format!("/[replicate]/{}", object_name).as_str())
|
|
.await
|
|
{
|
|
Ok(ns_lock) => ns_lock,
|
|
Err(e) => {
|
|
warn!(
|
|
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
reason = "ns_lock_create_failed",
|
|
error = %e,
|
|
"Skipping replication force-delete"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
let _lock_guard = match ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
|
Ok(guard) => guard,
|
|
Err(e) => {
|
|
warn!(
|
|
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
reason = "write_lock_failed",
|
|
error = %e,
|
|
"Skipping replication force-delete"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
|
|
let tgt_arns = if admitted_target_arns.is_empty() {
|
|
legacy_target_arns
|
|
} else {
|
|
admitted_target_arns
|
|
};
|
|
if tgt_arns.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let mut join_set = JoinSet::new();
|
|
let mut all_succeeded = true;
|
|
|
|
for arn in tgt_arns {
|
|
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(bucket, &arn).await else {
|
|
all_succeeded = false;
|
|
debug!(
|
|
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %arn,
|
|
reason = "target_client_missing",
|
|
"Skipping replication force-delete because target client is unavailable"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
continue;
|
|
};
|
|
|
|
let bucket = bucket.clone();
|
|
let object_name = object_name.clone();
|
|
|
|
join_set.spawn(async move {
|
|
if ReplicationTargetStore::target_is_offline(&tgt_client).await {
|
|
error!(
|
|
event = EVENT_REPLICATION_FORCE_DELETE_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
reason = "target_offline",
|
|
endpoint = %tgt_client.to_url(),
|
|
"Skipping replication force-delete"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationFailed.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if let Err(e) = tgt_client
|
|
.remove_object(&tgt_client.bucket, &object_name, None, replication_force_delete_remove_options())
|
|
.await
|
|
{
|
|
error!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
arn = %tgt_client.arn,
|
|
operation = "force_delete_remove_object",
|
|
error = %e,
|
|
"Replication target operation failed"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationFailed.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: ObjectInfo {
|
|
bucket: bucket.clone(),
|
|
name: object_name.clone(),
|
|
..Default::default()
|
|
},
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return false;
|
|
}
|
|
|
|
true
|
|
});
|
|
}
|
|
|
|
while let Some(result) = join_set.join_next().await {
|
|
match result {
|
|
Ok(success) => all_succeeded &= success,
|
|
Err(error) => {
|
|
all_succeeded = false;
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
operation = "force_delete",
|
|
error = %error,
|
|
"Replication resync task failed"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if all_succeeded
|
|
&& let Some(operation_id) = dobj.delete_object.force_delete_id
|
|
&& let Err(error) = super::replication_pool::complete_force_delete_intent(storage, operation_id).await
|
|
{
|
|
warn!(
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object_name,
|
|
operation_id = %operation_id,
|
|
error = %error,
|
|
"Force-delete replication completed but durable intent cleanup failed"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
all_succeeded
|
|
}
|
|
|
|
fn target_delete_version_id(version_id: Uuid, version_purge: bool) -> Option<String> {
|
|
if version_id.is_nil() {
|
|
version_purge.then(|| NULL_VERSION_ID.to_string())
|
|
} else {
|
|
Some(version_id.to_string())
|
|
}
|
|
}
|
|
|
|
async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_client: Arc<TargetClient>) -> ReplicatedTargetInfo {
|
|
let version_id = if let Some(version_id) = &dobj.delete_object.delete_marker_version_id {
|
|
version_id.to_owned()
|
|
} else {
|
|
dobj.delete_object.version_id.unwrap_or_default()
|
|
};
|
|
|
|
let mut rinfo = dobj
|
|
.delete_object
|
|
.replication_state
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.target_state(&tgt_client.arn);
|
|
rinfo.op_type = dobj.op_type;
|
|
rinfo.endpoint = tgt_client.endpoint.clone();
|
|
rinfo.secure = tgt_client.secure;
|
|
|
|
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
|
if !is_version_purge
|
|
&& rinfo.prev_replication_status == ReplicationStatusType::Completed
|
|
&& dobj.op_type != ReplicationType::ExistingObject
|
|
{
|
|
rinfo.replication_status = rinfo.prev_replication_status.clone();
|
|
return rinfo;
|
|
}
|
|
|
|
if is_version_purge && rinfo.version_purge_status == VersionPurgeStatusType::Complete {
|
|
return rinfo;
|
|
}
|
|
|
|
if ReplicationTargetStore::target_is_offline(&tgt_client).await {
|
|
if !is_version_purge {
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
} else {
|
|
rinfo.version_purge_status = VersionPurgeStatusType::Failed;
|
|
}
|
|
return rinfo;
|
|
}
|
|
|
|
let version_id = target_delete_version_id(version_id, is_version_purge);
|
|
|
|
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
|
|
match head_object_with_proxy_stats(
|
|
&dobj.bucket,
|
|
tgt_client.as_ref(),
|
|
&tgt_client.bucket,
|
|
&dobj.delete_object.object_name,
|
|
version_id.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
let non_retryable = matches!(
|
|
&e,
|
|
SdkError::ServiceError(service_err)
|
|
if is_retryable_delete_replication_head_error(
|
|
service_err.err().is_not_found(),
|
|
service_err.err().code(),
|
|
)
|
|
);
|
|
if non_retryable {
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
rinfo.error = Some(e.to_string());
|
|
return rinfo;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match tgt_client
|
|
.remove_object(
|
|
&tgt_client.bucket,
|
|
&dobj.delete_object.object_name,
|
|
version_id.clone(),
|
|
replication_delete_remove_options(dobj.delete_object.delete_marker, dobj.delete_object.delete_marker_mtime),
|
|
)
|
|
.await
|
|
{
|
|
Ok(assigned_version_id) => {
|
|
debug!(
|
|
bucket = tgt_client.bucket,
|
|
object = dobj.delete_object.object_name,
|
|
version_id = ?version_id,
|
|
assigned_version_id = ?assigned_version_id,
|
|
delete_marker = dobj.delete_object.delete_marker,
|
|
is_version_purge,
|
|
"replicate_delete_to_target succeeded"
|
|
);
|
|
if !is_version_purge {
|
|
// Record the version the target actually assigned to the marker it
|
|
// just created. A later purge addresses that id directly instead of
|
|
// deriving one from the source uuid, which only holds when the
|
|
// target mirrors source version ids.
|
|
if dobj.delete_object.delete_marker {
|
|
rinfo.target_delete_marker_version_id = assigned_version_id.filter(|version_id| !version_id.is_empty());
|
|
}
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
} else {
|
|
rinfo.version_purge_status = VersionPurgeStatusType::Complete;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = tgt_client.bucket,
|
|
object = dobj.delete_object.object_name,
|
|
version_id = ?version_id,
|
|
delete_marker = dobj.delete_object.delete_marker,
|
|
is_version_purge,
|
|
error = %e,
|
|
operation = "replicate_delete_to_target",
|
|
"Replication target operation failed"
|
|
);
|
|
rinfo.error = Some(e.to_string());
|
|
if !is_version_purge {
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
} else {
|
|
rinfo.version_purge_status = VersionPurgeStatusType::Failed;
|
|
}
|
|
mark_replication_target_offline_if_needed(&tgt_client, &e).await;
|
|
}
|
|
}
|
|
|
|
if rinfo.replication_status == ReplicationStatusType::Completed
|
|
&& !tgt_client.reset_id.is_empty()
|
|
&& dobj.op_type == ReplicationType::ExistingObject
|
|
{
|
|
rinfo.resync_timestamp = format!(
|
|
"{};{}",
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.unwrap_or_else(|_| "invalid-time".to_string()),
|
|
tgt_client.reset_id
|
|
);
|
|
}
|
|
|
|
rinfo
|
|
}
|
|
|
|
pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, storage: Arc<S>) -> ReplicationState {
|
|
replicate_object_with_outcome(roi, storage).await.0
|
|
}
|
|
|
|
pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
|
|
roi: ReplicateObjectInfo,
|
|
storage: Arc<S>,
|
|
) -> (ReplicationState, bool) {
|
|
let bucket = roi.bucket.clone();
|
|
let object = roi.name.clone();
|
|
|
|
let tgt_arns = roi.admitted_target_arns();
|
|
|
|
// Acquire a per-object namespace lock so that at most one worker (across all cluster
|
|
// nodes and MRF retry goroutines) replicates this object version at a time.
|
|
let obj_lock_key = format!("/[replicate]/{}", object);
|
|
let obj_ns_lock = match storage.new_ns_lock(&bucket, &obj_lock_key).await {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
error = %e,
|
|
reason = "ns_lock_create_failed",
|
|
"Skipping replication object"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: roi.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return (roi.replication_state.unwrap_or_default(), false);
|
|
}
|
|
};
|
|
let _obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
|
Ok(g) => g,
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
error = %e,
|
|
reason = "ns_lock_write_lock_failed",
|
|
"Skipping replication object"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: roi.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return (roi.replication_state.unwrap_or_default(), false);
|
|
}
|
|
};
|
|
|
|
let mut join_set = JoinSet::new();
|
|
|
|
for arn in tgt_arns {
|
|
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(&bucket, &arn).await else {
|
|
// Deliberately debug: this fires once per object per ARN, so a target that
|
|
// stays unreachable would flood the log from the replication hot path. The
|
|
// condition is reported once per pass by the site-replication reconciler and
|
|
// once per rebuild by `update_all_targets`, which is where an operator can act
|
|
// on it; the per-object event below still records each dropped object.
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
arn = %arn,
|
|
reason = "target_client_missing",
|
|
"Replication rule has no bucket target for its destination ARN; object not replicated"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: roi.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
continue;
|
|
};
|
|
|
|
let roi_clone = roi.clone();
|
|
let storage_clone = storage.clone();
|
|
join_set.spawn(async move {
|
|
if roi.op_type == ReplicationType::Object {
|
|
roi_clone.replicate_object(storage_clone, tgt_client).await
|
|
} else {
|
|
roi_clone.replicate_all(storage_clone, tgt_client).await
|
|
}
|
|
});
|
|
}
|
|
|
|
let mut rinfos = ReplicatedInfos {
|
|
replication_timestamp: Some(OffsetDateTime::now_utc()),
|
|
targets: Vec::with_capacity(join_set.len()),
|
|
};
|
|
|
|
while let Some(result) = join_set.join_next().await {
|
|
match result {
|
|
Ok(tgt_info) => {
|
|
rinfos.targets.push(tgt_info);
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
event = EVENT_RESYNC_TASK_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
operation = "replicate_object",
|
|
error = %e,
|
|
"Replication resync task failed"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: roi.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let previous_state = roi.replication_state.clone().unwrap_or_default();
|
|
let merged_state = get_replication_state(&rinfos, &previous_state, roi.version_id.map(|v| v.to_string()));
|
|
let replication_status = merged_state.composite_replication_status();
|
|
let new_replication_internal = merged_state.replication_status_internal.clone();
|
|
let mut object_info = roi.to_object_info();
|
|
let mut state_persisted = true;
|
|
|
|
if roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced() {
|
|
let mut eval_metadata = HashMap::new();
|
|
if let Some(ref s) = new_replication_internal {
|
|
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, s.clone());
|
|
}
|
|
let popts = ObjectOptions {
|
|
version_id: roi.version_id.map(|v| v.to_string()),
|
|
eval_metadata: Some(eval_metadata),
|
|
..Default::default()
|
|
};
|
|
|
|
match storage.put_object_metadata(&bucket, &object, &popts).await {
|
|
Ok(u) => object_info = u,
|
|
Err(e) => {
|
|
state_persisted = false;
|
|
// Persisting the resynced replication status failed. Don't swallow
|
|
// it silently — the object's on-disk status now disagrees with the
|
|
// resync result and needs operator visibility (backlog#799 B23).
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
error = %e,
|
|
"Failed to persist resynced replication status metadata"
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(stats) = runtime_sources::replication_stats() {
|
|
for tgt in &rinfos.targets {
|
|
if tgt.replication_status != tgt.prev_replication_status {
|
|
stats
|
|
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let event_name = if replication_status == ReplicationStatusType::Completed {
|
|
EventName::ObjectReplicationComplete.to_string()
|
|
} else {
|
|
EventName::ObjectReplicationFailed.to_string()
|
|
};
|
|
|
|
send_local_event(EventArgs {
|
|
event_name,
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
|
|
if rinfos.replication_status() != ReplicationStatusType::Completed
|
|
&& roi.replication_status_internal == rinfos.replication_status_internal()
|
|
&& let Some(stats) = runtime_sources::replication_stats()
|
|
{
|
|
for tgt in &rinfos.targets {
|
|
if tgt.replication_status != tgt.prev_replication_status {
|
|
stats
|
|
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
(merged_state, state_persisted)
|
|
}
|
|
|
|
trait ReplicateObjectInfoExt {
|
|
async fn replicate_object<S: ReplicationObjectIO>(
|
|
&self,
|
|
storage: Arc<S>,
|
|
tgt_client: Arc<TargetClient>,
|
|
) -> ReplicatedTargetInfo;
|
|
async fn replicate_all<S: ReplicationObjectIO>(&self, storage: Arc<S>, tgt_client: Arc<TargetClient>)
|
|
-> ReplicatedTargetInfo;
|
|
fn to_object_info(&self) -> ObjectInfo;
|
|
}
|
|
|
|
impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
|
async fn replicate_object<S: ReplicationObjectIO>(
|
|
&self,
|
|
storage: Arc<S>,
|
|
tgt_client: Arc<TargetClient>,
|
|
) -> ReplicatedTargetInfo {
|
|
let bucket = self.bucket.clone();
|
|
let object = self.name.clone();
|
|
|
|
let replication_action = ReplicationAction::All;
|
|
let mut rinfo = ReplicatedTargetInfo {
|
|
arn: tgt_client.arn.clone(),
|
|
size: self.actual_size,
|
|
replication_action,
|
|
op_type: self.op_type,
|
|
replication_status: ReplicationStatusType::Failed,
|
|
prev_replication_status: self.target_replication_status(&tgt_client.arn),
|
|
endpoint: tgt_client.endpoint.clone(),
|
|
secure: tgt_client.secure,
|
|
..Default::default()
|
|
};
|
|
|
|
if ReplicationTargetStore::target_is_offline(&tgt_client).await {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
reason = "target_offline",
|
|
endpoint = %tgt_client.to_url(),
|
|
"Skipping replication object target"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: self.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
|
|
let versioned = ReplicationVersioningStore::prefix_enabled(&bucket, &object).await;
|
|
let version_suspended = ReplicationVersioningStore::prefix_suspended(&bucket, &object).await;
|
|
|
|
let obj_opts = ObjectOptions {
|
|
version_id: self.version_id.map(|v| v.to_string()),
|
|
version_suspended,
|
|
versioned,
|
|
replication_request: true,
|
|
// SSE-C passthrough reads the stored ciphertext verbatim; the
|
|
// decrypting reader cannot serve it (no customer key server-side).
|
|
raw_data_movement_read: self.ssec,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut gr = match storage
|
|
.get_object_reader(&bucket, &object, None, HeaderMap::new(), &obj_opts)
|
|
.await
|
|
{
|
|
Ok(gr) => gr,
|
|
Err(e) => {
|
|
if !(is_err_object_not_found(&e) || is_err_version_not_found(&e)) {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %e,
|
|
reason = "object_reader_unavailable",
|
|
"Skipping replication object target"
|
|
);
|
|
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: self.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
return rinfo;
|
|
}
|
|
};
|
|
|
|
let object_info = gr.object_info.clone();
|
|
|
|
rinfo.prev_replication_status = object_info.target_replication_status(&tgt_client.arn);
|
|
|
|
let size = match object_info.get_actual_size() {
|
|
Ok(size) => size,
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %e,
|
|
reason = "actual_size_unavailable",
|
|
"Skipping replication object target"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
};
|
|
// SSE-C passthrough sends the stored ciphertext; the wire length is
|
|
// the stored size while rinfo keeps the logical size for metering.
|
|
let transfer_size = if self.ssec { object_info.size } else { size };
|
|
|
|
if tgt_client.bucket.is_empty() {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
reason = "target_bucket_empty",
|
|
"Skipping replication object target"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
|
|
let mut replication_action = replication_action;
|
|
match head_object_with_proxy_stats(
|
|
&bucket,
|
|
tgt_client.as_ref(),
|
|
&tgt_client.bucket,
|
|
&object,
|
|
self.version_id.map(|v| v.to_string()),
|
|
)
|
|
.await
|
|
{
|
|
Ok(oi) => {
|
|
replication_action = replication_action_for_target_head(&object_info, &oi, self.op_type);
|
|
if replication_action == ReplicationAction::None {
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
rinfo.replication_resynced = true;
|
|
rinfo.replication_action = ReplicationAction::None;
|
|
rinfo.size = size;
|
|
return rinfo;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if e.as_service_error().is_some_and(|se| se.is_not_found()) || has_raw_status(&e, 404) {
|
|
// Object not on target yet → fall through to PUT.
|
|
} else if is_version_id_format_mismatch(&e) {
|
|
// Version-ID format mismatch: retry without versionId and compare ETags.
|
|
match head_object_fallback(&bucket, &tgt_client, &object).await {
|
|
Ok(Some(oi)) if replication_etags_match(object_info.etag.as_deref(), oi.e_tag.as_deref()) => {
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
rinfo.replication_resynced = true;
|
|
rinfo.replication_action = ReplicationAction::None;
|
|
rinfo.size = size;
|
|
return rinfo;
|
|
}
|
|
Ok(_) => {}
|
|
Err(e2) => {
|
|
rinfo.error = Some(e2.to_string());
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
operation = "head_object_fallback",
|
|
error = %e2,
|
|
"Replication target operation failed"
|
|
);
|
|
return rinfo;
|
|
}
|
|
}
|
|
} else {
|
|
rinfo.error = Some(e.to_string());
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
operation = "head_object",
|
|
error = %e,
|
|
"Replication target operation failed"
|
|
);
|
|
return rinfo;
|
|
}
|
|
}
|
|
}
|
|
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
rinfo.replication_resynced = true;
|
|
rinfo.size = size;
|
|
rinfo.replication_action = replication_action;
|
|
|
|
let (put_opts, is_multipart) = match replication_put_object_options(&tgt_client.storage_class, &object_info) {
|
|
Ok((put_opts, is_mp)) => (put_opts, is_mp),
|
|
Err(e) => {
|
|
// Unsupported source metadata (e.g. managed SSE) is a fail-closed
|
|
// condition: report FAILED so the composite status and the
|
|
// OperationFailedReplication event reflect that nothing reached
|
|
// the target, instead of leaking the optimistic Completed above.
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
rinfo.error = Some(e.to_string());
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
operation = "build_put_options",
|
|
error = %e,
|
|
"Replication target operation failed"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
};
|
|
|
|
let has_tagging_replication = !put_opts.user_tags.is_empty();
|
|
if let Some(err) = if is_multipart {
|
|
drop(gr);
|
|
let result = replicate_object_with_multipart(MultipartReplicationContext {
|
|
storage: storage.clone(),
|
|
cli: tgt_client.clone(),
|
|
src_bucket: &bucket,
|
|
dst_bucket: &tgt_client.bucket,
|
|
object: &object,
|
|
object_info: &object_info,
|
|
obj_opts: &obj_opts,
|
|
arn: &rinfo.arn,
|
|
put_opts,
|
|
})
|
|
.await;
|
|
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
|
if has_tagging_replication {
|
|
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
|
|
}
|
|
result.err()
|
|
} else {
|
|
gr.stream = wrap_with_bandwidth_monitor(gr.stream, &put_opts, &bucket, &rinfo.arn);
|
|
let byte_stream = async_read_to_bytestream(gr.stream);
|
|
let result = tgt_client
|
|
.put_object(&tgt_client.bucket, &object, transfer_size, byte_stream, &put_opts)
|
|
.await
|
|
.map(|assigned_version_id| {
|
|
audit_target_version_identity(
|
|
&tgt_client,
|
|
&put_opts.internal.source_version_id,
|
|
assigned_version_id.as_deref(),
|
|
)
|
|
})
|
|
.map_err(|e| std::io::Error::other(e.to_string()));
|
|
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
|
if has_tagging_replication {
|
|
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
|
|
}
|
|
result.err()
|
|
} {
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
rinfo.error = Some(err.to_string());
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
target_bucket = %tgt_client.bucket,
|
|
arn = %tgt_client.arn,
|
|
object = %object,
|
|
operation = "put_object",
|
|
error = ?err,
|
|
"Replication target operation failed"
|
|
);
|
|
|
|
mark_replication_target_offline_if_needed(&tgt_client, &err).await;
|
|
return rinfo;
|
|
}
|
|
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
|
|
rinfo
|
|
}
|
|
|
|
async fn replicate_all<S: ReplicationObjectIO>(
|
|
&self,
|
|
storage: Arc<S>,
|
|
tgt_client: Arc<TargetClient>,
|
|
) -> ReplicatedTargetInfo {
|
|
let start_time = OffsetDateTime::now_utc();
|
|
|
|
let bucket = self.bucket.clone();
|
|
let object = self.name.clone();
|
|
|
|
let mut replication_action = ReplicationAction::Metadata;
|
|
let mut rinfo = ReplicatedTargetInfo {
|
|
arn: tgt_client.arn.clone(),
|
|
size: self.actual_size,
|
|
replication_action,
|
|
op_type: self.op_type,
|
|
replication_status: ReplicationStatusType::Failed,
|
|
prev_replication_status: self.target_replication_status(&tgt_client.arn),
|
|
endpoint: tgt_client.endpoint.clone(),
|
|
secure: tgt_client.secure,
|
|
..Default::default()
|
|
};
|
|
|
|
if ReplicationTargetStore::target_is_offline(&tgt_client).await {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
target = %tgt_client.to_url(),
|
|
reason = "target_offline",
|
|
"Skipped replication because target is offline"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: self.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
|
|
let versioned = ReplicationVersioningStore::prefix_enabled(&bucket, &object).await;
|
|
let version_suspended = ReplicationVersioningStore::prefix_suspended(&bucket, &object).await;
|
|
|
|
let obj_opts = ObjectOptions {
|
|
version_id: self.version_id.map(|v| v.to_string()),
|
|
version_suspended,
|
|
versioned,
|
|
replication_request: true,
|
|
// SSE-C passthrough reads the stored ciphertext verbatim; the
|
|
// decrypting reader cannot serve it (no customer key server-side).
|
|
raw_data_movement_read: self.ssec,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut gr = match storage
|
|
.get_object_reader(&bucket, &object, None, HeaderMap::new(), &obj_opts)
|
|
.await
|
|
{
|
|
Ok(gr) => gr,
|
|
Err(e) => {
|
|
if !(is_err_object_not_found(&e) || is_err_version_not_found(&e)) {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %e,
|
|
reason = "object_reader_unavailable",
|
|
"Skipped replication because object reader is unavailable"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: self.to_object_info(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
return rinfo;
|
|
}
|
|
};
|
|
|
|
let object_info = gr.object_info.clone();
|
|
|
|
rinfo.prev_replication_status = object_info.target_replication_status(&tgt_client.arn);
|
|
|
|
let size = match object_info.get_actual_size() {
|
|
Ok(size) => size,
|
|
Err(e) => {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %e,
|
|
reason = "actual_size_unavailable",
|
|
"Skipped replication because actual object size is unavailable"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
};
|
|
// SSE-C passthrough sends the stored ciphertext; the wire length is
|
|
// the stored size while rinfo keeps the logical size for metering.
|
|
let transfer_size = if self.ssec { object_info.size } else { size };
|
|
|
|
if tgt_client.bucket.is_empty() {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
reason = "target_bucket_empty",
|
|
"Skipped replication because target bucket is empty"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
return rinfo;
|
|
}
|
|
|
|
let mut sopts = StatObjectOptions {
|
|
version_id: object_info.version_id.map(|v| v.to_string()).unwrap_or_default(),
|
|
internal: AdvancedGetOptions {
|
|
replication_proxy_request: "false".to_string(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
if let Err(err) = sopts.set(AMZ_TAGGING_DIRECTIVE, "ACCESS") {
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %err,
|
|
reason = "tagging_directive_header_invalid",
|
|
"Skipped replication tagging directive header detail"
|
|
);
|
|
}
|
|
|
|
match head_object_with_proxy_stats(
|
|
&bucket,
|
|
tgt_client.as_ref(),
|
|
&tgt_client.bucket,
|
|
&object,
|
|
self.version_id.map(|v| v.to_string()),
|
|
)
|
|
.await
|
|
{
|
|
Ok(oi) => {
|
|
replication_action = replication_action_for_target_head(&object_info, &oi, self.op_type);
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
if replication_action == ReplicationAction::None {
|
|
if self.op_type == ReplicationType::ExistingObject
|
|
&& replication_target_head_is_newer_null_version(&object_info, &oi)
|
|
{
|
|
warn!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
arn = %tgt_client.arn,
|
|
endpoint = %tgt_client.to_url(),
|
|
reason = "target_newer_than_source_null_version",
|
|
"Skipping replication because newer target version exists"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info.clone(),
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
if object_info.target_replication_status(&tgt_client.arn) == ReplicationStatusType::Pending
|
|
|| object_info.target_replication_status(&tgt_client.arn) == ReplicationStatusType::Failed
|
|
|| self.op_type == ReplicationType::ExistingObject
|
|
{
|
|
rinfo.replication_action = replication_action;
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
}
|
|
|
|
if rinfo.replication_status == ReplicationStatusType::Completed
|
|
&& self.op_type == ReplicationType::ExistingObject
|
|
&& !tgt_client.reset_id.is_empty()
|
|
{
|
|
rinfo.resync_timestamp = format!(
|
|
"{};{}",
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.unwrap_or_else(|_| "invalid-time".to_string()),
|
|
tgt_client.reset_id
|
|
);
|
|
rinfo.replication_resynced = true;
|
|
}
|
|
|
|
rinfo.duration = (OffsetDateTime::now_utc() - start_time).unsigned_abs();
|
|
|
|
return rinfo;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if is_version_id_format_mismatch(&e) {
|
|
// Version-ID format mismatch: retry without versionId and compare ETags.
|
|
match head_object_fallback(&bucket, &tgt_client, &object).await {
|
|
Ok(Some(oi)) => {
|
|
replication_action = if replication_etags_match(object_info.etag.as_deref(), oi.e_tag.as_deref()) {
|
|
ReplicationAction::None
|
|
} else {
|
|
ReplicationAction::All
|
|
};
|
|
}
|
|
Ok(None) => {
|
|
replication_action = ReplicationAction::All;
|
|
}
|
|
Err(e2) => {
|
|
rinfo.error = Some(e2.to_string());
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %e2,
|
|
reason = "head_object_fallback_failed",
|
|
"Failed replication head-object fallback"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
rinfo.duration = (OffsetDateTime::now_utc() - start_time).unsigned_abs();
|
|
return rinfo;
|
|
}
|
|
}
|
|
} else if e.as_service_error().is_some_and(|se| se.is_not_found()) {
|
|
replication_action = ReplicationAction::All;
|
|
} else {
|
|
rinfo.error = Some(e.to_string());
|
|
debug!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
error = %e,
|
|
reason = "head_object_failed",
|
|
"Skipped replication because head-object failed"
|
|
);
|
|
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
|
|
rinfo.duration = (OffsetDateTime::now_utc() - start_time).unsigned_abs();
|
|
return rinfo;
|
|
}
|
|
}
|
|
};
|
|
|
|
rinfo.replication_status = ReplicationStatusType::Completed;
|
|
rinfo.size = size;
|
|
rinfo.replication_action = replication_action;
|
|
|
|
if replication_action == ReplicationAction::None {
|
|
// The target already holds a matching object (reached here only via
|
|
// the version-id fallback ETag match above) — there is nothing to
|
|
// copy. Record it as synced and return, instead of falling into the
|
|
// metadata propagation path below, which previously left
|
|
// AWS-style targets permanently FAILED and never converging
|
|
// (backlog#860 / #799 B11).
|
|
if self.op_type == ReplicationType::ExistingObject && !tgt_client.reset_id.is_empty() {
|
|
rinfo.resync_timestamp = format!(
|
|
"{};{}",
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.unwrap_or_else(|_| "invalid-time".to_string()),
|
|
tgt_client.reset_id
|
|
);
|
|
rinfo.replication_resynced = true;
|
|
}
|
|
rinfo.duration = (OffsetDateTime::now_utc() - start_time).unsigned_abs();
|
|
return rinfo;
|
|
}
|
|
|
|
// The target client has no metadata-only operation. Reuse the existing
|
|
// object transport so metadata changes carry tags and object-lock state
|
|
// atomically with the source version.
|
|
let (put_opts, is_multipart) = match replication_put_object_options(&tgt_client.storage_class, &object_info) {
|
|
Ok((put_opts, is_mp)) => (put_opts, is_mp),
|
|
Err(e) => {
|
|
// Unsupported source metadata (e.g. managed SSE) is a fail-closed
|
|
// condition: report FAILED so the composite status and the
|
|
// OperationFailedReplication event reflect that nothing reached
|
|
// the target, instead of leaking the optimistic Completed above.
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
rinfo.error = Some(e.to_string());
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
operation = "build_put_options",
|
|
error = %e,
|
|
"Replication target operation failed"
|
|
);
|
|
send_local_event(EventArgs {
|
|
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
|
bucket_name: bucket.clone(),
|
|
object: object_info,
|
|
user_agent: "Internal: [Replication]".to_string(),
|
|
..Default::default()
|
|
});
|
|
|
|
rinfo.duration = (OffsetDateTime::now_utc() - start_time).unsigned_abs();
|
|
return rinfo;
|
|
}
|
|
};
|
|
|
|
let has_tagging_replication = !put_opts.user_tags.is_empty();
|
|
if let Some(err) = if is_multipart {
|
|
drop(gr);
|
|
let result = replicate_object_with_multipart(MultipartReplicationContext {
|
|
storage: storage.clone(),
|
|
cli: tgt_client.clone(),
|
|
src_bucket: &bucket,
|
|
dst_bucket: &tgt_client.bucket,
|
|
object: &object,
|
|
object_info: &object_info,
|
|
obj_opts: &obj_opts,
|
|
arn: &rinfo.arn,
|
|
put_opts,
|
|
})
|
|
.await;
|
|
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
|
if has_tagging_replication {
|
|
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
|
|
}
|
|
result.err()
|
|
} else {
|
|
gr.stream = wrap_with_bandwidth_monitor(gr.stream, &put_opts, &bucket, &rinfo.arn);
|
|
let byte_stream = async_read_to_bytestream(gr.stream);
|
|
let result = tgt_client
|
|
.put_object(&tgt_client.bucket, &object, transfer_size, byte_stream, &put_opts)
|
|
.await
|
|
.map(|assigned_version_id| {
|
|
audit_target_version_identity(
|
|
&tgt_client,
|
|
&put_opts.internal.source_version_id,
|
|
assigned_version_id.as_deref(),
|
|
)
|
|
})
|
|
.map_err(|e| std::io::Error::other(e.to_string()));
|
|
record_proxy_request(&bucket, "PutObject", result.is_err()).await;
|
|
if has_tagging_replication {
|
|
record_proxy_request(&bucket, "PutObjectTagging", result.is_err()).await;
|
|
}
|
|
result.err()
|
|
} {
|
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
|
rinfo.error = Some(err.to_string());
|
|
warn!(
|
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
bucket = %bucket,
|
|
arn = %tgt_client.arn,
|
|
object = %object,
|
|
operation = "put_object",
|
|
error = ?err,
|
|
"Replication target operation failed"
|
|
);
|
|
rinfo.duration = (OffsetDateTime::now_utc() - start_time).unsigned_abs();
|
|
|
|
mark_replication_target_offline_if_needed(&tgt_client, &err).await;
|
|
return rinfo;
|
|
}
|
|
|
|
rinfo
|
|
}
|
|
|
|
fn to_object_info(&self) -> ObjectInfo {
|
|
ObjectInfo {
|
|
bucket: self.bucket.clone(),
|
|
name: self.name.clone(),
|
|
mod_time: self.mod_time,
|
|
version_id: self.version_id,
|
|
size: self.size,
|
|
user_tags: Arc::new(self.user_tags.clone()),
|
|
actual_size: self.actual_size,
|
|
replication_status_internal: self.replication_status_internal.clone(),
|
|
replication_status: self.replication_status.clone(),
|
|
version_purge_status_internal: self.version_purge_status_internal.clone(),
|
|
version_purge_status: self.version_purge_status.clone(),
|
|
delete_marker: self.delete_marker,
|
|
checksum: self.checksum.clone(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn wrap_with_bandwidth_monitor_with_header(
|
|
stream: Box<dyn AsyncRead + Unpin + Send + Sync>,
|
|
bucket: &str,
|
|
arn: &str,
|
|
header_size: usize,
|
|
) -> Box<dyn AsyncRead + Unpin + Send + Sync> {
|
|
if let Some(monitor) = runtime_sources::bucket_monitor() {
|
|
replication_bandwidth_boundary::wrap_reader(stream, monitor, bucket, arn, header_size)
|
|
} else {
|
|
WARNED_MONITOR_UNINIT.call_once(|| {
|
|
warn!(
|
|
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
reason = "bucket_monitor_uninitialized",
|
|
"Skipping replication bandwidth monitor because global bucket monitor is uninitialized"
|
|
)
|
|
});
|
|
stream
|
|
}
|
|
}
|
|
|
|
fn wrap_with_bandwidth_monitor(
|
|
stream: Box<dyn AsyncRead + Unpin + Send + Sync>,
|
|
put_opts: &PutObjectOptions,
|
|
bucket: &str,
|
|
arn: &str,
|
|
) -> Box<dyn AsyncRead + Unpin + Send + Sync> {
|
|
let header_size = replication_put_object_header_size(put_opts);
|
|
wrap_with_bandwidth_monitor_with_header(stream, bucket, arn, header_size)
|
|
}
|
|
|
|
fn async_read_to_bytestream(reader: impl AsyncRead + Send + Sync + Unpin + 'static) -> ByteStream {
|
|
// Non-retryable: SDK-level retries are not supported for streaming bodies.
|
|
// Replication-level retry handles failures at a higher layer.
|
|
let stream = ReaderStream::new(reader);
|
|
let body = StreamBody::new(stream.map(|r| r.map(Frame::data)));
|
|
ByteStream::new(SdkBody::from_body_1_x(body))
|
|
}
|
|
|
|
struct MultipartReplicationContext<'a, S: ReplicationObjectIO> {
|
|
storage: Arc<S>,
|
|
cli: Arc<TargetClient>,
|
|
src_bucket: &'a str,
|
|
dst_bucket: &'a str,
|
|
object: &'a str,
|
|
object_info: &'a ObjectInfo,
|
|
obj_opts: &'a ObjectOptions,
|
|
arn: &'a str,
|
|
put_opts: PutObjectOptions,
|
|
}
|
|
|
|
async fn replicate_object_with_multipart<S: ReplicationObjectIO>(ctx: MultipartReplicationContext<'_, S>) -> std::io::Result<()> {
|
|
let MultipartReplicationContext {
|
|
storage,
|
|
cli,
|
|
src_bucket,
|
|
dst_bucket,
|
|
object,
|
|
object_info,
|
|
obj_opts,
|
|
arn,
|
|
put_opts,
|
|
} = ctx;
|
|
let mut attempts = 1;
|
|
let upload_id = loop {
|
|
match cli.create_multipart_upload(dst_bucket, object, &put_opts).await {
|
|
Ok(id) => {
|
|
break id;
|
|
}
|
|
Err(e) => {
|
|
attempts += 1;
|
|
if attempts > 3 {
|
|
return Err(std::io::Error::other(e.to_string()));
|
|
}
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
continue;
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut uploaded_parts: Vec<CompletedPart> = Vec::new();
|
|
|
|
let mut header_size = replication_put_object_header_size(&put_opts);
|
|
let mut offset: i64 = 0;
|
|
for part_info in object_info.parts.iter() {
|
|
// Ciphertext passthrough (raw read) ranges over the stored part
|
|
// bytes; decrypted reads range over the logical plaintext parts.
|
|
let part_size = if obj_opts.raw_data_movement_read {
|
|
part_info.size as i64
|
|
} else {
|
|
part_info.actual_size
|
|
};
|
|
let part_plan = replication_multipart_part_plan(ReplicationMultipartPartInput {
|
|
offset,
|
|
part_number: part_info.number,
|
|
part_size,
|
|
})
|
|
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
|
let range_spec = HTTPRangeSpec {
|
|
is_suffix_length: false,
|
|
start: part_plan.range.start,
|
|
end: part_plan.range.end,
|
|
};
|
|
offset = part_plan.next_offset;
|
|
|
|
let part_reader = storage
|
|
.get_object_reader(src_bucket, object, Some(range_spec), HeaderMap::new(), obj_opts)
|
|
.await
|
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
|
|
let part_stream = wrap_with_bandwidth_monitor_with_header(part_reader.stream, src_bucket, arn, header_size);
|
|
header_size = 0;
|
|
let byte_stream = async_read_to_bytestream(part_stream);
|
|
|
|
let object_part = cli
|
|
.put_object_part(
|
|
dst_bucket,
|
|
object,
|
|
&upload_id,
|
|
part_plan.part_number,
|
|
part_plan.part_size,
|
|
byte_stream,
|
|
&PutObjectPartOptions { ..Default::default() },
|
|
)
|
|
.await
|
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
|
|
let etag = object_part.e_tag.unwrap_or_default();
|
|
|
|
uploaded_parts.push(
|
|
CompletedPart::builder()
|
|
.part_number(part_plan.part_number)
|
|
.e_tag(etag)
|
|
.build(),
|
|
);
|
|
}
|
|
|
|
let actual_size = replication_multipart_complete_actual_size(&object_info.user_defined);
|
|
|
|
let completed = cli
|
|
.complete_multipart_upload(
|
|
dst_bucket,
|
|
object,
|
|
&upload_id,
|
|
uploaded_parts,
|
|
&replication_complete_multipart_options(
|
|
actual_size,
|
|
object_info.etag.clone().unwrap_or_default(),
|
|
object_info.mod_time,
|
|
),
|
|
)
|
|
.await
|
|
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
|
|
// Multipart decides the target version at initiate time and only reveals
|
|
// it on completion, so this is where the identity contract is observable
|
|
// for this path. A target can mirror PutObject version ids and still mint
|
|
// its own here, which would leave multipart deletes and heals addressing
|
|
// a version that never existed.
|
|
audit_target_version_identity(&cli, &put_opts.internal.source_version_id, completed.version_id());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
|
|
use super::super::replication_target_boundary::{BucketTarget, BucketTargets};
|
|
use super::*;
|
|
use s3s::dto::{
|
|
BucketVersioningStatus, DeleteReplication, DeleteReplicationStatus, Destination, ExcludedPrefix, ReplicationRule,
|
|
ReplicationRuleStatus, VersioningConfiguration,
|
|
};
|
|
use std::collections::HashMap;
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
fn test_target_client(endpoint: String) -> Arc<TargetClient> {
|
|
let config = aws_sdk_s3::Config::builder()
|
|
.endpoint_url(endpoint.clone())
|
|
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
|
.credentials_provider(aws_sdk_s3::config::SharedCredentialsProvider::new(
|
|
aws_credential_types::Credentials::new("access", "secret", None, None, "test"),
|
|
))
|
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
|
.build();
|
|
|
|
Arc::new(TargetClient {
|
|
endpoint,
|
|
credentials: None,
|
|
bucket: "target-bucket".to_string(),
|
|
storage_class: String::new(),
|
|
disable_proxy: false,
|
|
arn: format!("arn:rustfs:replication:us-east-1:target:{}", Uuid::new_v4()),
|
|
reset_id: String::new(),
|
|
secure: false,
|
|
health_check_duration: std::time::Duration::from_secs(5),
|
|
replicate_sync: false,
|
|
client: Arc::new(aws_sdk_s3::Client::from_conf(config)),
|
|
})
|
|
}
|
|
|
|
async fn register_test_target(target: &Arc<TargetClient>) {
|
|
ReplicationTargetStore::register_test_target(target).await;
|
|
}
|
|
|
|
/// P1-19 runtime spot-check exemption matrix: drift only applies when the
|
|
/// source addressed a real version uuid.
|
|
#[test]
|
|
fn test_version_identity_drift_judgment() {
|
|
let source = "6fa459ea-ee8a-3ca4-894e-db77e160355e";
|
|
for (sent, got, expected) in [
|
|
(source, Some(source), false),
|
|
(source, Some("0e304ce5-33e9-4b8a-9b12-9e40a53e6ded"), true),
|
|
(source, None, true),
|
|
("", None, false),
|
|
("null", Some("anything"), false),
|
|
("00000000-0000-0000-0000-000000000000", Some("anything"), false),
|
|
] {
|
|
assert_eq!(
|
|
version_identity_drifted(sent, got),
|
|
expected,
|
|
"sent {sent:?} got {got:?} must judge drift = {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resync_admission_configuration_is_bounded() {
|
|
assert_eq!(ENV_REPL_RESYNC_MAX_JOBS, "RUSTFS_REPL_RESYNC_MAX_JOBS");
|
|
assert_eq!(bounded_resync_max_jobs(0), 1);
|
|
assert_eq!(bounded_resync_max_jobs(DEFAULT_REPL_RESYNC_MAX_JOBS), 2);
|
|
assert_eq!(bounded_resync_max_jobs(1000), MAX_REPL_RESYNC_MAX_JOBS);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resync_admission_limits_jobs_and_wait_is_cancelable() {
|
|
let resyncer = ReplicationResyncer {
|
|
resync_admission: Arc::new(Semaphore::new(2)),
|
|
..ReplicationResyncer::new().await
|
|
};
|
|
let first = resyncer
|
|
.acquire_resync_admission(&CancellationToken::new())
|
|
.await
|
|
.expect("first resync should acquire admission");
|
|
let second = resyncer
|
|
.acquire_resync_admission(&CancellationToken::new())
|
|
.await
|
|
.expect("second resync should acquire admission");
|
|
let cancellation = CancellationToken::new();
|
|
let blocked = resyncer.acquire_resync_admission(&cancellation);
|
|
tokio::pin!(blocked);
|
|
|
|
assert!(
|
|
tokio::time::timeout(TokioDuration::from_millis(25), &mut blocked)
|
|
.await
|
|
.is_err()
|
|
);
|
|
cancellation.cancel();
|
|
assert!(
|
|
tokio::time::timeout(TokioDuration::from_secs(1), &mut blocked)
|
|
.await
|
|
.expect("canceled admission wait should finish")
|
|
.is_none()
|
|
);
|
|
|
|
drop((first, second));
|
|
}
|
|
|
|
#[test]
|
|
fn replication_target_offline_error_classifier_is_network_scoped() {
|
|
assert!(is_replication_target_offline_error(&"put_object dispatch failure: connector error"));
|
|
assert!(is_replication_target_offline_error(&"request TimeoutError after retry"));
|
|
assert!(is_replication_target_offline_error(&"tcp connect error: connection refused"));
|
|
assert!(!is_replication_target_offline_error(&"put_object failed: AccessDenied: denied"));
|
|
assert!(!is_replication_target_offline_error(&"put_object failed: NoSuchBucket"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replication_target_network_failure_marks_target_offline() {
|
|
let endpoint = format!("http://network-failure-{}.example:9000", Uuid::new_v4());
|
|
let target_client = test_target_client(endpoint);
|
|
register_test_target(&target_client).await;
|
|
|
|
assert!(!ReplicationTargetStore::target_is_offline(&target_client).await);
|
|
|
|
let err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
|
|
mark_replication_target_offline_if_needed(&target_client, &err).await;
|
|
|
|
assert!(ReplicationTargetStore::target_is_offline(&target_client).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replication_target_service_failure_keeps_target_online() {
|
|
let endpoint = format!("http://service-failure-{}.example:9000", Uuid::new_v4());
|
|
let target_client = test_target_client(endpoint);
|
|
register_test_target(&target_client).await;
|
|
|
|
assert!(!ReplicationTargetStore::target_is_offline(&target_client).await);
|
|
|
|
mark_replication_target_offline_if_needed(&target_client, &"put_object failed: AccessDenied: denied").await;
|
|
|
|
assert!(!ReplicationTargetStore::target_is_offline(&target_client).await);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unmarshal_resync_payload() {
|
|
let start = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid ts");
|
|
let last = OffsetDateTime::from_unix_timestamp(1_700_000_123).expect("valid ts");
|
|
let before = OffsetDateTime::from_unix_timestamp(1_699_000_000).expect("valid ts");
|
|
let bucket_last = OffsetDateTime::from_unix_timestamp(1_700_111_111).expect("valid ts");
|
|
|
|
let mut payload = Vec::new();
|
|
rmp::encode::write_map_len(&mut payload, 4).expect("write map");
|
|
rmp::encode::write_str(&mut payload, "v").expect("write key");
|
|
rmp::encode::write_i32(&mut payload, 1).expect("write version");
|
|
rmp::encode::write_str(&mut payload, "brs").expect("write key");
|
|
rmp::encode::write_map_len(&mut payload, 1).expect("write target map");
|
|
rmp::encode::write_str(&mut payload, "arn:replication::1:dest").expect("write arn");
|
|
rmp::encode::write_map_len(&mut payload, 11).expect("write target");
|
|
rmp::encode::write_str(&mut payload, "st").expect("write key");
|
|
ReplicationMsgpCodec::write_time(&mut payload, start).expect("write time");
|
|
rmp::encode::write_str(&mut payload, "lst").expect("write key");
|
|
ReplicationMsgpCodec::write_time(&mut payload, last).expect("write time");
|
|
rmp::encode::write_str(&mut payload, "id").expect("write key");
|
|
rmp::encode::write_str(&mut payload, "resync-1").expect("write id");
|
|
rmp::encode::write_str(&mut payload, "rdt").expect("write key");
|
|
ReplicationMsgpCodec::write_time(&mut payload, before).expect("write time");
|
|
rmp::encode::write_str(&mut payload, "rst").expect("write key");
|
|
rmp::encode::write_i32(&mut payload, 3).expect("write status");
|
|
rmp::encode::write_str(&mut payload, "fs").expect("write key");
|
|
rmp::encode::write_i64(&mut payload, 11).expect("write fs");
|
|
rmp::encode::write_str(&mut payload, "frc").expect("write key");
|
|
rmp::encode::write_i64(&mut payload, 2).expect("write frc");
|
|
rmp::encode::write_str(&mut payload, "rs").expect("write key");
|
|
rmp::encode::write_i64(&mut payload, 101).expect("write rs");
|
|
rmp::encode::write_str(&mut payload, "rrc").expect("write key");
|
|
rmp::encode::write_i64(&mut payload, 9).expect("write rrc");
|
|
rmp::encode::write_str(&mut payload, "bkt").expect("write key");
|
|
rmp::encode::write_str(&mut payload, "bucket-a").expect("write bucket");
|
|
rmp::encode::write_str(&mut payload, "obj").expect("write key");
|
|
rmp::encode::write_str(&mut payload, "object-a").expect("write obj");
|
|
rmp::encode::write_str(&mut payload, "id").expect("write key");
|
|
rmp::encode::write_i32(&mut payload, 42).expect("write id");
|
|
rmp::encode::write_str(&mut payload, "lu").expect("write key");
|
|
ReplicationMsgpCodec::write_time(&mut payload, bucket_last).expect("write lu");
|
|
|
|
let got = BucketReplicationResyncStatus::unmarshal_msg(&payload).expect("decode");
|
|
assert_eq!(got.version, 1);
|
|
assert_eq!(got.id, 42);
|
|
assert_eq!(got.last_update, Some(bucket_last));
|
|
let tgt = got.targets_map.get("arn:replication::1:dest").expect("target exists");
|
|
assert_eq!(tgt.resync_id, "resync-1");
|
|
assert_eq!(tgt.resync_status, ResyncStatusType::ResyncStarted);
|
|
assert_eq!(tgt.bucket, "bucket-a");
|
|
assert_eq!(tgt.object, "object-a");
|
|
assert_eq!(tgt.start_time, Some(start));
|
|
assert_eq!(tgt.last_update, Some(last));
|
|
assert_eq!(tgt.resync_before_date, Some(before));
|
|
assert_eq!(tgt.error, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_unmarshal_legacy_resync_payload() {
|
|
let mut status = BucketReplicationResyncStatus::new();
|
|
status.id = 7;
|
|
status.version = 1;
|
|
status.last_update = Some(OffsetDateTime::from_unix_timestamp(1_700_222_222).expect("valid ts"));
|
|
status.targets_map = HashMap::from([(
|
|
"legacy-arn".to_string(),
|
|
TargetReplicationResyncStatus {
|
|
resync_id: "legacy-1".to_string(),
|
|
resync_status: ResyncStatusType::ResyncCompleted,
|
|
..Default::default()
|
|
},
|
|
)]);
|
|
|
|
let old_payload = rmp_serde::to_vec(&status).expect("legacy encode");
|
|
let got = BucketReplicationResyncStatus::unmarshal_legacy_msg(&old_payload).expect("legacy decode");
|
|
assert_eq!(got.id, 7);
|
|
assert_eq!(got.version, 1);
|
|
assert_eq!(got.targets_map["legacy-arn"].resync_id, "legacy-1");
|
|
assert_eq!(got.targets_map["legacy-arn"].resync_status, ResyncStatusType::ResyncCompleted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resync_file_roundtrip_wire_format() {
|
|
let mut status = BucketReplicationResyncStatus::new();
|
|
status.id = 19;
|
|
status.last_update = Some(OffsetDateTime::from_unix_timestamp(1_700_333_333).expect("valid ts"));
|
|
status.targets_map = HashMap::from([(
|
|
"arn:replication::1:dest".to_string(),
|
|
TargetReplicationResyncStatus {
|
|
resync_id: "wire-1".to_string(),
|
|
resync_status: ResyncStatusType::ResyncStarted,
|
|
replicated_count: 5,
|
|
..Default::default()
|
|
},
|
|
)]);
|
|
|
|
let bytes = encode_resync_file(&status).expect("encode file");
|
|
assert_eq!(&bytes[0..2], &RESYNC_META_FORMAT.to_le_bytes());
|
|
assert_eq!(&bytes[2..4], &RESYNC_META_VERSION.to_le_bytes());
|
|
|
|
let got = decode_resync_file(&bytes).expect("decode file");
|
|
assert_eq!(got.version, RESYNC_META_VERSION);
|
|
assert_eq!(got.id, 19);
|
|
assert_eq!(got.targets_map["arn:replication::1:dest"].resync_id, "wire-1");
|
|
assert_eq!(got.targets_map["arn:replication::1:dest"].replicated_count, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resync_file_decodes_legacy_payload() {
|
|
let mut status = BucketReplicationResyncStatus::new();
|
|
status.id = 7;
|
|
status.version = RESYNC_META_VERSION;
|
|
status.targets_map = HashMap::from([(
|
|
"legacy-arn".to_string(),
|
|
TargetReplicationResyncStatus {
|
|
resync_id: "legacy-v1".to_string(),
|
|
resync_status: ResyncStatusType::ResyncCompleted,
|
|
..Default::default()
|
|
},
|
|
)]);
|
|
|
|
let legacy_payload = rmp_serde::to_vec(&status).expect("legacy encode");
|
|
let mut file_bytes = Vec::new();
|
|
file_bytes.extend_from_slice(&RESYNC_META_FORMAT.to_le_bytes());
|
|
file_bytes.extend_from_slice(&RESYNC_META_VERSION.to_le_bytes());
|
|
file_bytes.extend_from_slice(&legacy_payload);
|
|
|
|
let got = decode_resync_file(&file_bytes).expect("decode legacy");
|
|
assert_eq!(got.id, 7);
|
|
assert_eq!(got.targets_map["legacy-arn"].resync_id, "legacy-v1");
|
|
assert_eq!(got.targets_map["legacy-arn"].resync_status, ResyncStatusType::ResyncCompleted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resync_none_time_encodes_as_wire_zero_and_decodes_to_none() {
|
|
let wire_zero = OffsetDateTime::from_unix_timestamp(WIRE_ZERO_TIME_UNIX).expect("valid wire zero timestamp");
|
|
|
|
let mut with_none = BucketReplicationResyncStatus::new();
|
|
with_none.id = 77;
|
|
with_none.targets_map = HashMap::from([(
|
|
"arn:replication::1:dest".to_string(),
|
|
TargetReplicationResyncStatus {
|
|
resync_id: "wire-none".to_string(),
|
|
resync_status: ResyncStatusType::ResyncStarted,
|
|
replicated_count: 1,
|
|
..Default::default()
|
|
},
|
|
)]);
|
|
|
|
let mut with_zero = with_none.clone();
|
|
with_zero.last_update = Some(wire_zero);
|
|
if let Some(target) = with_zero.targets_map.get_mut("arn:replication::1:dest") {
|
|
target.start_time = Some(wire_zero);
|
|
target.last_update = Some(wire_zero);
|
|
target.resync_before_date = Some(wire_zero);
|
|
}
|
|
|
|
let encoded_none = encode_resync_file(&with_none).expect("encode with none");
|
|
let encoded_zero = encode_resync_file(&with_zero).expect("encode with zero");
|
|
assert_eq!(encoded_none, encoded_zero);
|
|
|
|
let decoded = decode_resync_file(&encoded_none).expect("decode");
|
|
let target = decoded
|
|
.targets_map
|
|
.get("arn:replication::1:dest")
|
|
.expect("target should exist");
|
|
assert_eq!(decoded.last_update, None);
|
|
assert_eq!(target.start_time, None);
|
|
assert_eq!(target.last_update, None);
|
|
assert_eq!(target.resync_before_date, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_replicate_object_info_to_object_info_preserves_delete_marker_flag() {
|
|
let live = ReplicateObjectInfo {
|
|
bucket: "source".to_string(),
|
|
name: "object".to_string(),
|
|
delete_marker: false,
|
|
..Default::default()
|
|
};
|
|
let delete_marker = ReplicateObjectInfo {
|
|
bucket: "source".to_string(),
|
|
name: "object".to_string(),
|
|
delete_marker: true,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(!live.to_object_info().delete_marker);
|
|
assert!(delete_marker.to_object_info().delete_marker);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_version_delete_replication_for_delete_marker_version_purge() {
|
|
let dobj = ReplicationDeletedObject {
|
|
delete_marker: false,
|
|
delete_marker_version_id: Some(Uuid::new_v4()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
is_version_delete_replication(&dobj),
|
|
"delete-marker version purges must be tracked as version purge replication, not delete-marker creation replication"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_version_delete_replication_for_delete_marker_creation() {
|
|
let dobj = ReplicationDeletedObject {
|
|
delete_marker: true,
|
|
delete_marker_version_id: Some(Uuid::new_v4()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
!is_version_delete_replication(&dobj),
|
|
"delete-marker creation should remain on the delete-marker replication path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_retry_delete_marker_purge_for_version_purge() {
|
|
let dobj = ReplicationDeletedObject {
|
|
delete_marker: false,
|
|
delete_marker_version_id: Some(Uuid::new_v4()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
should_retry_delete_marker_purge(&dobj),
|
|
"delete-marker version purge should schedule delayed target cleanup in case the target marker arrives late"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_retry_delete_marker_purge_for_delete_marker_creation() {
|
|
let dobj = ReplicationDeletedObject {
|
|
delete_marker: true,
|
|
delete_marker_version_id: Some(Uuid::new_v4()),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
should_retry_delete_marker_purge(&dobj),
|
|
"delete-marker creation should keep the late-arrival cleanup path so downstream purges can catch up"
|
|
);
|
|
}
|
|
|
|
/// P1-21 regression guard for the outcome formula. A fully successful
|
|
/// delete-marker replication must acknowledge its MRF entry: the formula
|
|
/// once carried `&& !requires_delayed_purge`, which pinned every
|
|
/// delete-marker entry to Missed and retained the whole backlog forever.
|
|
/// (Deterministically staging a marker-creation entry in the durable
|
|
/// journal from e2e would require saturating the worker queues, so the
|
|
/// formula is pinned here instead; the purge-intent replay half is pinned
|
|
/// by the delayed-purge e2e pair.)
|
|
#[test]
|
|
fn test_replicate_delete_outcome_is_not_held_hostage_by_the_delayed_purge() {
|
|
assert!(
|
|
replicate_delete_outcome(1, 1, true, true, &ReplicationStatusType::Completed),
|
|
"a completed delete-marker replication must be acknowledgeable even though a delayed purge watch is pending"
|
|
);
|
|
assert!(!replicate_delete_outcome(0, 0, true, true, &ReplicationStatusType::Completed));
|
|
assert!(!replicate_delete_outcome(2, 1, true, true, &ReplicationStatusType::Completed));
|
|
assert!(!replicate_delete_outcome(1, 1, false, true, &ReplicationStatusType::Completed));
|
|
assert!(!replicate_delete_outcome(1, 1, true, false, &ReplicationStatusType::Completed));
|
|
assert!(!replicate_delete_outcome(1, 1, true, true, &ReplicationStatusType::Failed));
|
|
}
|
|
|
|
/// P1-21 review follow-up: a target whose recorded marker version is
|
|
/// inconsistent must be reported as a per-target FAILURE. Treating the
|
|
/// refusal as success let the watcher and the MRF replay drop the purge
|
|
/// intent while the marker was still on the target.
|
|
#[tokio::test]
|
|
async fn test_delete_marker_purge_reports_corrupt_recorded_version_as_failure() {
|
|
let arn = format!("arn:rustfs:replication:us-east-1:corrupt:{}", Uuid::new_v4());
|
|
let mut dsc = ReplicateDecision::new();
|
|
dsc.set(ReplicateTargetDecision::new(arn.clone(), true, false));
|
|
|
|
let mut state = ReplicationState {
|
|
target_delete_marker_version_ids_corrupt: true,
|
|
..Default::default()
|
|
};
|
|
state.targets.insert(arn.clone(), ReplicationStatusType::Completed);
|
|
|
|
let dobj = DeletedObjectReplicationInfo {
|
|
delete_object: ReplicationDeletedObject {
|
|
object_name: "doc.txt".to_string(),
|
|
delete_marker: true,
|
|
delete_marker_version_id: Some(Uuid::new_v4()),
|
|
replication_state: Some(state),
|
|
..Default::default()
|
|
},
|
|
bucket: "bucket-a".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
// No target client is registered: the refusal must be decided from
|
|
// the recorded metadata alone, before any remote call is attempted.
|
|
let failed = replicate_delete_marker_purge_to_targets("bucket-a", &dobj, &dsc, None).await;
|
|
|
|
assert_eq!(
|
|
failed,
|
|
vec![arn],
|
|
"a refused purge must stay in the failed set so the intent is never acknowledged"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_marker_purge_mrf_entry_replays_through_the_stale_marker_branch() {
|
|
let delete_marker_version_id = Uuid::new_v4();
|
|
let dobj = DeletedObjectReplicationInfo {
|
|
delete_object: ReplicationDeletedObject {
|
|
object_name: "doc.txt".to_string(),
|
|
// A version-purge flavored source event: the entry must still
|
|
// be reshaped as a marker-creation delete so replay funnels
|
|
// into the stale-marker branch instead of re-running the full
|
|
// delete replication (whose source-state stamping would fail
|
|
// against the already-purged version).
|
|
delete_marker: false,
|
|
version_id: Some(Uuid::new_v4()),
|
|
delete_marker_version_id: Some(delete_marker_version_id),
|
|
..Default::default()
|
|
},
|
|
bucket: "bucket-a".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
let entry = delete_marker_purge_mrf_entry(&dobj, vec!["arn:a".to_string()]);
|
|
|
|
assert!(entry.delete_marker, "purge intents must replay as marker-creation deletes");
|
|
assert_eq!(entry.version_id, None, "the purged data version must not leak into the replay");
|
|
assert_eq!(entry.delete_marker_version_id, Some(delete_marker_version_id));
|
|
assert_eq!(
|
|
entry.target_arns,
|
|
vec!["arn:a".to_string()],
|
|
"only the targets whose purge failed may be retried"
|
|
);
|
|
assert_eq!(entry.retry_count, 0);
|
|
assert_eq!(entry.bucket, "bucket-a");
|
|
assert_eq!(entry.object, "doc.txt");
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_retryable_delete_replication_head_error_allows_delete_marker_head_responses() {
|
|
assert!(
|
|
!is_retryable_delete_replication_head_error(false, Some("405")),
|
|
"numeric 405 responses should not block delete-marker purge replication"
|
|
);
|
|
assert!(
|
|
!is_retryable_delete_replication_head_error(false, Some("MethodNotAllowed")),
|
|
"MethodNotAllowed responses should not block delete-marker purge replication"
|
|
);
|
|
assert!(
|
|
!is_retryable_delete_replication_head_error(true, Some("NoSuchKey")),
|
|
"not-found responses should not block delete-marker purge replication"
|
|
);
|
|
assert!(
|
|
is_retryable_delete_replication_head_error(false, Some("AccessDenied")),
|
|
"unexpected head errors should still fail fast"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_head_proxy_failure_ignores_not_found_and_405() {
|
|
assert!(
|
|
!should_count_head_proxy_failure(true, Some("NoSuchKey"), Some(404)),
|
|
"not-found heads are expected when the object has not reached the target yet"
|
|
);
|
|
assert!(
|
|
!should_count_head_proxy_failure(false, Some("MethodNotAllowed"), Some(405)),
|
|
"405 delete-marker probing responses should not be counted as proxy failures"
|
|
);
|
|
assert!(
|
|
!should_count_head_proxy_failure(false, Some("405"), Some(405)),
|
|
"numeric 405 codes must align with MethodNotAllowed semantics"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_head_proxy_failure_ignores_version_id_format_rejections() {
|
|
assert!(
|
|
!should_count_head_proxy_failure(false, Some("InvalidArgument"), Some(400)),
|
|
"InvalidArgument/400 is a version-ID format rejection and must not be counted as a proxy failure"
|
|
);
|
|
assert!(
|
|
!should_count_head_proxy_failure(false, None, Some(400)),
|
|
"raw HTTP 400 without error code must not be counted as a proxy failure"
|
|
);
|
|
assert!(
|
|
!should_count_head_proxy_failure(false, None, Some(403)),
|
|
"raw HTTP 403 without error code must not be counted as a proxy failure (IAM user + invalid versionId)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_version_id_mismatch_detects_invalid_argument() {
|
|
assert!(
|
|
is_version_id_mismatch(Some("InvalidArgument"), Some(400)),
|
|
"AWS S3 returns InvalidArgument/400 when a UUID versionId is passed to HeadObject"
|
|
);
|
|
assert!(
|
|
!is_version_id_mismatch(Some("AccessDenied"), Some(403)),
|
|
"AccessDenied must not trigger the version-ID fallback path"
|
|
);
|
|
assert!(
|
|
!is_version_id_mismatch(Some("NoSuchKey"), Some(404)),
|
|
"NoSuchKey is an object-not-found response, not a version-ID mismatch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_version_id_mismatch_raw_status_without_service_code() {
|
|
assert!(
|
|
is_version_id_mismatch(None, Some(400)),
|
|
"no error code + HTTP 400 is treated as version-ID mismatch (HEAD response)"
|
|
);
|
|
assert!(
|
|
is_version_id_mismatch(Some(""), Some(400)),
|
|
"empty error code + HTTP 400 is treated as version-ID mismatch"
|
|
);
|
|
assert!(
|
|
is_version_id_mismatch(None, Some(403)),
|
|
"no error code + HTTP 403 is treated as version-ID mismatch (IAM user + invalid versionId)"
|
|
);
|
|
assert!(
|
|
is_version_id_mismatch(Some(""), Some(403)),
|
|
"empty error code + HTTP 403 is treated as version-ID mismatch"
|
|
);
|
|
assert!(
|
|
!is_version_id_mismatch(None, Some(500)),
|
|
"raw 5xx must not trigger the version-ID fallback path"
|
|
);
|
|
assert!(
|
|
!is_version_id_mismatch(None, Some(404)),
|
|
"raw 404 must not trigger the version-ID fallback path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_version_id_mismatch_400_with_other_service_code() {
|
|
assert!(
|
|
!is_version_id_mismatch(Some("MalformedXML"), Some(400)),
|
|
"MalformedXML/400 is a real request error and must not trigger version-ID fallback"
|
|
);
|
|
assert!(
|
|
!is_version_id_mismatch(Some("EntityTooLarge"), Some(400)),
|
|
"EntityTooLarge/400 is a real request error and must not trigger version-ID fallback"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_should_count_head_proxy_failure_counts_unexpected_errors() {
|
|
assert!(
|
|
should_count_head_proxy_failure(false, Some("AccessDenied"), Some(403)),
|
|
"non-NotFound and non-405 service errors should be counted as failures"
|
|
);
|
|
assert!(
|
|
should_count_head_proxy_failure(false, None, Some(500)),
|
|
"raw 5xx head responses should be counted as proxy failures"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_heal_replicate_object_info_failed_object_returns_heal_roi() {
|
|
let oi = ObjectInfo {
|
|
bucket: "test-bucket".to_string(),
|
|
name: "key".to_string(),
|
|
delete_marker: false,
|
|
replication_status: ReplicationStatusType::Failed,
|
|
version_id: Some(Uuid::nil()),
|
|
mod_time: Some(OffsetDateTime::now_utc()),
|
|
..Default::default()
|
|
};
|
|
let rcfg = ReplicationConfig::new(None, None);
|
|
let roi = get_heal_replicate_object_info(&oi, &rcfg)
|
|
.await
|
|
.expect("non-delete heal classification should succeed");
|
|
|
|
assert_eq!(roi.replication_status, ReplicationStatusType::Failed);
|
|
assert_eq!(roi.op_type, ReplicationType::Heal);
|
|
assert!(
|
|
roi.dsc.replicate_any() || roi.dsc.targets_map.is_empty(),
|
|
"With no replication config, dsc may be empty; with config, replicate_any() would be true and queueing would occur"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_heal_replicate_object_info_preserves_ssec_checksum() {
|
|
let checksum = bytes::Bytes::from_static(b"ssec-checksum");
|
|
let oi = ObjectInfo {
|
|
bucket: "test-bucket".to_string(),
|
|
name: "key".to_string(),
|
|
user_defined: Arc::new(HashMap::from([(
|
|
rustfs_utils::http::SSEC_ALGORITHM_HEADER.to_string(),
|
|
"AES256".to_string(),
|
|
)])),
|
|
checksum: Some(checksum.clone()),
|
|
..Default::default()
|
|
};
|
|
let rcfg = ReplicationConfig::new(None, None);
|
|
|
|
let roi = get_heal_replicate_object_info(&oi, &rcfg)
|
|
.await
|
|
.expect("non-delete heal classification should succeed");
|
|
|
|
assert!(roi.ssec);
|
|
assert_eq!(roi.checksum, Some(checksum));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_heal_replicate_object_info_maps_version_purge_status_for_role() {
|
|
let role = "arn:rustfs:replication::target:bucket";
|
|
let oi = ObjectInfo {
|
|
bucket: "test-bucket".to_string(),
|
|
name: "key".to_string(),
|
|
delete_marker: false,
|
|
version_purge_status: VersionPurgeStatusType::Pending,
|
|
version_id: Some(Uuid::nil()),
|
|
mod_time: Some(OffsetDateTime::now_utc()),
|
|
replication_decision: format!("{role}=true;false;{role};"),
|
|
..Default::default()
|
|
};
|
|
let rcfg = ReplicationConfig::new(
|
|
Some(ReplicationConfiguration {
|
|
role: role.to_string(),
|
|
rules: vec![],
|
|
}),
|
|
None,
|
|
);
|
|
let roi = get_heal_replicate_object_info(&oi, &rcfg)
|
|
.await
|
|
.expect("stored purge admission should classify without a live versioning lookup");
|
|
|
|
assert_eq!(roi.replication_status_internal, None);
|
|
assert_eq!(roi.version_purge_status_internal.as_deref(), Some(format!("{role}=PENDING;").as_str()));
|
|
assert_eq!(roi.target_purge_statuses.get(role), Some(&VersionPurgeStatusType::Pending));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heal_pending_purge_reads_one_versioning_generation() {
|
|
let bucket = format!("heal-versioning-snapshot-{}", Uuid::new_v4());
|
|
let object = "archive/object";
|
|
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
|
ReplicationVersioningStore::install_prefix_state_test_config(
|
|
&bucket,
|
|
VersioningConfiguration {
|
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
|
excluded_prefixes: Some(vec![ExcludedPrefix {
|
|
prefix: Some("archive/".to_string()),
|
|
}]),
|
|
..Default::default()
|
|
},
|
|
);
|
|
let rcfg = ReplicationConfig::new(
|
|
Some(ReplicationConfiguration {
|
|
role: String::new(),
|
|
rules: vec![ReplicationRule {
|
|
delete_marker_replication: None,
|
|
delete_replication: Some(DeleteReplication {
|
|
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
|
}),
|
|
destination: Destination {
|
|
bucket: arn.to_string(),
|
|
..Default::default()
|
|
},
|
|
existing_object_replication: None,
|
|
filter: None,
|
|
id: Some("delete".to_string()),
|
|
prefix: Some(String::new()),
|
|
priority: Some(1),
|
|
source_selection_criteria: None,
|
|
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
|
}],
|
|
}),
|
|
Some(BucketTargets {
|
|
targets: vec![BucketTarget {
|
|
arn: arn.to_string(),
|
|
..Default::default()
|
|
}],
|
|
}),
|
|
);
|
|
let oi = ObjectInfo {
|
|
bucket,
|
|
name: object.to_string(),
|
|
version_id: Some(Uuid::nil()),
|
|
version_purge_status: VersionPurgeStatusType::Pending,
|
|
..Default::default()
|
|
};
|
|
|
|
let roi = get_heal_replicate_object_info(&oi, &rcfg)
|
|
.await
|
|
.expect("pending null purge classification should succeed");
|
|
|
|
assert!(roi.dsc.targets_map.get(arn).is_some_and(|target| target.replicate));
|
|
assert!(
|
|
roi.existing_obj_resync
|
|
.targets
|
|
.get(arn)
|
|
.is_some_and(|target| target.replicate)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heal_pending_purge_preserves_the_persisted_admission_decision() {
|
|
let admitted_arn = "arn:rustfs:replication:us-east-1:target:admitted";
|
|
let current_role = "arn:rustfs:replication:us-east-1:target:current";
|
|
let rcfg = ReplicationConfig::new(
|
|
Some(ReplicationConfiguration {
|
|
role: current_role.to_string(),
|
|
rules: vec![ReplicationRule {
|
|
delete_marker_replication: None,
|
|
delete_replication: Some(DeleteReplication {
|
|
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED),
|
|
}),
|
|
destination: Destination {
|
|
bucket: current_role.to_string(),
|
|
..Default::default()
|
|
},
|
|
existing_object_replication: None,
|
|
filter: None,
|
|
id: Some("delete".to_string()),
|
|
prefix: Some(String::new()),
|
|
priority: Some(1),
|
|
source_selection_criteria: None,
|
|
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
|
}],
|
|
}),
|
|
Some(BucketTargets {
|
|
targets: vec![
|
|
BucketTarget {
|
|
arn: admitted_arn.to_string(),
|
|
..Default::default()
|
|
},
|
|
BucketTarget {
|
|
arn: current_role.to_string(),
|
|
..Default::default()
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
let oi = ObjectInfo {
|
|
bucket: "heal-persisted-delete-decision".to_string(),
|
|
name: "object".to_string(),
|
|
version_id: Some(Uuid::new_v4()),
|
|
version_purge_status: VersionPurgeStatusType::Pending,
|
|
version_purge_status_internal: Some(format!("{admitted_arn}=PENDING;")),
|
|
replication_decision: format!("{admitted_arn}=true;false;{admitted_arn};"),
|
|
..Default::default()
|
|
};
|
|
|
|
let roi = get_heal_replicate_object_info(&oi, &rcfg)
|
|
.await
|
|
.expect("persisted delete admission should survive live rule disablement");
|
|
|
|
assert_eq!(
|
|
roi.version_purge_status_internal.as_deref(),
|
|
Some(format!("{admitted_arn}=PENDING;").as_str())
|
|
);
|
|
assert!(roi.dsc.targets_map.get(admitted_arn).is_some_and(|target| target.replicate));
|
|
assert!(!roi.dsc.targets_map.contains_key(current_role));
|
|
assert!(
|
|
roi.existing_obj_resync
|
|
.targets
|
|
.get(admitted_arn)
|
|
.is_some_and(|target| target.replicate)
|
|
);
|
|
assert!(!roi.existing_obj_resync.targets.contains_key(current_role));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heal_rejects_semantically_invalid_replication_config() {
|
|
let rcfg = ReplicationConfig::new(
|
|
Some(ReplicationConfiguration {
|
|
role: String::new(),
|
|
rules: vec![ReplicationRule {
|
|
delete_marker_replication: None,
|
|
delete_replication: None,
|
|
destination: Destination {
|
|
bucket: "arn:rustfs:replication:us-east-1:target:bucket".to_string(),
|
|
..Default::default()
|
|
},
|
|
existing_object_replication: None,
|
|
filter: None,
|
|
id: Some("invalid".to_string()),
|
|
prefix: Some(String::new()),
|
|
priority: Some(1),
|
|
source_selection_criteria: None,
|
|
status: ReplicationRuleStatus::from_static("Enabld"),
|
|
}],
|
|
}),
|
|
Some(BucketTargets::default()),
|
|
);
|
|
let err = rcfg
|
|
.validate()
|
|
.expect_err("invalid string-backed statuses must fail before heal classification loop");
|
|
|
|
assert!(err.to_string().contains("Rule.Status"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cancel_marks_only_matching_bucket_target_token() {
|
|
let resyncer = ReplicationResyncer::new().await;
|
|
let opts_a = ResyncOpts {
|
|
bucket: "bucket-a".to_string(),
|
|
arn: "arn:replication::a".to_string(),
|
|
resync_id: "rid-a".to_string(),
|
|
resync_before: None,
|
|
};
|
|
let opts_b = ResyncOpts {
|
|
bucket: "bucket-b".to_string(),
|
|
arn: "arn:replication::b".to_string(),
|
|
resync_id: "rid-b".to_string(),
|
|
resync_before: None,
|
|
};
|
|
let token_a = CancellationToken::new();
|
|
let token_b = CancellationToken::new();
|
|
resyncer.register_cancel_token(&opts_a, token_a.clone()).await;
|
|
resyncer.register_cancel_token(&opts_b, token_b.clone()).await;
|
|
|
|
resyncer.cancel(&opts_a).await;
|
|
|
|
assert!(token_a.is_cancelled());
|
|
assert!(!token_b.is_cancelled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_finish_resync_workers_closes_result_collector() {
|
|
let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel::<ReplicateObjectInfo>(1);
|
|
let (results_tx, mut results_rx) = tokio::sync::mpsc::channel::<TargetReplicationResyncStatus>(1);
|
|
let worker = tokio::spawn(async move { while worker_rx.recv().await.is_some() {} });
|
|
let collector = tokio::spawn(async move { while results_rx.recv().await.is_some() {} });
|
|
|
|
let failed = tokio::time::timeout(
|
|
TokioDuration::from_secs(1),
|
|
finish_resync_workers(vec![worker_tx], results_tx, vec![worker, collector], false),
|
|
)
|
|
.await
|
|
.expect("resync worker cleanup should not hang after closing senders");
|
|
|
|
assert!(!failed);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_finish_resync_workers_reports_join_failure() {
|
|
let (results_tx, _results_rx) = tokio::sync::mpsc::channel::<TargetReplicationResyncStatus>(1);
|
|
let failed_worker = tokio::spawn(async {
|
|
panic!("intentional resync worker failure");
|
|
});
|
|
|
|
let failed = finish_resync_workers(Vec::new(), results_tx, vec![failed_worker], false).await;
|
|
|
|
assert!(failed);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_target_has_resync_failures_reads_accumulated_stats() {
|
|
let resyncer = ReplicationResyncer::new().await;
|
|
let opts = ResyncOpts {
|
|
bucket: "bucket".to_string(),
|
|
arn: "arn:replication::dest".to_string(),
|
|
resync_id: "run-new".to_string(),
|
|
resync_before: None,
|
|
};
|
|
let status = TargetReplicationResyncStatus {
|
|
failed_count: 1,
|
|
..Default::default()
|
|
};
|
|
|
|
resyncer.inc_stats(&status, opts.clone()).await;
|
|
|
|
assert!(resyncer.target_has_resync_failures(&opts).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_inc_stats_retains_first_sanitized_error_across_success() {
|
|
let resyncer = ReplicationResyncer::new().await;
|
|
let opts = ResyncOpts {
|
|
bucket: "bucket".to_string(),
|
|
arn: "arn:replication::dest".to_string(),
|
|
resync_id: "run-new".to_string(),
|
|
resync_before: None,
|
|
};
|
|
let failed = TargetReplicationResyncStatus {
|
|
failed_count: 1,
|
|
object: "failed-object".to_string(),
|
|
error: Some("Authorization: Bearer status-secret".to_string()),
|
|
..Default::default()
|
|
};
|
|
let later_failure = TargetReplicationResyncStatus {
|
|
failed_count: 1,
|
|
object: "later-failed-object".to_string(),
|
|
error: Some("AccessDenied".to_string()),
|
|
..Default::default()
|
|
};
|
|
let succeeded = TargetReplicationResyncStatus {
|
|
replicated_count: 1,
|
|
object: "successful-object".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
resyncer.inc_stats(&failed, opts.clone()).await;
|
|
resyncer.inc_stats(&later_failure, opts.clone()).await;
|
|
resyncer.inc_stats(&succeeded, opts.clone()).await;
|
|
|
|
let status_map = resyncer.status_map.read().await;
|
|
let target = &status_map["bucket"].targets_map["arn:replication::dest"];
|
|
assert_eq!(target.failed_count, 2);
|
|
assert_eq!(target.replicated_count, 1);
|
|
assert_eq!(target.object, "successful-object");
|
|
assert_eq!(target.error.as_deref(), Some("[redacted sensitive resync error detail]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resync_target_error_detail_uses_safe_service_code_and_fallback() {
|
|
let metadata = aws_smithy_types::error::ErrorMetadata::builder()
|
|
.code("AccessDenied")
|
|
.message("Authorization: Bearer status-secret")
|
|
.build();
|
|
let service_error = SdkError::service_error(HeadObjectError::generic(metadata), ());
|
|
let timeout_error =
|
|
SdkError::<HeadObjectError, ()>::timeout_error(std::io::Error::new(std::io::ErrorKind::TimedOut, "status-secret"));
|
|
|
|
assert_eq!(resync_target_error_detail(&service_error).as_deref(), Some("AccessDenied"));
|
|
assert_eq!(resync_target_error_detail(&timeout_error).as_deref(), Some("target request timed out"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resync_state_accepts_update_only_for_matching_run() {
|
|
let current = TargetReplicationResyncStatus {
|
|
resync_id: "run-new".to_string(),
|
|
..Default::default()
|
|
};
|
|
let matching = ResyncOpts {
|
|
bucket: "bucket".to_string(),
|
|
arn: "arn:replication::dest".to_string(),
|
|
resync_id: "run-new".to_string(),
|
|
resync_before: None,
|
|
};
|
|
let stale = ResyncOpts {
|
|
bucket: "bucket".to_string(),
|
|
arn: "arn:replication::dest".to_string(),
|
|
resync_id: "run-old".to_string(),
|
|
resync_before: None,
|
|
};
|
|
|
|
assert!(resync_state_accepts_update(&TargetReplicationResyncStatus::default(), &matching));
|
|
assert!(resync_state_accepts_update(¤t, &matching));
|
|
assert!(!resync_state_accepts_update(¤t, &stale));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resync_status_duration_only_tracks_terminal_status() {
|
|
let start = match OffsetDateTime::from_unix_timestamp(1_700_000_000) {
|
|
Ok(start) => start,
|
|
Err(err) => panic!("valid test timestamp: {err}"),
|
|
};
|
|
let end = start + time::Duration::seconds(2);
|
|
|
|
assert_eq!(
|
|
resync_status_duration(ResyncStatusType::ResyncCompleted, Some(start), end),
|
|
Some(std::time::Duration::from_millis(2000))
|
|
);
|
|
assert_eq!(resync_status_duration(ResyncStatusType::ResyncStarted, Some(start), end), None);
|
|
assert_eq!(resync_status_duration(ResyncStatusType::ResyncFailed, None, end), None);
|
|
}
|
|
|
|
#[test]
|
|
fn target_delete_version_id_preserves_explicit_null_purges() {
|
|
let version_id = Uuid::new_v4();
|
|
|
|
assert_eq!(target_delete_version_id(version_id, true), Some(version_id.to_string()));
|
|
assert_eq!(target_delete_version_id(Uuid::nil(), true).as_deref(), Some(NULL_VERSION_ID));
|
|
assert_eq!(target_delete_version_id(Uuid::nil(), false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_marker_purge_prefers_the_recorded_target_version() {
|
|
let source = Uuid::new_v4();
|
|
let arn = "arn:rustfs:replication::target:bucket";
|
|
|
|
// No recorded mapping: fall back to deriving from the source uuid.
|
|
assert_eq!(delete_marker_purge_version_id(None, arn, source), Some(Some(source.to_string())));
|
|
|
|
// Recorded mapping wins — a generic S3 target assigns its own id, so the
|
|
// derived one would purge the wrong version or nothing at all.
|
|
let mut state = ReplicationState::default();
|
|
state
|
|
.target_delete_marker_version_ids
|
|
.insert(arn.to_string(), "target-assigned-id".to_string());
|
|
assert_eq!(
|
|
delete_marker_purge_version_id(Some(&state), arn, source),
|
|
Some(Some("target-assigned-id".to_string()))
|
|
);
|
|
|
|
// A mapping recorded for a different ARN must not be reused.
|
|
assert_eq!(
|
|
delete_marker_purge_version_id(Some(&state), "arn:rustfs:replication::other:bucket", source),
|
|
Some(Some(source.to_string()))
|
|
);
|
|
|
|
// Inconsistent persisted metadata: refuse to purge rather than guess.
|
|
let mut corrupt = state.clone();
|
|
corrupt.target_delete_marker_version_ids_corrupt = true;
|
|
assert_eq!(delete_marker_purge_version_id(Some(&corrupt), arn, source), None);
|
|
}
|
|
}
|