mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-02 10:18:10 +00:00
fix(replication): surface object-lock denied purges and back off heal retries (#6900)
This commit is contained in:
@@ -20,8 +20,8 @@ pub use rustfs_replication::{
|
|||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
|
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
|
||||||
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
|
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
|
||||||
delete_replication_object_opts, heal_uses_delete_replication_path, is_retryable_delete_replication_head_error,
|
delete_replication_object_opts, heal_uses_delete_replication_path, is_object_lock_denied_delete,
|
||||||
is_version_delete_replication, replicate_delete_outcome, replication_etags_match, replication_multipart_complete_actual_size,
|
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
|
||||||
replication_multipart_part_plan, resync_existing_delete_replication_info, resync_target_for_object,
|
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_existing_delete_replication_info,
|
||||||
should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
|
resync_target_for_object, should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3177,6 +3177,19 @@ pub(crate) async fn queue_replication_heal_internal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ReplicationHealQueueAction::QueueDelete(dv) => {
|
ReplicationHealQueueAction::QueueDelete(dv) => {
|
||||||
|
// A purge the peer denied under object lock cannot succeed until
|
||||||
|
// the lock lapses (#6850); requeuing it every heal cycle only
|
||||||
|
// burns bandwidth and failure counters. The backoff expires on
|
||||||
|
// its own, so the purge is probed again — and converges — once
|
||||||
|
// the retention window has a chance of being over.
|
||||||
|
if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object)
|
||||||
|
&& super::replication_resyncer::object_lock_denied_purge_backoff_active(&dv)
|
||||||
|
{
|
||||||
|
return ReplicationHealQueueResult {
|
||||||
|
object_info: roi,
|
||||||
|
admission: ReplicationQueueAdmission::Skipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
let admission = if let Some(pool) = runtime_sources::replication_pool() {
|
let admission = if let Some(pool) = runtime_sources::replication_pool() {
|
||||||
pool.queue_replica_delete_task(dv).await
|
pool.queue_replica_delete_task(dv).await
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ use super::replication_msgp_boundary::ReplicationMsgpCodec;
|
|||||||
use super::replication_object_config::{ReplicationConfig, get_replication_config, must_replicate};
|
use super::replication_object_config::{ReplicationConfig, get_replication_config, must_replicate};
|
||||||
use super::replication_object_decision_boundary::{
|
use super::replication_object_decision_boundary::{
|
||||||
MustReplicateOptions, ReplicationMultipartPartInput, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
MustReplicateOptions, ReplicationMultipartPartInput, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
||||||
delete_replication_creates_marker, heal_uses_delete_replication_path, is_retryable_delete_replication_head_error,
|
delete_replication_creates_marker, heal_uses_delete_replication_path, is_object_lock_denied_delete,
|
||||||
is_version_delete_replication, replicate_delete_outcome, replication_etags_match, replication_multipart_complete_actual_size,
|
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
|
||||||
replication_multipart_part_plan, resync_existing_delete_replication_info, should_retry_delete_marker_purge,
|
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_existing_delete_replication_info,
|
||||||
single_part_replica_etag_mismatch, target_delete_version_id,
|
should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
|
||||||
};
|
};
|
||||||
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
|
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
|
||||||
use super::replication_resync_boundary::ResyncStatusType;
|
use super::replication_resync_boundary::ResyncStatusType;
|
||||||
@@ -118,6 +118,7 @@ const EVENT_DELETE_MARKER_PURGE_FAILED: &str = "replication_delete_marker_purge_
|
|||||||
const EVENT_DELETE_MARKER_PURGE_MRF: &str = "replication_delete_marker_purge_mrf";
|
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 METRIC_DELETE_MARKER_PURGE_TOTAL: &str = "rustfs_replication_delete_marker_purge_total";
|
||||||
const EVENT_REPLICATION_VERSION_IDENTITY_DRIFT: &str = "replication_version_identity_drift";
|
const EVENT_REPLICATION_VERSION_IDENTITY_DRIFT: &str = "replication_version_identity_drift";
|
||||||
|
const EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED: &str = "replication_purge_object_lock_denied";
|
||||||
|
|
||||||
#[allow(
|
#[allow(
|
||||||
dead_code,
|
dead_code,
|
||||||
@@ -195,6 +196,76 @@ const METRIC_VERSION_IDENTITY_DRIFT_TOTAL: &str = "rustfs_replication_version_id
|
|||||||
/// after a restart is acceptable.
|
/// after a restart is acceptable.
|
||||||
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
|
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
|
||||||
|
|
||||||
|
/// Version purges the peer denied under object lock (#6850). Replication
|
||||||
|
/// carries no governance bypass, so such a purge cannot succeed until the
|
||||||
|
/// lock on the replica lapses — retrying every heal cycle only burns
|
||||||
|
/// bandwidth and failure counters. Entries suppress heal requeues for the
|
||||||
|
/// backoff window; after it expires one probe runs again, so the purge still
|
||||||
|
/// converges on its own once retention ends. In-process only: a restart
|
||||||
|
/// costs at most one extra probe per entry.
|
||||||
|
const OBJECT_LOCK_DENIED_PURGE_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||||
|
const OBJECT_LOCK_DENIED_PURGE_CACHE_MAX: usize = 4096;
|
||||||
|
type ObjectLockDeniedPurgeKey = (String, String, String);
|
||||||
|
|
||||||
|
struct ObjectLockDeniedPurge {
|
||||||
|
denied_at: std::time::Instant,
|
||||||
|
denied_arns: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static OBJECT_LOCK_DENIED_PURGES: LazyLock<StdMutex<HashMap<ObjectLockDeniedPurgeKey, ObjectLockDeniedPurge>>> =
|
||||||
|
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
fn object_lock_denied_purge_key(dobj: &DeletedObjectReplicationInfo) -> ObjectLockDeniedPurgeKey {
|
||||||
|
let version_id = dobj
|
||||||
|
.delete_object
|
||||||
|
.delete_marker_version_id
|
||||||
|
.or(dobj.delete_object.version_id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
(dobj.bucket.clone(), dobj.delete_object.object_name.clone(), version_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_object_lock_denied_purge(dobj: &DeletedObjectReplicationInfo, arn: &str) {
|
||||||
|
let mut denied = OBJECT_LOCK_DENIED_PURGES
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
if denied.len() >= OBJECT_LOCK_DENIED_PURGE_CACHE_MAX {
|
||||||
|
denied.retain(|_, entry| entry.denied_at.elapsed() < OBJECT_LOCK_DENIED_PURGE_BACKOFF);
|
||||||
|
}
|
||||||
|
let key = object_lock_denied_purge_key(dobj);
|
||||||
|
if denied.len() < OBJECT_LOCK_DENIED_PURGE_CACHE_MAX || denied.contains_key(&key) {
|
||||||
|
let entry = denied.entry(key).or_insert_with(|| ObjectLockDeniedPurge {
|
||||||
|
denied_at: std::time::Instant::now(),
|
||||||
|
denied_arns: HashSet::new(),
|
||||||
|
});
|
||||||
|
entry.denied_at = std::time::Instant::now();
|
||||||
|
entry.denied_arns.insert(arn.to_string());
|
||||||
|
}
|
||||||
|
// Still full after dropping expired entries: skip recording — the purge
|
||||||
|
// then simply keeps retrying, which is the pre-#6850 behavior.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a heal requeue of this delete can only reach targets that denied
|
||||||
|
/// it under object lock within the backoff window. A target the entry does
|
||||||
|
/// not cover (another peer, or one whose denial expired) keeps the requeue
|
||||||
|
/// flowing — suppressing it would delay a purge that could succeed there.
|
||||||
|
pub(crate) fn object_lock_denied_purge_backoff_active(dobj: &DeletedObjectReplicationInfo) -> bool {
|
||||||
|
let key = object_lock_denied_purge_key(dobj);
|
||||||
|
let mut denied = OBJECT_LOCK_DENIED_PURGES
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
match denied.get(&key) {
|
||||||
|
Some(entry) if entry.denied_at.elapsed() < OBJECT_LOCK_DENIED_PURGE_BACKOFF => {
|
||||||
|
let admitted = dobj.admitted_target_arns();
|
||||||
|
!admitted.is_empty() && admitted.iter().all(|arn| entry.denied_arns.contains(arn))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
denied.remove(&key);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const REPLICA_ETAG_VERIFY_ENV: &str = "RUSTFS_REPLICATION_REPLICA_ETAG_VERIFY";
|
const REPLICA_ETAG_VERIFY_ENV: &str = "RUSTFS_REPLICATION_REPLICA_ETAG_VERIFY";
|
||||||
|
|
||||||
/// Escape hatch for a target whose 32-hex ETags are legitimately not the
|
/// Escape hatch for a target whose 32-hex ETags are legitimately not the
|
||||||
@@ -2756,6 +2827,28 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
let object_lock_denied = is_version_purge && is_object_lock_denied_delete(e.code.as_deref(), e.message.as_deref());
|
||||||
|
if object_lock_denied {
|
||||||
|
// Terminal for as long as the lock holds: the peer retains
|
||||||
|
// this version and replication carries no governance bypass
|
||||||
|
// (#6850), so the sites stay diverged until the retention or
|
||||||
|
// legal hold on the replica lapses. Surface it loudly instead
|
||||||
|
// of letting a silent failed counter and a hot heal-retry
|
||||||
|
// loop stand in for the divergence.
|
||||||
|
record_object_lock_denied_purge(dobj, &tgt_client.arn);
|
||||||
|
error!(
|
||||||
|
event = EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||||
|
bucket = tgt_client.bucket,
|
||||||
|
object = dobj.delete_object.object_name,
|
||||||
|
version_id = ?version_id,
|
||||||
|
arn = %tgt_client.arn,
|
||||||
|
error = %e,
|
||||||
|
operation = "replicate_delete_to_target",
|
||||||
|
"Replicated version purge denied by object lock on the target; the sites stay diverged until the lock lapses"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -2769,6 +2862,7 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
|||||||
operation = "replicate_delete_to_target",
|
operation = "replicate_delete_to_target",
|
||||||
"Replication target operation failed"
|
"Replication target operation failed"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
rinfo.error = Some(e.to_string());
|
rinfo.error = Some(e.to_string());
|
||||||
if !is_version_purge {
|
if !is_version_purge {
|
||||||
rinfo.replication_status = ReplicationStatusType::Failed;
|
rinfo.replication_status = ReplicationStatusType::Failed;
|
||||||
@@ -5511,6 +5605,35 @@ mod tests {
|
|||||||
assert!(!resync_state_accepts_update(¤t, &stale));
|
assert!(!resync_state_accepts_update(¤t, &stale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_lock_denied_purge_backoff_tracks_version_and_target() {
|
||||||
|
let denied = DeletedObjectReplicationInfo {
|
||||||
|
bucket: "worm-backoff-test-bucket".to_string(),
|
||||||
|
target_arn: "arn:rustfs:replication::worm-test:t1".to_string(),
|
||||||
|
delete_object: ReplicationDeletedObject {
|
||||||
|
object_name: "locked-object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!object_lock_denied_purge_backoff_active(&denied));
|
||||||
|
|
||||||
|
record_object_lock_denied_purge(&denied, "arn:rustfs:replication::worm-test:t1");
|
||||||
|
assert!(object_lock_denied_purge_backoff_active(&denied));
|
||||||
|
|
||||||
|
// A requeue that can also reach a target this denial does not cover
|
||||||
|
// must keep flowing: the purge may succeed there.
|
||||||
|
let mut other_target = denied.clone();
|
||||||
|
other_target.target_arn = "arn:rustfs:replication::worm-test:t2".to_string();
|
||||||
|
assert!(!object_lock_denied_purge_backoff_active(&other_target));
|
||||||
|
|
||||||
|
// A different version of the same object must not be suppressed.
|
||||||
|
let mut other_version = denied;
|
||||||
|
other_version.delete_object.version_id = Some(uuid::Uuid::new_v4());
|
||||||
|
assert!(!object_lock_denied_purge_backoff_active(&other_version));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn abort_multipart_on_failure_skips_abort_when_transfer_succeeded() {
|
async fn abort_multipart_on_failure_skips_abort_when_transfer_succeeded() {
|
||||||
let aborted = Arc::new(AtomicBool::new(false));
|
let aborted = Arc::new(AtomicBool::new(false));
|
||||||
|
|||||||
@@ -126,6 +126,27 @@ pub fn should_retry_delete_marker_purge(dobj: &DeletedObject) -> bool {
|
|||||||
dobj.delete_marker_version_id.is_some()
|
dobj.delete_marker_version_id.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the target denied a replicated delete because object-lock
|
||||||
|
/// retention or a legal hold protects that version on the replica (its
|
||||||
|
/// deletion gate answers `AccessDenied` with the lock reason, and a
|
||||||
|
/// replication request carries no governance bypass, rustfs#6850). Retrying
|
||||||
|
/// cannot succeed until the lock itself lapses, so callers treat this as a
|
||||||
|
/// policy denial rather than a transient fault.
|
||||||
|
///
|
||||||
|
/// The reason text is the RustFS deletion-gate wording; a MinIO/AWS peer
|
||||||
|
/// phrases its WORM denial differently and simply stays unclassified — the
|
||||||
|
/// caller then falls back to plain retry behavior, never a wrong state.
|
||||||
|
pub fn is_object_lock_denied_delete(code: Option<&str>, message: Option<&str>) -> bool {
|
||||||
|
if !matches!(code, Some("AccessDenied")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(message) = message else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let message = message.to_ascii_lowercase();
|
||||||
|
message.contains("retention") || message.contains("legal hold")
|
||||||
|
}
|
||||||
|
|
||||||
fn admitted_target_arns_from_replication_state(state: &ReplicationState) -> Vec<String> {
|
fn admitted_target_arns_from_replication_state(state: &ReplicationState) -> Vec<String> {
|
||||||
let mut target_arns = state.targets.keys().cloned().collect::<Vec<_>>();
|
let mut target_arns = state.targets.keys().cloned().collect::<Vec<_>>();
|
||||||
target_arns.extend(state.purge_targets.keys().cloned());
|
target_arns.extend(state.purge_targets.keys().cloned());
|
||||||
@@ -237,9 +258,9 @@ mod tests {
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
DeletedObjectReplicationInfo, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
DeletedObjectReplicationInfo, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
||||||
delete_replication_creates_marker, is_retryable_delete_replication_head_error, is_version_delete_replication,
|
delete_replication_creates_marker, is_object_lock_denied_delete, is_retryable_delete_replication_head_error,
|
||||||
replicate_delete_outcome, resync_existing_delete_replication_info, should_retry_delete_marker_purge,
|
is_version_delete_replication, replicate_delete_outcome, resync_existing_delete_replication_info,
|
||||||
target_delete_version_id,
|
should_retry_delete_marker_purge, target_delete_version_id,
|
||||||
};
|
};
|
||||||
use crate::storage_api::DeletedObject;
|
use crate::storage_api::DeletedObject;
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -615,4 +636,24 @@ mod tests {
|
|||||||
corrupt.target_delete_marker_version_ids_corrupt = true;
|
corrupt.target_delete_marker_version_ids_corrupt = true;
|
||||||
assert_eq!(delete_marker_purge_version_id(Some(&corrupt), arn, source), None);
|
assert_eq!(delete_marker_purge_version_id(Some(&corrupt), arn, source), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_lock_denied_delete_is_recognized_by_code_and_reason() {
|
||||||
|
// The peer's deletion gate answers AccessDenied with the lock reason.
|
||||||
|
assert!(is_object_lock_denied_delete(
|
||||||
|
Some("AccessDenied"),
|
||||||
|
Some("Object is under GOVERNANCE retention and cannot be deleted until 2026-09-01T00:00:00Z")
|
||||||
|
));
|
||||||
|
assert!(is_object_lock_denied_delete(
|
||||||
|
Some("AccessDenied"),
|
||||||
|
Some("Object has a legal hold and cannot be deleted. Remove the legal hold first.")
|
||||||
|
));
|
||||||
|
|
||||||
|
// A plain policy denial (misconfigured replicator) is not a lock denial.
|
||||||
|
assert!(!is_object_lock_denied_delete(Some("AccessDenied"), Some("Access Denied.")));
|
||||||
|
assert!(!is_object_lock_denied_delete(Some("AccessDenied"), None));
|
||||||
|
// Other errors mentioning retention must not match.
|
||||||
|
assert!(!is_object_lock_denied_delete(Some("InternalError"), Some("retention lookup failed")));
|
||||||
|
assert!(!is_object_lock_denied_delete(None, Some("legal hold")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ pub use config::{
|
|||||||
};
|
};
|
||||||
pub use delete::{
|
pub use delete::{
|
||||||
DeletedObjectReplicationInfo, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
DeletedObjectReplicationInfo, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
||||||
delete_replication_creates_marker, is_retryable_delete_replication_head_error, is_version_delete_replication,
|
delete_replication_creates_marker, is_object_lock_denied_delete, is_retryable_delete_replication_head_error,
|
||||||
replicate_delete_outcome, resync_existing_delete_replication_info, should_retry_delete_marker_purge,
|
is_version_delete_replication, replicate_delete_outcome, resync_existing_delete_replication_info,
|
||||||
target_delete_version_id,
|
should_retry_delete_marker_purge, target_delete_version_id,
|
||||||
};
|
};
|
||||||
pub use filemeta::{
|
pub use filemeta::{
|
||||||
NULL_VERSION_ID, REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, REPLICATE_HEAL, REPLICATE_HEAL_DELETE, REPLICATE_INCOMING,
|
NULL_VERSION_ID, REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, REPLICATE_HEAL, REPLICATE_HEAL_DELETE, REPLICATE_INCOMING,
|
||||||
|
|||||||
Reference in New Issue
Block a user