diff --git a/.github/workflows/rustfs-performance-test.yml b/.github/workflows/rustfs-performance-test.yml
index d698b27e0..699b583cf 100644
--- a/.github/workflows/rustfs-performance-test.yml
+++ b/.github/workflows/rustfs-performance-test.yml
@@ -82,6 +82,12 @@ jobs:
performance-test:
runs-on: pf-testing
timeout-minutes: 900
+ env:
+ RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
+ RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
+ RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
+ RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
+ RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
# Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
@@ -158,19 +164,15 @@ jobs:
- name: Run benchmark (GET/PUT/MIXED)
id: benchmark
run: |
- # Empty on automatic (workflow_run) runs -> full 30 rounds.
- # Manual dispatch can restrict method(s)/size(s).
- export WARP_METHODS="${{ inputs.test_method }}"
- export WARP_SIZES="${{ inputs.object_size }}"
./auto-testing/rustfs_performance_test.sh \
--step 5 -y \
- --warp-duration "${{ inputs.warp_duration || '5m' }}" \
- --warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
--log-file "${LOG_FILE}"
- name: Analyze results
if: ${{ steps.benchmark.conclusion == 'success' }}
run: |
+ export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
+ export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Collect RustFS version info
diff --git a/.gitignore b/.gitignore
index 60325653d..8a26b9e94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,6 +52,7 @@ docs
__pycache__/
!docs/
docs/*
+!docs/README.md
!docs/architecture/
!docs/architecture/**
!docs/operations/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6963f7084..588fb7799 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Replication
+
+- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
+- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
+- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
+- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
+- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
+- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
+- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
+
### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
diff --git a/crates/e2e_test/src/replication_extension_test.rs b/crates/e2e_test/src/replication_extension_test.rs
index 10a106975..48dfd8cf5 100644
--- a/crates/e2e_test/src/replication_extension_test.rs
+++ b/crates/e2e_test/src/replication_extension_test.rs
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
Enabled{target_b_arn}
+
+ matrix-and-tags
+ 135
+ Enabled
+ and-tags/envprodtiergold
+ Disabled
+ Enabled
+ Enabled
+ {target_b_arn}
+ matrix-disabled140
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
"matrix-prefix",
"matrix-tag",
"matrix-disabled",
+ "matrix-and-tags",
"matrix-priority-high",
"Priority>200",
"Disabled",
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
+ // S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
+ // the required tags is not admitted. Matching any single tag would push
+ // data to a destination the rule never selected (backlog#2366 P1-1), and
+ // the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
+ // writes, so a single-tag rule passing is not evidence for this.
+ source_client
+ .put_object()
+ .bucket(source_bucket)
+ .key("and-tags/partial.txt")
+ .tagging("env=prod")
+ .body(ByteStream::from_static(b"one of two tags"))
+ .send()
+ .await?;
+ assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
+ source_client
+ .put_object()
+ .bucket(source_bucket)
+ .key("and-tags/full.txt")
+ .tagging("env=prod&tier=gold")
+ .body(ByteStream::from_static(b"both tags"))
+ .send()
+ .await?;
+ wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
+
source_client
.put_object()
.bucket(source_bucket)
diff --git a/crates/replication/src/config.rs b/crates/replication/src/config.rs
index c7dba3508..16a258ff7 100644
--- a/crates/replication/src/config.rs
+++ b/crates/replication/src/config.rs
@@ -166,6 +166,24 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
}
}
+fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap) -> bool {
+ let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
+ (None, None) => true,
+ (Some(key), _) if key.is_empty() => true,
+ (Some(key), Some(value)) => object_tags.get(key) == Some(value),
+ _ => false,
+ };
+
+ filter
+ .and
+ .as_ref()
+ .and_then(|and| and.tags.as_deref())
+ .into_iter()
+ .flatten()
+ .chain(filter.tag.iter())
+ .all(tag_matches)
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplicationTargetValidationError {
RoleWithMultipleDestinations,
@@ -704,7 +722,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
if let Some(filter) = &rule.filter {
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
- if filter.test_tags(&object_tags) {
+ if replication_filter_tags_match(filter, &object_tags) {
rules.push(rule.clone());
}
} else {
@@ -1139,6 +1157,47 @@ mod tests {
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
}
+ #[test]
+ fn actionable_rules_require_every_and_tag_to_match() {
+ let mut rule = replication_rule("rule-1", "arn:target:a");
+ rule.filter = Some(s3s::dto::ReplicationRuleFilter {
+ and: Some(s3s::dto::ReplicationRuleAndOperator {
+ prefix: None,
+ tags: Some(vec![
+ s3s::dto::Tag {
+ key: Some("env".to_string()),
+ value: Some("prod".to_string()),
+ },
+ s3s::dto::Tag {
+ key: Some("tier".to_string()),
+ value: Some("gold".to_string()),
+ },
+ ]),
+ }),
+ ..Default::default()
+ });
+ let config = structure_config(vec![rule]);
+ let object = |user_tags: &str| ObjectOpts {
+ name: "object".to_string(),
+ user_tags: user_tags.to_string(),
+ ..Default::default()
+ };
+
+ assert!(config.filter_target_arns(&object("env=prod")).is_empty());
+ assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
+ assert!(config.filter_target_arns(&object("")).is_empty());
+
+ let mut malformed = config;
+ malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
+ key: Some("env".to_string()),
+ value: None,
+ }]);
+ assert!(
+ malformed.filter_target_arns(&object("env=prod")).is_empty(),
+ "a malformed tag filter must fail closed"
+ );
+ }
+
#[test]
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
let mut rule = replication_rule("rule-1", "arn:target:a");
diff --git a/crates/replication/src/stats.rs b/crates/replication/src/stats.rs
index 9f606bd23..70a4964d1 100644
--- a/crates/replication/src/stats.rs
+++ b/crates/replication/src/stats.rs
@@ -580,6 +580,30 @@ impl FailStats {
FailedMetric { count, size }
}
+ /// Both rolling windows from one walk of the samples. `short` must be the
+ /// narrower window; the walk stops at `long`. Callers that need both (the
+ /// per-node site snapshot) would otherwise scan the deque twice while
+ /// holding the bucket-stats read lock, and the deque is only bounded by
+ /// the one-hour window - an unreachable target under load fills it.
+ pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
+ let now = Instant::now();
+ let mut short_metric = FailedMetric::default();
+ let mut long_metric = FailedMetric::default();
+ for sample in self.recent.iter().rev() {
+ let age = now.duration_since(sample.observed_at);
+ if age > long {
+ break;
+ }
+ if age <= short {
+ short_metric.count += 1;
+ short_metric.size += sample.size;
+ }
+ long_metric.count += 1;
+ long_metric.size += sample.size;
+ }
+ (short_metric, long_metric)
+ }
+
pub fn merge(&self, other: &FailStats) -> Self {
Self {
count: self.count.saturating_add(other.count),
@@ -912,6 +936,26 @@ mod tests {
assert_eq!(last_hour.size, 96);
}
+ #[test]
+ fn fail_stats_recent_windows_matches_two_separate_scans() {
+ let mut stats = FailStats::default();
+ stats.add_size(64, None::<&()>);
+ stats.add_size(32, None::<&()>);
+
+ let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
+ let expected_minute = stats.recent_since(Duration::from_secs(60));
+ let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
+
+ assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
+ assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
+ assert_eq!(minute.count, 2);
+ assert_eq!(hour.size, 96);
+
+ let empty = FailStats::default();
+ let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
+ assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
+ }
+
#[test]
fn fail_stats_saturate_instead_of_wrapping() {
let mut stats = FailStats {
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..872b5b9be
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,23 @@
+# Documentation
+
+Use the focused indexes rather than treating this directory as an unordered
+collection:
+
+- [Architecture knowledge base](architecture/README.md)
+- [Testing references](testing/README.md)
+
+## Operations
+
+Operational runbooks live under [`operations/`](operations/). Replication
+operators should start with:
+
+| Runbook | Use it for |
+|---|---|
+| [Site replication operations](operations/site-replication-operations.md) | Health fields, pending operations, outage recovery, re-pair admission, IAM/SSE boundaries, and upgrades. |
+| [Replication target check](operations/replication-check.md) | Validating an S3 destination and version fidelity before enabling replication. |
+| [Replication object size limits](operations/replication-object-size-limits.md) | Multipart routing, large-object limits, and retry characteristics. |
+| [Replication outbound transport](operations/replication-outbound-transport.md) | Integrity headers, generic target behavior, and transport knobs. |
+
+Other runbooks remain grouped by filename in [`operations/`](operations/);
+architecture pages link to the relevant runbook where a cross-boundary
+procedure is required.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 9a39edc7b..c151711cf 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -60,6 +60,8 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
-Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
+Operations runbooks are registered in the [documentation operations index](../README.md#operations), and testing references live in [../testing/README.md](../testing/README.md).
+
+For replication operations, start with [site replication operations](../operations/site-replication-operations.md), [replication target check](../operations/replication-check.md), [replication object size limits](../operations/replication-object-size-limits.md), and [replication outbound transport](../operations/replication-outbound-transport.md).
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
diff --git a/docs/architecture/s3-compatibility-matrix.md b/docs/architecture/s3-compatibility-matrix.md
index d4dfdf5c7..f296003dd 100644
--- a/docs/architecture/s3-compatibility-matrix.md
+++ b/docs/architecture/s3-compatibility-matrix.md
@@ -38,6 +38,42 @@ Counts ignore blank lines and comments; compute them from the files. The lifecyc
"Supported" for the SSE row means RustFS encrypts and decrypts its own objects. MinIO SSE objects (SSE-S3, SSE-KMS, SSE-C) are not readable in default builds; see [minio-file-format-compat.md Part C](minio-file-format-compat.md#part-c--server-side-encryption-sse) for the `rio-v2` migration build.
+## Replication Support Boundary
+
+Site replication and bucket replication are not the same compatibility claim.
+Site replication requires RustFS-compatible peer admin APIs and coordinates
+IAM, topology, buckets, and metadata. A generic S3-compatible service can only
+be a bucket-replication data target.
+
+For a generic S3 target, RustFS supports object PUT/HEAD/DELETE, multipart
+uploads, tags, version deletes, and Object Lock mutations when the target
+implements the corresponding S3 APIs and has versioning enabled. Targets that
+mint their own version IDs are supported through a per-target version ledger;
+pre-ledger replicas are adopted only when exact key and ETag identify one
+unambiguous target version. `NoSuchVersion` for an already absent addressed
+replica is treated as converged.
+
+The following are capability boundaries, not universal S3 claims:
+
+- `GET /BUCKET?replication-check` must pass the phases required by the intended
+ workload. `VersionFidelity` may report a minting target as mismatched even
+ though ledger-addressed delete and Object Lock phases succeed.
+- A target that rejects standard multipart constraints, required Object Lock
+ integrity headers, or the configured checksum framing is unsupported until
+ its transport settings are made compatible.
+- SSE-S3 and SSE-KMS are decrypted at the source and re-encrypted by the
+ destination's KMS. SSE-C uses ciphertext passthrough and requires target
+ evidence. Unsupported or ambiguous encryption metadata fails closed.
+- ACL authorization is intentionally unsupported, and generic targets never
+ receive RustFS IAM/site-control-plane state.
+- RustFS does not guess between multiple target versions with the same key and
+ ETag. The mutation remains failed and retryable until repair establishes an
+ unambiguous mapping.
+
+See [site replication operations](../operations/site-replication-operations.md)
+for health, recovery, and upgrade rules and [replication outbound transport](../operations/replication-outbound-transport.md)
+for the tested target classes and knobs.
+
## Not Yet Passing
Standard S3 areas that must not be described as complete:
diff --git a/docs/operations/site-replication-operations.md b/docs/operations/site-replication-operations.md
new file mode 100644
index 000000000..556ef1a51
--- /dev/null
+++ b/docs/operations/site-replication-operations.md
@@ -0,0 +1,258 @@
+# Site Replication Operations
+
+**Use this when:** operating a site-replication deployment, diagnosing a peer
+outage or incomplete topology change, pairing sites that already contain data,
+or planning an upgrade.
+
+**Source of truth:** `rustfs/src/admin/handlers/site_replication.rs`,
+`rustfs/src/site_replication/`, and the bucket-replication worker under
+`crates/ecstore/src/bucket/replication/`.
+
+Site replication combines two different convergence paths:
+
+- the control plane replicates buckets, bucket metadata, IAM, and topology;
+- ordinary bucket replication moves object versions and delete operations.
+
+An `enabled: true` response only says that a site has more than one configured
+peer. It does not prove that every peer is reachable or caught up. Always read
+`pendingOperation`, `retryStats`, `PeerErrors`, and `Metrics` as well.
+
+## Routine checks
+
+Run these commands from an admin workstation with one alias per site:
+
+```console
+mc admin replicate info site-a
+mc admin replicate status site-a
+```
+
+Check more than one site. A partition can leave each side with a different but
+locally valid view.
+
+`replicate info` is the compact control-plane view:
+
+| Field | Interpretation |
+|---|---|
+| `enabled` | More than one site is configured; this is not a health verdict. |
+| `sites` | The locally persisted topology. Compare deployment IDs and endpoints on every site. |
+| `retryStats.pending` | Collapsed peer deliveries waiting to be retried. |
+| `retryStats.failed` | Deliveries that crossed the escalation threshold and require attention. |
+| `retryStats.lastError` | A redacted summary of the most recent delivery failure. |
+| `pendingOperation` | A durable multi-step topology operation described below. Absence is the healthy steady state. |
+
+`replicate status` adds detailed convergence state:
+
+| Field | Interpretation |
+|---|---|
+| `Sites` / `PeerStates` | Configured peers and derived reachability/configuration state. |
+| `PeerErrors` | A peer could not be queried. Its detailed counters may be absent; do not read zeros as success. |
+| `BucketStats` | Per-bucket presence and versioning, replication, lifecycle, Object Lock, and metadata mismatches. |
+| `PolicyStats`, `UserStats`, `GroupStats` | IAM inventory mismatches. |
+| `RetryStats` | Durable control-plane retry backlog and escalation count. |
+| `Metrics.replMetrics` | Per-destination online state, downtime, replicated counts/bytes, and `failed` totals/windows. |
+| `Metrics.queued` / `Metrics.inProgress` | Object work waiting or active on the responding node. |
+| `Metrics.errors` | Node-level object-replication failures. When only queue statistics are available, RustFS synthesizes a node entry and preserves this counter rather than reporting zero. |
+| `Metrics.retries` | Redeliveries. Always zero today: a failed object is not retried by an event, it waits for the scanner pass described below. Read `errors` instead. |
+
+Healthy means: the same topology is visible on all sites, no pending operation,
+no peer error, no failed retry escalation, required bucket/IAM state is in sync,
+and queue/error counters are stable or falling. Counters are cumulative; alert on
+their rate and on a backlog that does not drain, not merely on a non-zero total.
+
+## Pending operations and recovery
+
+`pendingOperation` contains `operation`, an opaque `id`, `pendingPeers`, and
+`ackedPeers`. Do not edit the site-replication state object by hand. The marker
+is the crash-recovery journal and removing it can make a partially applied
+operation look complete.
+
+The heavyweight reconciler runs once at startup and every 600 seconds. The
+lightweight retry drain runs every 30 seconds. A restart is therefore a valid
+way to cause an immediate heavyweight pass after the underlying fault has been
+fixed, but it is not a substitute for fixing connectivity, credentials, TLS,
+or the remote endpoint.
+
+### `remove`
+
+The original topology and each peer acknowledgement are persisted before the
+operation finalizes. While peers remain in `pendingPeers`, restore access to
+them and wait for reconciliation. If a peer is permanently gone, a new remove
+request may remove all currently active unacknowledged peers; RustFS permits
+that request and then finalizes against the remaining topology. Removing the
+local site or all sites is also an explicit completion path.
+
+Do not re-add a site merely to hide this marker. First compare the topology on
+all reachable peers. If the same operation ID makes no progress for more than
+one heavyweight interval, collect `PeerErrors`, `RetryStats`, and the
+site-replication logs before retrying the remove.
+
+### `rotate-svc-acct`
+
+Service-account rotation keeps the candidate secrets and peer acknowledgements
+until every current remote peer accepts the rotation. Restore the failing peer
+and allow the reconciler to resume it. Do not manually delete either candidate
+credential during this window: doing so can remove the only credential that a
+not-yet-acknowledged peer accepts.
+
+After the marker clears, verify `replicate status` from every site, then retire
+any separately retained old credential material according to local policy.
+
+### `endpoint-refresh`
+
+An endpoint, CA, or TLS-verification edit first refreshes the replication
+target on every active peer and records acknowledgements. On startup and every
+heavyweight pass, RustFS probes peer capability, uses the endpoint-refresh API
+when supported (or the legacy peer-edit fallback), refreshes local bucket
+targets, and commits the edit only after every still-active peer acknowledges.
+
+If this marker is stuck:
+
+1. Confirm that the proposed endpoint and CA are correct and reachable from
+ every site, not only from the admin workstation.
+2. Restore the site-replication service account and TLS trust path.
+3. Wait for one 600-second pass or restart one healthy node to trigger the
+ startup pass.
+4. Re-run the identical edit only if the operation remains visible; a different
+ endpoint edit is rejected while the existing refresh is pending. The journal
+ pins the edit's payload, so a re-run without `--replicate-ilm-expiry` keeps
+ the value the first attempt recorded, and a re-run asking for a different
+ value is rejected. Finish or remove the pending refresh before changing it.
+
+A peer removed from the topology no longer blocks completion. A remove request
+is accepted when it removes every active unacknowledged peer.
+
+While this marker is present, control-plane retry replay to the other peers
+keeps running, but bucket wiring reconciliation waits: it rewrites the same
+targets the refresh is changing. Expect bucket-level drift on this site to
+persist until the refresh settles.
+
+## Outage recovery and convergence time
+
+Control-plane retry begins on the 30-second drain, while heavyweight snapshots,
+pending topology operations, and bucket wiring are revisited on the 600-second
+pass. Object MRF entries are persisted every 10 seconds by default and target
+health is probed every 5 seconds. These are scheduling bounds, not delivery
+SLAs: network timeouts and the amount of queued work add to them.
+
+Objects that must be rediscovered by the scanner have this conservative upper
+bound before discovery:
+
+```text
+RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES
+ × max(RUSTFS_SCANNER_CYCLE, actual duration of one scanner cycle)
+```
+
+The defaults re-descend a compacted directory every 16 cycles. A practical
+production starting point for a tighter recovery objective is
+`RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=4`; `1` forces re-descent every cycle.
+Measure the additional disk and metadata load before lowering it further or
+tuning the scanner cadence. For an immediate operator-driven recovery, start a
+site resync with `mc admin replicate resync start` and monitor its status.
+Transfer time after discovery remains proportional to backlog size, bandwidth,
+worker capacity, and target latency. Use queue depth and the rate of
+`Metrics.errors` rather than the formula alone to decide whether convergence is
+progressing.
+
+## Pairing sites that already contain data
+
+When more than one requested site is non-empty, preflight considers each bucket
+name held by more than one site:
+
+- versioning must be `Enabled` on every site holding the shared bucket;
+- Object Lock enablement must be identical on every holder.
+
+A bucket present on only one site is safe: post-add backfill creates it on the
+other peers. A shared unversioned bucket is rejected because merging can
+overwrite the only copy of an object. An Object Lock mismatch is rejected
+because lock enablement cannot be changed after bucket creation and convergence
+could otherwise strip a WORM guarantee.
+
+If preflight rejects the pair, keep the authoritative copy, delete the
+conflicting bucket (or its contents) from all other sites, run `replicate add`
+again, and then start `replicate resync` from the surviving site. Back up and
+validate the authoritative data before deleting anything.
+
+## IAM convergence and repair boundary
+
+Ordinary IAM changes are delivered to each peer. A successful bulk IAM import
+also schedules one collapsed full-IAM snapshot per remote peer. A failed IAM
+deletion is replayed before that snapshot so the snapshot cannot re-create a
+principal or grant that was already revoked.
+
+The safety state has two bounds:
+
+- deletion high-water marks are retained for 30 days;
+- deletion replay bodies are capped at 256 distinct entities per peer.
+
+Repeated deletion of the same entity replaces its saved body. When the per-peer
+cap is exceeded or the body cannot be serialized, the retry entry remains
+escalated rather than pretending the deletion is replayable. An item from an
+older sender without a source timestamp cannot install the 30-day high-water
+mark, so verify it explicitly after a prolonged split. A successful drain
+clears replay bodies; removing the peer prunes its bodies. For an escalated IAM
+retry, use the site-replication repair workflow for the affected peer and IAM
+family, then verify users, service accounts, groups, policies, and mappings on
+both sides. Repair is the operator's explicit accountability transfer and
+clears the saved deletion bodies only after the IAM repair succeeds.
+
+A group's status converges in one direction. An explicit disable is applied
+everywhere, including through a snapshot, but a membership change never
+carries an enable - it would otherwise re-enable a group frozen on the
+receiving site. If a group ended up disabled on one site only, re-enable it
+there explicitly with `mc admin group enable`; a snapshot or repair will not
+do it.
+
+Treat IAM divergence as a security incident: a user deleted on one site can
+remain usable on an unreachable peer until replay or repair completes. A peer
+whose IAM entry is escalated does not receive scheduled snapshots either -
+including the one a bulk import schedules - until the repair settles it.
+
+## Encrypted objects
+
+| Source form | Replication behavior | Fail-closed condition |
+|---|---|---|
+| SSE-S3 | The source decrypts the object; the request sends only `AES256` intent; the destination encrypts with its own KMS. Source envelope material never leaves the site. | The destination cannot satisfy the encryption request, or the source metadata is incomplete/unsupported. The replica is `FAILED`; plaintext is not silently stored. |
+| SSE-KMS | The source decrypts the object; the request sends `aws:kms` intent without the source-local key ID; the destination selects its own configured KMS key. | Either side cannot decrypt/encrypt, or the metadata mixes incompatible encryption evidence. |
+| SSE-C | Stored ciphertext and the required SSE-C replication transport metadata pass through. RustFS verifies target evidence before accepting the replica. | The target does not echo the customer-algorithm evidence, required material/layout is absent, or the metadata is ambiguous. |
+
+Unknown MinIO/RustFS encryption markers are never forwarded as ordinary user
+metadata. They fail replication so an operator must migrate or repair the
+object with a supported format.
+
+## Rolling upgrades and rollback
+
+Keep every node in one site on the same version whenever possible. Upgrade all
+nodes of one site consecutively, verify its startup reconciliation and status,
+then move to the next site. Do not intentionally leave a site mixed-version:
+admin requests can land on different nodes, and an older node may not resume a
+new pending-operation shape or expose its health fields.
+
+Current state additions are optional and defaulted, so older readers ignore
+them. The target-version ledger is stored as dual-prefixed internal object
+metadata and is also ignored by older readers; rollback does not corrupt the
+object format, but older code loses the assigned-version routing improvement.
+
+Before rolling back across the fix that retains the data directory of a version
+awaiting purge replication (rustfs/rustfs#7307), ensure no version purge is
+pending. Older code can free that retained version's data directory before the
+remote purge is acknowledged, leaving unreadable metadata and blocking bucket
+deletion. Drain or repair replication and take a metadata/data backup first.
+
+## Runtime knobs
+
+These values are read when the owning background task starts. Restart the
+server after changing them. The millisecond intervals have a 10 ms floor;
+invalid values fall back to the default with a warning.
+
+| Variable | Default | Effect |
+|---|---:|---|
+| `RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS` | `5000` | Remote-target health probe interval. Lowering it increases outbound probes. |
+| `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS` | `10000` | Maximum periodic interval between MRF persistence flushes; 1,000 new entries also trigger a flush. |
+| `RUSTFS_REPL_RESYNC_POLL_MAX_MS` | `60000` | Upper bound for randomized resync retry-poll sleep. |
+| `RUSTFS_REPL_RESYNC_MAX_JOBS` | `2` | Concurrent resync jobs; values are bounded to `1..=32`. |
+
+Transport-specific controls and target behavior are documented in
+[Replication outbound transport](replication-outbound-transport.md). Validate a
+new destination with [Replication target check](replication-check.md), and read
+[Replication object size limits](replication-object-size-limits.md) before
+moving large objects.
diff --git a/rustfs/src/admin/handlers/replication.rs b/rustfs/src/admin/handlers/replication.rs
index e4ca6ec9d..0179074c4 100644
--- a/rustfs/src/admin/handlers/replication.rs
+++ b/rustfs/src/admin/handlers/replication.rs
@@ -627,6 +627,32 @@ pub(crate) async fn cluster_replication_stats(bucket: &str, context: Option>, action: &'static str) {
+ let Some(notification_system) = current_notification_system_for_context(context.as_deref()) else {
+ return;
+ };
+ if let Err(err) = notification_system.load_bucket_metadata(bucket).await {
+ warn!(
+ event = EVENT_ADMIN_REMOTE_TARGET_STATE,
+ component = LOG_COMPONENT_ADMIN,
+ subsystem = LOG_SUBSYSTEM_REPLICATION,
+ action = action,
+ result = "peer_metadata_reload_failed",
+ bucket = %bucket,
+ error = ?err,
+ "admin remote target state"
+ );
+ }
+}
+
fn unique_replication_peers(peer_clients: &[Option]) -> (Vec<&PeerRestClient>, u32) {
let mut seen_grid_hosts = HashSet::new();
let peers: Vec<_> = peer_clients
@@ -699,6 +725,7 @@ pub struct SetRemoteTargetHandler {}
impl Operation for SetRemoteTargetHandler {
async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> {
let cred = validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
+ let app_context = app_context_from_req(&req);
let queries = extract_query_params(&req.uri);
@@ -926,6 +953,8 @@ impl Operation for SetRemoteTargetHandler {
.map_err(map_bucket_target_error)?;
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
let arn = persist_remote_target_write(bucket, remote_target, incarnation, mode).await?;
+ drop(_targets_guard);
+ notify_remote_target_metadata_reload(bucket, app_context, "set_remote_target").await;
let arn_str = serde_json::to_string(&arn)
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize target ARN"))?;
@@ -1006,6 +1035,7 @@ pub struct RemoveRemoteTargetHandler {}
impl Operation for RemoveRemoteTargetHandler {
async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> {
validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
+ let app_context = app_context_from_req(&req);
debug!("remove remote target called");
let queries = extract_query_params(&req.uri);
@@ -1081,6 +1111,7 @@ impl Operation for RemoveRemoteTargetHandler {
}
let json_targets = serde_json::to_vec(&targets)
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets"))?;
+ let notification_bucket = bucket.clone();
let bucket = bucket.clone();
let arn = arn_str.clone();
// The pool cancellation owns a detached task. Both outer guards must
@@ -1101,6 +1132,8 @@ impl Operation for RemoveRemoteTargetHandler {
S3Error::with_message(S3ErrorCode::InternalError, format!("remote target removal task failed: {error}"))
})??;
+ notify_remote_target_metadata_reload(¬ification_bucket, app_context, "remove_remote_target").await;
+
Ok(S3Response::new((StatusCode::NO_CONTENT, Body::from("".to_string()))))
}
}
@@ -1787,6 +1820,25 @@ mod tests {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
+ #[test]
+ fn remote_target_writes_notify_peer_metadata_caches() {
+ let source = include_str!("replication.rs");
+ for (start, end) in [
+ ("impl Operation for SetRemoteTargetHandler", "pub struct ListRemoteTargetHandler"),
+ ("impl Operation for RemoveRemoteTargetHandler", "async fn cancel_active_resync_intent"),
+ ] {
+ let body = source
+ .split(start)
+ .nth(1)
+ .and_then(|rest| rest.split(end).next())
+ .expect(start);
+ assert!(
+ body.contains("notify_remote_target_metadata_reload"),
+ "{start} must notify every node before returning success"
+ );
+ }
+ }
+
#[test]
fn update_ops_parse_minio_query_contract() {
let ops = parse_remote_target_update_ops(&query_map(&[
diff --git a/rustfs/src/admin/handlers/site_replication.rs b/rustfs/src/admin/handlers/site_replication.rs
index 318c8a093..ad810384f 100644
--- a/rustfs/src/admin/handlers/site_replication.rs
+++ b/rustfs/src/admin/handlers/site_replication.rs
@@ -62,12 +62,13 @@ use rustfs_iam::sys::{
};
use rustfs_madmin::{
BucketBandwidth, GroupStatus, IDPSettings, InProgressMetric, InQueueMetric, LDAPConfigSettings, LDAPSettings,
- OpenIDProviderSettings, OpenIDSettings, PeerInfo, PeerSite, QStat, ReplProxyMetric, ReplicateAddStatus, ReplicateEditStatus,
- ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC, SR_IAM_ITEM_STS_ACC_LEGACY,
- SRBucketInfo, SRBucketMeta, SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMUser,
- SRILMExpiryStatsSummary, SRInfo, SRMetric, SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping,
- SRPolicyStatsSummary, SRRemoveReq, SRResyncOpStatus, SRSTSCredential, SRSiteSummary, SRStateEditReq, SRStateInfo,
- SRStatusInfo, SRSvcAccChange, SRSvcAccCreate, SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat,
+ OpenIDProviderSettings, OpenIDSettings, PeerInfo, PeerSite, QStat, RStat, ReplProxyMetric, ReplicateAddStatus,
+ ReplicateEditStatus, ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC,
+ SR_IAM_ITEM_STS_ACC_LEGACY, SRBucketInfo, SRBucketMeta, SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem,
+ SRIAMUser, SRILMExpiryStatsSummary, SRInfo, SRMetric, SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation,
+ SRPolicyMapping, SRPolicyStatsSummary, SRRemoveReq, SRResyncOpStatus, SRSTSCredential, SRSiteSummary, SRStateEditReq,
+ SRStateInfo, SRStatusInfo, SRSvcAccChange, SRSvcAccCreate, SRUserStatsSummary, SiteReplicationInfo, SyncStatus,
+ TimedErrStats, WorkerStat,
};
use rustfs_policy::policy::{
Policy,
@@ -87,7 +88,7 @@ use std::sync::{LazyLock, Mutex as StdMutex};
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::Mutex;
-use tracing::{info, warn};
+use tracing::{debug, info, warn};
use url::Url;
use url::form_urlencoded;
use uuid::Uuid;
@@ -1866,7 +1867,17 @@ fn reconcile_site_replication_wiring() -> std::pin::Pin {
- if state.pending_endpoint_refresh.is_some() {
+ if let Some(pending_endpoint_refresh) = state.pending_endpoint_refresh.clone() {
+ resume_pending_endpoint_refresh(&state, &pending_endpoint_refresh).await;
+ // The bucket reconciler below rewrites targets from the
+ // topology, which is exactly what this refresh is in the
+ // middle of changing, so it still waits for the next pass.
+ // The retry queue does not: it replays per-peer deliveries
+ // against the endpoints currently committed in state, and a
+ // refresh that cannot finish - a peer that never comes back
+ // - must not also stall replay to the healthy peers.
+ drop(lifecycle);
+ drain_site_replication_retry_queue().await;
return;
}
// A wedged rotation is worse than a wedged removal: the local
@@ -2173,6 +2184,7 @@ fn peer_metric_entry(
reachable: bool,
(total_downtime_ns, last_online): (i64, Option),
local_counters: (i64, i64),
+ local_failures: TimedErrStats,
) -> SRMetric {
let (replica_size, replica_count) = local_counters;
@@ -2187,6 +2199,25 @@ fn peer_metric_entry(
// remote entries would double-count them cluster-wide.
replicated_size: if is_local { replica_size } else { 0 },
replicated_count: if is_local { replica_count } else { 0 },
+ failed: if is_local { local_failures } else { TimedErrStats::default() },
+ ..Default::default()
+ }
+}
+
+fn site_failure_stats(node: &crate::storage::storage_api::ReplicationSiteMetricsSnapshot) -> TimedErrStats {
+ TimedErrStats {
+ last_minute: RStat {
+ count: node.failed_last_minute_count as f64,
+ bytes: node.failed_last_minute_bytes,
+ },
+ last_hour: RStat {
+ count: node.failed_last_hour_count as f64,
+ bytes: node.failed_last_hour_bytes,
+ },
+ totals: RStat {
+ count: node.failed_count as f64,
+ bytes: node.failed_bytes,
+ },
..Default::default()
}
}
@@ -2201,6 +2232,7 @@ async fn build_metrics_summary(
};
let node = stats.site_metrics_snapshot().await;
+ let failures = site_failure_stats(&node);
let mut metrics = BTreeMap::new();
// Emit an entry for every peer, not just the local one. An operator reading
@@ -2221,6 +2253,7 @@ async fn build_metrics_summary(
reachable,
health,
(node.replica_size, node.replica_count),
+ failures.clone(),
),
);
}
@@ -2234,6 +2267,7 @@ async fn build_metrics_summary(
last_online: Some(OffsetDateTime::now_utc()),
replicated_size: node.replica_size,
replicated_count: node.replica_count,
+ failed: failures,
..Default::default()
});
@@ -3015,6 +3049,22 @@ fn peer_endpoint_refresh_requested(state: &SiteReplicationState, incoming: &Peer
.is_some_and(|peer| !peer_connection_settings_match(peer, incoming))
}
+/// A persisted refresh journal pins the edit's payload: the commit reads the
+/// peer and the ilm-expiry override back out of it, so a re-run cannot change
+/// them. Re-running without the flag keeps the pinned value - that is the
+/// documented way to redrive a stuck refresh - but a re-run that asks for a
+/// different value must be rejected rather than accepted and ignored.
+/// The one construction of the concurrent-change error: every writer of a
+/// pending refresh re-checks the journal inside its own transaction and
+/// reports the same condition when it no longer owns it.
+fn endpoint_refresh_state_changed() -> S3Error {
+ s3_error!(InvalidRequest, "endpoint target refresh state changed during update")
+}
+
+fn endpoint_refresh_ilm_override_conflicts(persisted: &PendingEndpointRefresh, requested: Option) -> bool {
+ requested.is_some() && requested != persisted.ilm_expiry_override
+}
+
fn merge_pending_endpoint_refresh(
state: &SiteReplicationState,
candidate: &PendingEndpointRefresh,
@@ -3025,7 +3075,7 @@ fn merge_pending_endpoint_refresh(
|| latest.peer.deployment_id != candidate.peer.deployment_id
|| !peer_connection_settings_match(&latest.peer, &candidate.peer)
{
- return Err(s3_error!(InvalidRequest, "endpoint target refresh state changed during update"));
+ return Err(endpoint_refresh_state_changed());
}
latest
} else {
@@ -3762,6 +3812,21 @@ fn pending_operation_for_state(state: &SiteReplicationState, local_peer: &PeerIn
});
}
+ if let Some(pending) = pending_endpoint_refresh(state) {
+ let pending_peers = pending_endpoint_refresh_required_peer_ids(state, &pending, local_peer)
+ .into_iter()
+ .filter(|deployment_id| !pending.acked_deployment_ids.contains(deployment_id))
+ .collect();
+ return Some(SRPendingOperation {
+ operation: "endpoint-refresh".to_string(),
+ id: pending.id,
+ pending_peers,
+ acked_peers: pending.acked_deployment_ids.into_iter().collect(),
+ updated_at: None,
+ api_version: Some(SITE_REPL_API_VERSION.to_string()),
+ });
+ }
+
state.pending_rotation.as_ref().map(|pending| {
let pending_peers = pending_remote_peer_ids(&pending.peers, local_peer)
.into_iter()
@@ -3798,6 +3863,78 @@ fn pending_all_remote_peers_acked(
.all(|deployment_id| acked_deployment_ids.contains(deployment_id))
}
+fn pending_endpoint_refresh_required_peer_ids(
+ state: &SiteReplicationState,
+ pending: &PendingEndpointRefresh,
+ local_peer: &PeerInfo,
+) -> BTreeSet {
+ pending
+ .remote_peers
+ .values()
+ .filter(|peer| {
+ state.peers.contains_key(&peer.deployment_id)
+ && peer.deployment_id != local_peer.deployment_id
+ && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
+ })
+ .map(|peer| peer.deployment_id.clone())
+ .collect()
+}
+
+fn pending_endpoint_refresh_is_complete(
+ state: &SiteReplicationState,
+ pending: &PendingEndpointRefresh,
+ local_peer: &PeerInfo,
+) -> bool {
+ pending_endpoint_refresh_required_peer_ids(state, pending, local_peer)
+ .iter()
+ .all(|deployment_id| pending.acked_deployment_ids.contains(deployment_id))
+}
+
+fn pending_endpoint_refresh_allows_remove(
+ state: &SiteReplicationState,
+ pending: &PendingEndpointRefresh,
+ local_peer: &PeerInfo,
+ remove_req: &SRRemoveReq,
+) -> bool {
+ if remove_req.remove_all || remove_req.site_names.iter().any(|name| name == &local_peer.name) {
+ return true;
+ }
+ let removed = removed_deployment_ids_for_remove_req(state, remove_req);
+ pending_endpoint_refresh_required_peer_ids(state, pending, local_peer)
+ .difference(&pending.acked_deployment_ids)
+ .all(|deployment_id| removed.contains(deployment_id))
+}
+
+fn discard_endpoint_refresh_if_target_was_removed(state: &mut SiteReplicationState) {
+ if pending_endpoint_refresh(state)
+ .as_ref()
+ .is_some_and(|pending| !state.peers.contains_key(&pending.peer.deployment_id))
+ {
+ clear_pending_endpoint_refresh(state);
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum EndpointRefreshPostwriteState {
+ Current,
+ Superseded,
+ TargetRemoved,
+}
+
+fn endpoint_refresh_postwrite_state(
+ state: &SiteReplicationState,
+ pending_id: &str,
+ target_deployment_id: &str,
+) -> EndpointRefreshPostwriteState {
+ if !state.peers.contains_key(target_deployment_id) {
+ EndpointRefreshPostwriteState::TargetRemoved
+ } else if pending_endpoint_refresh(state).is_some_and(|pending| pending.id == pending_id) {
+ EndpointRefreshPostwriteState::Current
+ } else {
+ EndpointRefreshPostwriteState::Superseded
+ }
+}
+
fn push_unique_secret_candidate(candidates: &mut Vec, secret: String) {
if !secret.is_empty() && !candidates.iter().any(|candidate| candidate == &secret) {
candidates.push(secret);
@@ -4019,6 +4156,171 @@ async fn resume_pending_rotation(state: &SiteReplicationState, pending: &Pending
}
}
+async fn mark_pending_endpoint_refresh_peer_acked(refresh_id: &str, deployment_id: &str) -> S3Result<()> {
+ let refresh_id = refresh_id.to_string();
+ let deployment_id = deployment_id.to_string();
+ update_site_replication_state_when_changed(move |state| {
+ let Some(pending) = state
+ .pending_endpoint_refresh
+ .as_mut()
+ .filter(|pending| pending.id == refresh_id)
+ else {
+ return Ok(StateCommit::Unchanged(()));
+ };
+ pending.acked_deployment_ids.insert(deployment_id);
+ Ok(StateCommit::Changed(()))
+ })
+ .await
+}
+
+async fn finalize_pending_endpoint_refresh_if_complete(refresh_id: &str, service_account_secret_key: &str) -> S3Result {
+ let state = load_site_replication_state().await?;
+ let Some(pending) = pending_endpoint_refresh(&state).filter(|pending| pending.id == refresh_id) else {
+ return Ok(true);
+ };
+ let local_peer = current_local_runtime_peer(&state);
+ if !pending_endpoint_refresh_is_complete(&state, &pending, &local_peer) {
+ return Ok(false);
+ }
+
+ refresh_bucket_targets_after_endpoint_edit(refresh_id, service_account_secret_key).await?;
+
+ let refresh_id = refresh_id.to_string();
+ update_site_replication_state_when_changed(move |state| {
+ let Some(pending) = pending_endpoint_refresh(state).filter(|pending| pending.id == refresh_id) else {
+ return Ok(StateCommit::Unchanged(true));
+ };
+ let local_peer = current_local_runtime_peer(state);
+ if !pending_endpoint_refresh_is_complete(state, &pending, &local_peer) {
+ return Ok(StateCommit::Unchanged(false));
+ }
+ *state = edit_state(std::mem::take(state), pending.peer, pending.ilm_expiry_override);
+ clear_pending_endpoint_refresh(state);
+ Ok(StateCommit::Changed(true))
+ })
+ .await
+}
+
+async fn drive_pending_endpoint_refresh(
+ state: &SiteReplicationState,
+ pending: &PendingEndpointRefresh,
+) -> S3Result<(Vec, bool)> {
+ if state.service_account_access_key.is_empty() {
+ return Err(s3_error!(InvalidRequest, "site replication service account is not configured"));
+ }
+ let service_account_secret_key = site_replicator_service_account_secret(&state.service_account_access_key).await?;
+ let local_peer = current_local_runtime_peer(state);
+ let required = pending_endpoint_refresh_required_peer_ids(state, pending, &local_peer);
+ let mut peer_errors = Vec::new();
+
+ for target in pending.remote_peers.values().filter(|target| {
+ required.contains(&target.deployment_id) && !pending.acked_deployment_ids.contains(&target.deployment_id)
+ }) {
+ let refreshed = async {
+ let (status, body) = send_endpoint_refresh_admin_request_raw(
+ target,
+ pending,
+ SITE_REPLICATION_PEER_EDIT_CAPABILITY_PATH,
+ &state.service_account_access_key,
+ &service_account_secret_key,
+ &(),
+ )
+ .await?;
+ if endpoint_refresh_capability_supported(target, status, &body)? {
+ let request = EndpointRefreshRequest {
+ id: pending.id.clone(),
+ peer: pending.peer.clone(),
+ };
+ let body = send_endpoint_refresh_admin_request(
+ target,
+ pending,
+ SITE_REPLICATION_PEER_EDIT_REFRESH_PATH,
+ &state.service_account_access_key,
+ &service_account_secret_key,
+ &request,
+ )
+ .await?;
+ parse_endpoint_refresh_status(target, &body)
+ } else {
+ refresh_legacy_peer_bucket_targets(
+ target,
+ pending,
+ &state.service_account_access_key,
+ &service_account_secret_key,
+ )
+ .await
+ }
+ }
+ .await;
+
+ match refreshed {
+ Ok(()) => mark_pending_endpoint_refresh_peer_acked(&pending.id, &target.deployment_id).await?,
+ Err(err) => peer_errors.push(summarize_peer_error_detail(&format!("{}: {err}", target.endpoint))),
+ }
+ }
+
+ let complete = finalize_pending_endpoint_refresh_if_complete(&pending.id, &service_account_secret_key).await?;
+ Ok((peer_errors, complete))
+}
+
+/// The coordinator of an endpoint edit snapshots the topology it must fan out
+/// to into `remote_peers`; the peer-side handler persists its journal with an
+/// empty map ([`SRPeerEditHandler`]) because it fans out to nobody. Only the
+/// coordinator's journal may be resumed here. A receiver's journal has no
+/// required peers, so it would read as complete on the first tick and commit
+/// through [`edit_state`] instead of [`apply_internal_peer_edit`] — dropping
+/// the local-name sync and racing the in-flight request that owns it, whose
+/// own commit would then report the refresh as changed and leave the
+/// coordinator waiting for an acknowledgement it will never get. A receiver's
+/// journal is redriven by the coordinator resending the same refresh id.
+fn pending_endpoint_refresh_is_locally_driven(pending: &PendingEndpointRefresh) -> bool {
+ !pending.remote_peers.is_empty()
+}
+
+async fn resume_pending_endpoint_refresh(state: &SiteReplicationState, pending: &PendingEndpointRefresh) {
+ if !pending_endpoint_refresh_is_locally_driven(pending) {
+ debug!(
+ event = EVENT_ADMIN_SITE_REPLICATION_STATE,
+ component = LOG_COMPONENT_ADMIN,
+ subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
+ result = "pending_endpoint_refresh_owned_by_peer_request",
+ "admin site replication state"
+ );
+ return;
+ }
+ match drive_pending_endpoint_refresh(state, pending).await {
+ Ok((peer_errors, true)) if peer_errors.is_empty() => {
+ info!(
+ event = EVENT_ADMIN_SITE_REPLICATION_STATE,
+ component = LOG_COMPONENT_ADMIN,
+ subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
+ result = "pending_endpoint_refresh_resumed",
+ "admin site replication state"
+ );
+ }
+ Ok((peer_errors, _)) => {
+ warn!(
+ event = EVENT_ADMIN_SITE_REPLICATION_STATE,
+ component = LOG_COMPONENT_ADMIN,
+ subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
+ result = "pending_endpoint_refresh_still_pending",
+ error_count = peer_errors.len(),
+ "admin site replication state"
+ );
+ }
+ Err(err) => {
+ warn!(
+ event = EVENT_ADMIN_SITE_REPLICATION_STATE,
+ component = LOG_COMPONENT_ADMIN,
+ subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
+ result = "pending_endpoint_refresh_resume_failed",
+ error = ?err,
+ "admin site replication state"
+ );
+ }
+ }
+}
+
async fn pending_remove_ready_to_finalize(remove_id: &str, local_peer: &PeerInfo) -> S3Result