refactor(replication): move resync decision contracts (#4173)

* refactor(replication): move resync decision contracts

* fix(replication): avoid resync status clones
This commit is contained in:
Zhengchao An
2026-07-02 14:13:05 +08:00
committed by GitHub
parent 4615df006d
commit 98a0cca4a2
6 changed files with 290 additions and 296 deletions
+5 -2
View File
@@ -32,9 +32,12 @@ pub use delete::{
pub use mrf::{MrfOpKind, MrfReplicateEntry, decode_mrf_file, encode_mrf_file};
pub use object::{
ReplicationSourceObject, ReplicationTargetObject, content_matches_by_etag, replication_action_for_target,
target_is_newer_than_source_null_version,
replication_etags_match, target_is_newer_than_source_null_version,
};
pub use operation::{
MustReplicateOptions, ReplicationDeleteSource, ReplicationResyncTargetObject, delete_replication_missing_source_decision,
delete_replication_object_opts, heal_uses_delete_replication_path, is_ssec_encrypted, resync_target_for_object,
};
pub use operation::{MustReplicateOptions, is_ssec_encrypted};
pub use queue::{ReplicationHealQueueResult, ReplicationOperation, ReplicationPriority, ReplicationQueueAdmission};
pub use resync::{
BucketReplicationResyncStatus, Error, Result, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus,
+15 -12
View File
@@ -24,6 +24,7 @@ use std::collections::HashMap;
use time::OffsetDateTime;
const AMZ_META_PREFIX: &str = "X-Amz-Meta-";
const CONTENT_ENCODING_LOWER: &str = "content-encoding";
const REPLICATION_METADATA_COMPARE_KEYS: [&str; 9] = [
EXPIRES,
CACHE_CONTROL,
@@ -62,8 +63,12 @@ pub struct ReplicationTargetObject<'a> {
}
pub fn content_matches_by_etag(source: &ReplicationSourceObject<'_>, target: &ReplicationTargetObject<'_>) -> bool {
let source_etag = source.etag.map(trim_etag);
let target_etag = target.etag.map(trim_etag);
replication_etags_match(source.etag, target.etag)
}
pub fn replication_etags_match(source: Option<&str>, target: Option<&str>) -> bool {
let source_etag = source.map(trim_etag);
let target_etag = target.map(trim_etag);
source_etag.is_some() && source_etag == target_etag
}
@@ -121,7 +126,7 @@ fn content_encoding_differs(source: &ReplicationSourceObject<'_>, target: &Repli
.and_then(|metadata| {
metadata
.get(CONTENT_ENCODING)
.or_else(|| metadata.get(&CONTENT_ENCODING.to_lowercase()))
.or_else(|| metadata.get(CONTENT_ENCODING_LOWER))
})
.is_none_or(|enc| enc != content_encoding);
}
@@ -130,14 +135,11 @@ fn content_encoding_differs(source: &ReplicationSourceObject<'_>, target: &Repli
fn tag_metadata_differs(source: &ReplicationSourceObject<'_>, target: &ReplicationTargetObject<'_>) -> bool {
let source_tags = ReplicationTagFilter::decode_tags_to_map(source.user_tags);
let target_tags = ReplicationTagFilter::decode_tags_to_map(
target
.metadata
.and_then(|metadata| metadata.get(AMZ_OBJECT_TAGGING))
.cloned()
.unwrap_or_default()
.as_str(),
);
let target_tagging = target
.metadata
.and_then(|metadata| metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str))
.unwrap_or_default();
let target_tags = ReplicationTagFilter::decode_tags_to_map(target_tagging);
let source_tag_count = match i32::try_from(source_tags.len()) {
Ok(count) => count,
Err(_) => i32::MAX,
@@ -163,7 +165,7 @@ fn comparable_metadata(metadata: Option<&HashMap<String, String>>) -> HashMap<St
mod tests {
use super::{
ReplicationSourceObject, ReplicationTargetObject, content_matches_by_etag, replication_action_for_target,
target_is_newer_than_source_null_version,
replication_etags_match, target_is_newer_than_source_null_version,
};
use rustfs_filemeta::{ReplicationAction, ReplicationType};
use std::collections::HashMap;
@@ -214,6 +216,7 @@ mod tests {
};
assert!(content_matches_by_etag(&source, &target));
assert!(replication_etags_match(source.etag, target.etag));
}
#[test]
+233 -3
View File
@@ -14,11 +14,16 @@
use std::collections::HashMap;
use rustfs_storage_api::ObjectToDelete;
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, AMZ_OBJECT_TAGGING, SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER,
SUFFIX_REPLICATION_RESET_STATUS, get_header_map,
};
use time::OffsetDateTime;
use crate::{ReplicationStatusType, ReplicationType};
use crate::{
ObjectOpts, ReplicationStatusType, ReplicationType, ResyncTargetDecision, VersionPurgeStatusType, target_reset_header,
};
#[derive(Debug, Clone, Default)]
pub struct MustReplicateOptions {
@@ -71,12 +76,128 @@ pub fn is_ssec_encrypted(user_defined: &HashMap<String, String>) -> bool {
|| user_defined.contains_key(SSEC_KEY_MD5_HEADER)
}
#[derive(Debug, Clone)]
pub struct ReplicationDeleteSource<'a> {
pub user_defined: &'a HashMap<String, String>,
pub user_tags: &'a str,
pub delete_marker: bool,
pub replication_status: ReplicationStatusType,
}
pub fn delete_replication_object_opts(dobj: &ObjectToDelete, source: &ReplicationDeleteSource<'_>) -> ObjectOpts {
ObjectOpts {
name: dobj.object_name.clone(),
ssec: is_ssec_encrypted(source.user_defined),
user_tags: source.user_tags.to_string(),
delete_marker: source.delete_marker,
version_id: dobj.version_id,
op_type: ReplicationType::Delete,
replica: source.replication_status == ReplicationStatusType::Replica,
..Default::default()
}
}
pub fn heal_uses_delete_replication_path(delete_marker: bool, version_purge_status: &VersionPurgeStatusType) -> bool {
delete_marker || !version_purge_status.is_empty()
}
pub fn delete_replication_missing_source_decision(
delete_marker: bool,
target_status: ReplicationStatusType,
rule_replicates: bool,
version_purge_status: &VersionPurgeStatusType,
) -> Option<bool> {
let valid_replication_status = matches!(
target_status,
ReplicationStatusType::Pending | ReplicationStatusType::Completed | ReplicationStatusType::Failed
);
if delete_marker && (valid_replication_status || rule_replicates) {
return Some(rule_replicates);
}
if !version_purge_status.is_empty() {
return Some(matches!(
version_purge_status,
&VersionPurgeStatusType::Pending | &VersionPurgeStatusType::Failed
));
}
None
}
#[derive(Debug, Clone)]
pub struct ReplicationResyncTargetObject<'a> {
pub mod_time: Option<OffsetDateTime>,
pub user_defined: &'a HashMap<String, String>,
}
pub fn resync_target_for_object(
object: &ReplicationResyncTargetObject<'_>,
arn: &str,
reset_id: &str,
reset_before_date: Option<OffsetDateTime>,
status: ReplicationStatusType,
) -> ResyncTargetDecision {
let rs = object
.user_defined
.get(target_reset_header(arn).as_str())
.cloned()
.or_else(|| get_header_map(object.user_defined, SUFFIX_REPLICATION_RESET_STATUS));
let mut dec = ResyncTargetDecision::default();
let mod_time = object.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH);
let Some(rs) = rs else {
let reset_before_date = reset_before_date.unwrap_or(OffsetDateTime::UNIX_EPOCH);
if !reset_id.is_empty() && mod_time <= reset_before_date {
dec.replicate = true;
return dec;
}
dec.replicate = status == ReplicationStatusType::Empty;
return dec;
};
let Some(reset_before_date) = reset_before_date else {
return dec;
};
if reset_id.is_empty() {
return dec;
}
let parts: Vec<&str> = rs.splitn(2, ';').collect();
if parts.len() != 2 {
return dec;
}
let new_reset = parts[1] != reset_id;
if !new_reset && status == ReplicationStatusType::Completed {
return dec;
}
dec.replicate = new_reset && mod_time <= reset_before_date;
dec
}
#[cfg(test)]
mod tests {
use super::{MustReplicateOptions, is_ssec_encrypted};
use crate::{ReplicationStatusType, ReplicationType};
use super::{
MustReplicateOptions, ReplicationDeleteSource, ReplicationResyncTargetObject, delete_replication_missing_source_decision,
delete_replication_object_opts, heal_uses_delete_replication_path, is_ssec_encrypted, resync_target_for_object,
};
use crate::{ReplicationStatusType, ReplicationType, VersionPurgeStatusType, target_reset_header};
use rustfs_storage_api::ObjectToDelete;
use rustfs_utils::http::{AMZ_BUCKET_REPLICATION_STATUS, SSEC_ALGORITHM_HEADER};
use std::collections::HashMap;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
#[test]
fn must_replicate_options_preserves_user_tags_and_operation_type() {
@@ -102,4 +223,113 @@ mod tests {
assert!(is_ssec_encrypted(&meta));
assert!(!is_ssec_encrypted(&HashMap::new()));
}
#[test]
fn heal_delete_path_is_limited_to_delete_markers_and_version_purges() {
assert!(heal_uses_delete_replication_path(true, &VersionPurgeStatusType::Empty));
assert!(heal_uses_delete_replication_path(false, &VersionPurgeStatusType::Pending));
assert!(!heal_uses_delete_replication_path(false, &VersionPurgeStatusType::Empty));
}
#[test]
fn missing_source_delete_decision_keeps_delete_marker_and_purge_rules() {
assert_eq!(
delete_replication_missing_source_decision(
true,
ReplicationStatusType::Completed,
false,
&VersionPurgeStatusType::Empty,
),
Some(false)
);
assert_eq!(
delete_replication_missing_source_decision(
false,
ReplicationStatusType::Empty,
false,
&VersionPurgeStatusType::Pending,
),
Some(true)
);
assert_eq!(
delete_replication_missing_source_decision(
false,
ReplicationStatusType::Empty,
false,
&VersionPurgeStatusType::Empty
),
None
);
}
#[test]
fn delete_replication_object_opts_marks_replica_deletes() {
let version_id = Uuid::new_v4();
let user_defined = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
let dobj = ObjectToDelete {
object_name: "obj".to_string(),
version_id: Some(version_id),
..Default::default()
};
let source = ReplicationDeleteSource {
user_defined: &user_defined,
user_tags: "env=prod",
delete_marker: true,
replication_status: ReplicationStatusType::Replica,
};
let opts = delete_replication_object_opts(&dobj, &source);
assert!(opts.replica);
assert!(opts.ssec);
assert_eq!(opts.user_tags, "env=prod");
assert_eq!(opts.version_id, Some(version_id));
assert_eq!(opts.name, "obj");
assert_eq!(opts.op_type, ReplicationType::Delete);
}
#[test]
fn resync_target_includes_object_at_reset_before_boundary() {
let reset_before = OffsetDateTime::UNIX_EPOCH + Duration::seconds(30);
let user_defined = HashMap::new();
let object = ReplicationResyncTargetObject {
mod_time: Some(reset_before),
user_defined: &user_defined,
};
let decision =
resync_target_for_object(&object, "arn:target", "reset-1", Some(reset_before), ReplicationStatusType::Completed);
assert!(decision.replicate);
}
#[test]
fn resync_target_replicates_when_reset_id_changes() {
let reset_before = OffsetDateTime::UNIX_EPOCH + Duration::seconds(30);
let user_defined = HashMap::from([(target_reset_header("arn:target"), "1970-01-01T00:00:20Z;old-reset".to_string())]);
let object = ReplicationResyncTargetObject {
mod_time: Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(10)),
user_defined: &user_defined,
};
let decision =
resync_target_for_object(&object, "arn:target", "new-reset", Some(reset_before), ReplicationStatusType::Completed);
assert!(decision.replicate);
}
#[test]
fn resync_target_skips_completed_object_for_same_reset_id() {
let reset_before = OffsetDateTime::UNIX_EPOCH + Duration::seconds(30);
let user_defined = HashMap::from([(target_reset_header("arn:target"), "1970-01-01T00:00:20Z;same-reset".to_string())]);
let object = ReplicationResyncTargetObject {
mod_time: Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(10)),
user_defined: &user_defined,
};
let decision =
resync_target_for_object(&object, "arn:target", "same-reset", Some(reset_before), ReplicationStatusType::Completed);
assert!(!decision.replicate);
}
}