fix(replication): compute the PUT replication decision exactly once (#4934)

The PutObject usecase computed `must_replicate_object` twice with the same inputs: once before commit to persist the pending replication metadata, and again after commit to drive `schedule_object_replication`. Besides repeating the versioning/config/target traversal on the hot path, the two computations read the replication configuration independently, so a config hot update landing between them could split the two phases into a pending-without-schedule or schedule-without-pending divergence.

Reuse the single immutable `ReplicateDecision` computed before commit for both the pending metadata and the post-commit schedule. The two former computations were already equivalent for a stable config (`must_replicate` reads only `opts.replication_request` from the options, which the pending-suffix insertion does not touch), so this preserves replication semantics while removing the redundant traversal and closing the config-race window. The decision carries only stable target/rule/status identifiers (arn, replicate, synchronous, id) and no secrets.

Add a white-box regression test that drives a real PutObject through the usecase and asserts, via a test-only invocation counter on `must_replicate_object`, that a single PUT computes the decision exactly once; reverting to the pre-commit + post-commit recompute makes the counter observe 2 and fails the test.

Refs: https://github.com/rustfs/backlog/issues/1320
This commit is contained in:
Zhengchao An
2026-07-17 08:30:03 +08:00
committed by GitHub
parent 4d22ed4465
commit a2a336aec3
3 changed files with 74 additions and 1 deletions
+12 -1
View File
@@ -4054,6 +4054,11 @@ impl DefaultObjectUsecase {
.map(|ctx| ctx.request_id.clone())
.unwrap_or_else(|| request_context::RequestContext::fallback().request_id);
// Compute the replication decision exactly once per PUT. The same
// immutable `dsc` drives both the pending metadata written below and the
// post-commit schedule (see the reuse site further down), so a
// replication-config hot update can no longer split the two phases
// (https://github.com/rustfs/backlog/issues/1320).
let dsc =
must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone())
.await;
@@ -4193,9 +4198,15 @@ impl DefaultObjectUsecase {
let e_tag = obj_info.etag.clone().map(|etag| to_s3s_etag(&etag));
let dsc = must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts).await;
let expiration = resolve_put_object_expiration(&bucket, &obj_info).await;
// Reuse the single replication decision computed before commit (see `dsc`
// above) so the pending metadata persisted with the object and the
// post-commit schedule always derive from the same immutable decision.
// Recomputing here would repeat the versioning/config/target traversal and,
// worse, allow a replication-config hot update between the two phases to
// produce a pending-without-schedule or schedule-without-pending divergence
// (https://github.com/rustfs/backlog/issues/1320).
if dsc.replicate_any() {
schedule_object_replication(obj_info.clone(), store, dsc).await;
}