mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 02:56:18 +00:00
refactor(replication): move resync decision contracts (#4173)
* refactor(replication): move resync decision contracts * fix(replication): avoid resync status clones
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user