test(replication): pin the scanner existing-object compensation matrix (#5877)

P1-20 (rustfs/backlog#1675 B2, test-only). No prior test wrote objects
BEFORE the replication rule arrived, leaving the scanner's existing-object
resync pass — the only channel for such objects — without end-to-end
coverage, and the enqueue truth table partially unpinned at unit level.

e2e (both negative cells are contracts, asserted over multiple fast-scanner
cycles next to a replicated control key that proves the scanner and the
live path are running):
- test_scanner_compensates_existing_objects_across_write_paths: plain PUT,
  CopyObject and Snowball auto-extract products written pre-rule all
  converge via scanner compensation; a null-version object (PUT before the
  bucket became versioned) is pinned as never compensated (the scanner heal
  gate skips nil-version objects).
- test_scanner_never_compensates_when_existing_object_replication_disabled:
  ExistingObjectReplication=Disabled is a contract, not a delay — existing
  keys stay absent while post-rule writes replicate normally.

Unit truth-table pins (crates/replication):
- queue.rs: an empty replicate decision (Disabled existing-object, inbound
  REPLICA) skips heal queueing for every status; Completed without a resync
  decision skips.
- operation.rs: existing-object resync without a reset replicates exactly
  the never-replicated (Empty) objects.

Helper: put_bucket_replication_with_statuses parameterizes the previously
hardcoded ExistingObjectReplication status; the nextest count comments are
refreshed to the post-rebase totals.
This commit is contained in:
唐小鸭
2026-08-11 11:58:25 +08:00
committed by GitHub
parent 8a8be12f0b
commit a076ae4045
4 changed files with 322 additions and 3 deletions
+32
View File
@@ -635,6 +635,38 @@ mod tests {
assert!(!should_use_existing_delete_replication_info(false, false));
}
/// P1-20 truth-table pin (rustfs/backlog#1675): without any reset in play
/// (no per-target reset header on the object, empty reset id on the
/// target) the existing-object resync decision compensates exactly the
/// never-replicated objects — Empty replicates, any recorded status does
/// not.
#[test]
fn resync_target_without_reset_replicates_only_empty_status() {
let user_defined = HashMap::new();
let object = ReplicationResyncTargetObject {
mod_time: Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(10)),
user_defined: &user_defined,
};
for (status, expected) in [
(ReplicationStatusType::Empty, true),
(ReplicationStatusType::Completed, false),
// "COMPLETE" on disk parses to this legacy variant, so objects
// written by older versions reach the decision through it.
(ReplicationStatusType::CompletedLegacy, false),
(ReplicationStatusType::Pending, false),
(ReplicationStatusType::Failed, false),
(ReplicationStatusType::Replica, false),
] {
let label = format!("{status:?}");
let decision = resync_target_for_object(&object, "arn:target", "", None, status);
assert_eq!(
decision.replicate, expected,
"existing-object resync without a reset must replicate only never-replicated objects (status {label})"
);
}
}
#[test]
fn resync_target_includes_object_at_reset_before_boundary() {
let reset_before = OffsetDateTime::UNIX_EPOCH + Duration::seconds(30);
+42
View File
@@ -360,6 +360,48 @@ mod tests {
);
}
/// P1-20 truth-table pin (rustfs/backlog#1675): when no target replicates
/// — the decision is empty because ExistingObjectReplication is Disabled
/// for a never-replicated object, or because the object is an inbound
/// REPLICA (must_replicate returns an empty decision for those) — the
/// heal pass must skip entirely, whatever the recorded status says. The
/// scanner never compensates these objects.
#[test]
fn heal_queue_action_skips_when_no_target_replicates() {
for status in [
ReplicationStatusType::Empty,
ReplicationStatusType::Failed,
ReplicationStatusType::Replica,
] {
let mut roi = ReplicateObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
replication_status: status,
dsc: ReplicateDecision::new(),
..Default::default()
};
let action = replication_heal_queue_action(&mut roi);
assert!(
matches!(action, ReplicationHealQueueAction::Skip),
"an empty replicate decision must skip heal queueing (status {:?})",
roi.replication_status
);
}
}
/// P1-20 truth-table pin: a Completed object with no resync decision has
/// nothing left to heal — the scanner must not requeue it.
#[test]
fn heal_queue_action_skips_completed_object_without_resync() {
let mut roi = replicate_object_info(ReplicationStatusType::Completed);
let action = replication_heal_queue_action(&mut roi);
assert!(matches!(action, ReplicationHealQueueAction::Skip));
}
#[test]
fn heal_queue_action_routes_failed_objects_to_heal_queue() {
let mut roi = replicate_object_info(ReplicationStatusType::Failed);