Compare commits

...

60 Commits

Author SHA1 Message Date
cxymds 1880b42169 fix(heal): preserve decode repair signals and retry startup quorum (#8067)
* fix(startup): retry transient bucket metadata quorum failures

* fix(ecstore): retain recovered shard damage for read repair

* fix(ecstore): reconnect missing read sources before GET

* test(heal): prove exact marker replay preserves history

* fix(ecstore): fence reconnect retries at the deadline

Reject expired reconnect waiters before dispatch and start cooldown on sweep completion, including timeout and cancellation. Cover exact-deadline admission independently of cooldown and preserve the original strict RPC-count regression.
2026-09-22 14:58:31 +00:00
Chris 3f549e26f3 fix(ci): publish version-tagged preview Docker images (#8068) 2026-09-22 13:06:23 +00:00
Dae-Cheol Noh 3c6c88b2e7 fix(amqp): emit S3 events directly in notification records (#8066)
* fix(amqp): emit S3 events directly in notification records

* test(amqp): avoid queued timestamp equality race
2026-09-22 12:16:40 +00:00
Dae-Cheol Noh 1f04a12abf fix(ftps): bound upload memory with multipart streaming (#8064)
* fix(ftps): bound upload memory with multipart streaming

* fix(ftps): keep multipart upload within driver module
2026-09-22 12:15:20 +00:00
cxymds 122abfaae2 feat(integrity): add inventory, audit, and protected migration (#8065)
* feat(integrity): add inventory, audit, and protected migration

* refactor(admin): use gateway facade for integrity handlers

* fix(integrity): sort fingerprint metadata explicitly
2026-09-22 19:23:41 +08:00
cxymds d0ce2f758b fix(ecstore): bound decommission target gate contention (#8061)
* fix(ecstore): bound decommission target gate contention

Target capacity gate contention during pool decommission escalated a
per-object transient into a durable bucket pause: each contended object
failed its bucket entry, which re-ran the whole bucket listing and amplified
attempts on the same objects.

- centralize the decommission capacity failure classification so gate
  contention, benign contention and fatal failures are decided once
- retry target-gate contention inline (bounded, jittered) before a mutation
  is admitted, covering put, part, complete, new-multipart and abort
- defer contended entries to the end of the round instead of failing the
  bucket entry, and require the deferred set to drain before a set completes
- treat a missing object or version, an overwrite and a superseded upload id
  as benign contention that is neither counted as a failure nor escalated
- use full-jitter exponential backoff, capped, for decommission retries
- expose the capacity pause reason, the waiting reason and a cumulative
  pause count in the admin pool status, plus gate-retry and per-object
  attempt metrics

Related: rustfs/backlog#2644

* fix(ecstore): correct deferred replay and metadata compatibility
2026-09-22 19:20:05 +08:00
hector 9c30cc8851 feat(ci): on-demand fault-tolerance matrix sweep workflow (#8058)
Adds rustfs-fault-tolerance-matrix.yml, a manually dispatched workflow that
runs the --matrix topology x EC outage sweep from rustfs/auto-testing (38
combinations, 320 cases). The scenario suite hardcodes --all and a 60-minute
budget, so the exhaustive sweep had no CI entry point.
2026-09-22 13:22:18 +08:00
Hauser 0ff06757c8 fix(obs): support custom OTLP CA bundles (#8060)
Add RUSTFS_OBS_TLS_CA_FILE for additive private CA trust in OTLP HTTP exporters.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-22 13:21:44 +08:00
cxymds e12100b4a1 fix(s3): emit x-amz-expiration as HTTP-date (#8057)
The expiry-date field of the x-amz-expiration response header was
rendered as RFC 3339 (e.g. 2026-10-02T00:00:00Z). AWS SDKs parse this
value with an RFC 822 parser; the Java SDK v1 rejects the ISO-8601
shape, drops the whole header, and logs a WARN from
ObjectExpirationHeaderHandler. S3 specifies HTTP-date (RFC 1123 with a
literal GMT zone), which is also what MinIO emits.

Render via the shared HTTP-date formatter (extracted alongside
format_expires_header) and pin the exact output in the unit test.
2026-09-22 11:18:59 +08:00
hector d7c42d4099 fix(ci): accept the testing-sha staleness fallback in chain evidence (#8056)
The 09-22 nightly chain failed all 12 lanes in seconds at the 'Bind
functional candidate' step:

  ValueError: private script pin differs from chain

resolve_functional_candidate.py's >24h staleness fallback (added by
#8026, made functional by #8041's token fix) legitimately sets
manifest.testing_sha to auto-testing main HEAD, but current_chain()
still required it to equal .config/functional-script-revision.txt -
a check written for the pre-fallback world where the two could never
diverge. Once the fallback finally fired, prepare produced testing_sha
21edcf4 while the pin file still holds 27e9584 and every lane aborted
before checking out the test scripts.

Drop the pin-file comparison and keep what the lane actually needs to
guarantee: testing_sha is a valid commit sha (current_chain), the lane
checked out exactly that sha (record: private_head == testing_sha, kept
as-is), and the health checker validates the same format instead of
re-reading the pin file. Tests updated: a fallback testing_sha that
differs from the pin is accepted; a non-sha testing_sha is rejected.

Verified: python3 -m unittest test_functional_chain
test_functional_chain_health -> 39 tests OK.
2026-09-22 10:25:36 +08:00
hector 4b0118520e test(ci): align FT workflow contract tests with scenario E (44 cases) (#8054)
#8052 renamed the fault-tolerance step to 'Run fault-tolerance
scenarios (A, B, C, C2, D, E)' and raised the evidence gate to 44
cases, but scripts/test_security_workflow.py still expected the old
step name and a 38-case fixture, breaking Quick Checks with three
KeyErrors and one assertion failure. Update DIRECT_TESTS and the
write_result fixture to the new contract.
2026-09-22 00:18:40 +08:00
cxymds 1afaf4491e fix(ecstore): bound decommission capacity gate waits (#8044)
* fix(ecstore): bound decommission capacity gate waits

* fix(ecstore): keep rebalance meta save lock errors retryable

The rebalance metadata retry policy only retries when the source chain still
carries the typed lock error, but resolve_rebalance_meta_save_result collapsed
every failure into a plain string. A transient rebalance.bin write-lock timeout
therefore bypassed retry_rebalance_metadata_access entirely and surfaced as a
hard failure of the periodic and terminal rebalance metadata saves.

Wrap the failure with the data-movement stage context instead, which keeps the
original lock error reachable through rebalance_error_source so the existing
transient lock policy applies. The rendered message is unchanged.

Add a regression test that feeds the wrapped meta save lock timeout through the
retry helper and asserts the retry engages.

* chore(ecstore): refresh error format ratchet baseline

Removing the string-wrapped meta save failure drops one `::other(format!`
call site in the rebalance worker, so the shrink-only baseline must be
regenerated in the same change.
2026-09-21 20:56:26 +08:00
hector bdea44f332 fix(ci): align FT evidence contract with scenario E (44 cases) (#8052)
The fault-tolerance suite now registers and runs scenario E
(rustfs/auto-testing#100): --all covers A, B, C, C2, D, E with 38 + 6
= 44 expected cases. The chain-evidence assertion still hardcoded
[A, B, C, C2, D] and 38 everywhere, which would fail every green FT
run's evidence validation once E is registered.

Bump the scenario list to include E and all count assertions from 38
to 44; step name updated to match. No other lanes reference 38.
2026-09-21 20:13:56 +08:00
cxymds 8ba3309624 fix(rebalance): retry fleet proof before start activation (#8049)
Retry the transient missing fleet capability proof in both the admin start flow and node RPC activation. Return a retryable 503 with Retry-After after rollback when readiness does not converge.
2026-09-21 11:27:58 +00:00
cxymds cd2fa50e50 fix: rename admin metrics endpoint to realtime (#8046) 2026-09-21 19:23:58 +08:00
cxymds 077b961232 fix(obs): default span events to none and gate span list by level (#8047)
JSON log layers synthesized span lifecycle events for every span and
embedded the full ancestor span list on every record. With INFO spans on
object hot paths that produced multi-megabyte lines, which journald
truncated at 48 KiB: the payload was silently dropped and the volume
itself became the dominant load.

Add RUSTFS_OBS_LOG_SPAN_EVENTS (none|close|full) with a default of none,
and keep with_span_list enabled only for verbose (debug/trace) levels so
the ancestor chain stops being re-serialized per record. The request_id
promotion path reads the span scope, not the span list, so it is
unaffected.

Refs rustfs/backlog#2642
2026-09-21 19:15:39 +08:00
cxymds e1bc731331 fix(lifecycle): reject empty expiration actions (#8045)
* fix(lifecycle): reject empty expiration actions

* style: apply rustfmt to lifecycle validation
2026-09-21 15:43:07 +08:00
cxymds bb43ef861a fix(decommission): use owned data for source capacity (#8038) 2026-09-21 15:29:30 +08:00
Chris 4047ed6c8d fix(heal): restore bucket metadata before replacement completion (#8042) 2026-09-21 12:18:06 +08:00
hector ae6bbaef27 fix(ci): give the chain staleness probe a token that can read auto-testing (#8041)
resolve_functional_candidate.py probes rustfs/auto-testing (private) to
age the pinned functional-script revision and fall back to main HEAD
after 24h. The prepare step passed github.token, which cannot see the
private repo, so every nightly chain logged

  staleness probe failed (...exit status 1.); keeping pinned revision

and replayed the 09-14 harness. On the 09-20 nightly that harness died
on the dpkg conffile prompt in all 12 lanes (see rustfs/auto-testing#97)
because the --force-confold and other fixes never reached the chain.

Use PF_TESTING_GH_TOKEN - already required by the other steps in this
workflow - so the probe can actually run and the >24h fallback works.
2026-09-21 11:08:41 +08:00
cxymds a7c875b48f fix(heal): preserve remote corruption and reject incomplete deep scans (#8032)
* fix(heal): reject truncated encoded shards

* test(heal): cover truncated shards across integrity modes (#8035)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): preserve remote corruption and reject incomplete deep scans

* test(admin): tolerate config lock timeout while polling

* fix(test): use admin storage error facade

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-21 02:33:03 +00:00
マルコメ 42dd76d3b2 ci(docker): sync the Docker Hub overview from README.md on release (#8034)
Docker Hub's overview is a separate `full_description` field that
`docker push` never touches, so it had drifted into an 8 KB snapshot of
an old README.md that still linked to
https://docs.rustfs.com/introduction.html (now 404).

Add a `sync-dockerhub-description` job to docker.yml that runs after the
images are pushed and republishes README.md from the same commit via
peter-evans/dockerhub-description (pinned to v5.0.0). It reuses the
existing DOCKERHUB_USERNAME / DOCKERHUB_TOKEN credentials, so no new
secrets are needed. Relative links (docs/, CONTRIBUTING.md) are
rewritten to github.com URLs so they resolve on Docker Hub.

Docker Hub caps the field at 25,000 bytes and the action truncates to
fit with only a warning; README.md is at 22,879 bytes today. Read the
published overview back after the sync and fail the job if it hit the
cap, so a truncated overview cannot be published silently.

Fixes #7995

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Hauser <housemecn@gmail.com>
2026-09-21 07:04:47 +08:00
Chris 9aeecad4a9 docs(security): add encrypted ETag advisory lesson (#8040) 2026-09-20 23:52:47 +08:00
Hauser 3738b32df1 chore(deps): update flake.lock (#8033)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/aff8a0b' (2026-09-10)
  → 'github:NixOS/nixpkgs/a32edd7' (2026-09-17)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/228ecef' (2026-09-12)
  → 'github:oxalica/rust-overlay/26a71e6' (2026-09-19)
2026-09-20 14:54:45 +08:00
Hauser d997b07505 fix(get): skip futile resume for single-disk objects (#8031)
* chore: pin s3s to upstream git revision

Use the upstream s3s git repository at 50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6 for both s3s and s3s-sigv4.

Allow the official s3s git source in cargo-deny so source checks continue to pass.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(get): skip futile resume for single-disk objects

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-20 13:25:08 +08:00
Chris 0632a64f91 fix(ecstore): allow server-side copy at normal read quorum (#8029) 2026-09-20 12:36:53 +08:00
hector cbf6b02905 ci(chain): fall back to auto-testing main HEAD when the pinned harness goes stale (#8026)
The functional chain pins the test harness to the revision recorded in
.config/functional-script-revision.txt, which is only refreshed by
release syncs - it went stale on Sep 16 and the nightly chain has been
replaying a Sep-17-era harness ever since. Last night that meant:
the performance lane died on a dpkg conffile prompt (fix merged as
#88 but never reached the chain), the FT lane died on 'unknown option:
--results-file' (harness/workflow version mismatch), and the
STS-105 signing fix (#94) sat untested.

When the pinned revision's commit is older than 24 hours, fall back to
auto-testing main HEAD so the chain always runs the current harness.
Any staleness-probe failure keeps the pinned revision (fail-safe).
The revision file stays in place as the audit trail and the release
sync continues to manage it.
2026-09-20 11:27:56 +08:00
hector ed2c3eaf77 fix(package): preserve service state across upgrades (#8024)
* fix(package): preserve service state across upgrades

* fix(package): match legacy DEB versions in tilde form

Published prerelease packages carry ~ in the dpkg control version
(package_versions.sh maps the SemVer prerelease - to ~), so the
legacy fallback list written with dots never matched 1.0.0~rc.x and
upgrades away from those DEBs still left the service stopped (#8011).

Fix the legacy glob to the tilde form and update the contract test,
which had enshrined the dot form. Verified on Ubuntu 24.04 systemd
containers: DEB upgrade 1.0.0~rc.5 -> 1.0.1 now keeps the service
running; 1.0.0 -> 1.0.1 and 1.0.1 -> 1.0.2 marker path still pass.

* docs(package): expand /etc/default/rustfs example template

Document the commonly used RUSTFS_* settings as commented examples in
the packaged conffile and point to docs.rustfs.com.
2026-09-20 11:27:41 +08:00
cxymds 22243e791d test(heal): cover stale current delete marker rejoin (#8021) 2026-09-19 19:33:24 +08:00
Hauser c35de00e8a fix(scanner): protect single-disk foreground latency (#8022)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-19 19:32:02 +08:00
cxymds 0e03bcda49 fix: serialize notify runtime module switch reconciliation (#8020) 2026-09-19 13:41:39 +08:00
cxymds c3269ad4c2 fix(heal): close tail-truncation and stale current-marker gaps (#8019)
* fix(heal): verify complete encoded shard tails

* fix(heal): prove stale current marker deletion
2026-09-19 13:41:30 +08:00
Chris 0fcd34c331 fix(ecstore): retry fleet capability probe while the notification system boots (#8017)
Startup finalizes IAM before init_notification_runtime publishes the notification system, and IAM finalization is what starts the fleet capability probe. The first probe pass therefore always failed closed and then slept the full ten-second probe interval. On a single node that left every durable capability, including the durable hard quota fence, unavailable for about ten seconds after /health already reported ok, so SetBucketQuota answered 503 durable quota capability is not confirmed across the cluster during that window.

Publishing the notification system now wakes the probe immediately through a Notify permit, with a 100ms bootstrap poll as the fallback for a wakeup that races the availability check. The probe keeps failing closed while the system is absent and logs the wait once. The four probe futures no longer carry an unreachable notification-system-unavailable arm, and the seven proof slots are revoked through one helper.

Fixes #8014
2026-09-19 13:41:16 +08:00
Chris ddc5f97a04 fix(release): sort pre-release packages below their final release (#8016)
Pre-release RPM versions replaced the SemVer `-` with `_`, which rpm treats as an ordinary segment separator, so `1.0.0_rc.5` compared as newer than `1.0.0` and a host with an rc RPM installed could not `dnf upgrade` to the GA package. DEB previews had the mirror-image problem: only the first `-` became `~`, so `1.0.0~rc.5-preview.2` carried `preview.2` as its Debian revision and sorted above `1.0.0~rc.5`.

Both formats now spell every pre-release separator as `~`, which dpkg and rpm (>= 4.10) both treat as "sorts before anything". Verified with rpm 4.16 (AlmaLinux 9) and dpkg 1.21: `1.0.0~rc.5 < 1.0.0`, `1.0.0~rc.5~preview.2 < 1.0.0~rc.5 < 1.0.0~rc.6`, alpha < beta < rc, and `rc.9 < rc.10`; fpm 1.18 passes `~` through into both package headers, and `dnf upgrade` from a `~rc.5` RPM to the GA RPM succeeds where the `_rc.5` one refused. The test now pins the full ordering contract under both package managers and fails against the previous spelling.

The release-checksum step already normalizes `~` to `.` for every uploaded asset, so RPM assets need no further handling. Hosts that already installed an `_rc`/`_beta` RPM need a one-time `dnf install rustfs-1.0.0` or `rpm -Uvh --oldpackage` to reach GA.

Fixes #8012
2026-09-19 03:22:44 +08:00
cxymds ff62c810b4 fix(tier): gate opaque version recovery cleanup (#8006)
* fix(tier): gate opaque version recovery cleanup

* test(tier): authorize opaque recovery fixtures

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
2026-09-18 21:54:03 +08:00
cxymds 950db7412c fix(heal): inspect stale members during deep scans (#8009)
* fix(heal): inspect stale members during deep scans

* test(heal): prove convergence outcomes
2026-09-18 21:53:31 +08:00
Chris 4e7a8fabb8 fix(ecstore): repair buckets left with only an incarnation sidecar (#8008)
The legacy bucket metadata migration writes the `.bucket-incarnation`
sidecar before `.metadata.bin`. A crash or a lost namespace lease between
the two writes left the bucket with a sidecar and no metadata, and every
later load failed closed with "bucket incarnation sidecar exists without
bucket metadata", so the bucket answered 500 to every request on every
node with no repair path (rustfs/rustfs#8003).

Load the sidecar-only state as a legacy bucket so the migration runs
again. The migration and the force-create path re-read the sidecar under
the transaction lock and keep its incarnation when persisting the
metadata, so the retry never replaces an identity other nodes fenced on.
A retired incarnation is never adopted: it is residue of a deleted
bucket, and re-publishing it would let heal reclaim the new objects.
2026-09-18 18:14:00 +08:00
cxymds b0f4e66c92 fix(tier): model R2 as unversioned storage (#8004) 2026-09-18 10:12:51 +00:00
cxymds e9da1fa074 feat: expose subscriptions used by event destinations (#8001)
* feat: expose event subscriptions by destination

* style: format event subscription handler

* fix: preserve s3 error footprint checks
2026-09-18 16:11:19 +08:00
Hauser 023adb1696 fix(heal): discharge absent durable partial writes (#8000)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 16:07:18 +08:00
Hauser 0f69c073de fix(ecstore): preserve bounded listing completion reason (#7994)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 15:41:44 +08:00
cxymds 7a5e1efa95 fix(heal): fail admin heal with unhealthy drives (#7996) 2026-09-18 12:58:24 +08:00
Chris aa7f55946c docs(operations): add cluster and erasure-coding lifecycle runbook (#7998)
Consolidate the operator entry point for planning, storage classes and
EC:0 consequences, expansion, rebalance, decommission, heal, drive
replacement, restart recovery, and the rc CLI mapping. Every claim was
checked against the current handlers, storage-class validation, quorum
arithmetic, and the rustfs/cli admin reference; the runbook links the
normative contracts instead of restating them.

Refs rustfs/backlog#2607, rustfs/backlog#2608
2026-09-18 12:50:14 +08:00
Hauser 785b44a696 fix(replication): harden delete marker HEAD convergence (#7993)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 11:05:47 +08:00
Hauser 415bd2310c build(deps): bump dial9 to 0.5.1 (#7991)
Update dial9 and the resolved companion crates to 0.5.1.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 09:53:30 +08:00
Chris 3f469ab257 fix(helm): default the ingress backend port and refresh the nix cli hashes (#7990) 2026-09-18 09:08:24 +08:00
Hauser 31f446bed1 fix(ecstore): preserve vectored bitrot writes (#7981)
* fix(ecstore): preserve vectored bitrot writes

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): route test trait through contracts

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-18 08:07:37 +08:00
Hauser d2cdf057cc build(deps): refresh workspace dependencies (#7986) 2026-09-18 06:05:34 +08:00
Hauser 717e8b9c87 fix(scanner): resume and persist checkpoints across leader handoff (#7982) 2026-09-18 06:04:35 +08:00
Chris 5886368178 ci(performance): drop stale shared-lock comment from the performance workflow (#7987)
fix(ci): align security workflow test with the performance suite's own concurrency group

#7984 moved rustfs-performance-test.yml to the rustfs-performance-suite group but left test_all_suites_hold_the_shared_lock_for_manual_and_chain_runs asserting the shared functional group, so Quick Checks (make script-tests) fails on main and on every PR. Expect the dedicated group for the performance suite and drop the workflow comment that still described the shared lock.
2026-09-18 01:26:08 +08:00
cxymds db4b8290e7 fix(notify): validate bucket notification config before saving (#7980) 2026-09-17 22:58:29 +08:00
cxymds 5829c66f21 fix(decommission): keep scanner backlog handoff conflicts recoverable (#7979) 2026-09-17 21:41:11 +08:00
hector 9c199aefd1 ci(performance): move the performance suite to its own concurrency group (#7984)
The performance suite runs on the pf-testing fleet - a completely
separate environment (own runner, own target nodes) from the
smoke-testing fleet the functional suites share. Holding
rustfs-shared-functional-tests-v2 made an in-flight performance run
block every functional dispatch (observed: kms queued pending with
zero jobs for 4+ hours behind a 4-hour performance run), and
performance benchmarks queued behind functional suites in return.

Give it its own group; nothing else changes.
2026-09-17 20:43:55 +08:00
こころの在り処 4c02ad4025 feat(helm): add ingress support for both console and endpoint (#7972) 2026-09-17 18:30:29 +08:00
GatewayJ e31a22febb fix(ci): reject incomplete fault-tolerance runs (#7961)
* fix(ci): reject incomplete fault-tolerance runs

* fix(ci): validate fault-tolerance case verdicts
2026-09-17 18:20:33 +08:00
Hauser 91a6c5bad8 feat(release): add workspace cargo publish tooling (#7973)
* test(ecstore): bind MRF manifest CAS dirsync recovery (#7482)

Cover the committed MRF manifest path through LocalDisk conditional CAS when the metadata directory fsync fails. The fixture proves the previous manifest anchor survives rollback, an unanchored first successor is removed, and the legacy MRF journal remains readable even while global durability is relaxed.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): derive evidence runner profile from registry (#7484)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require mixed-version evidence roles (#7485)

Tighten the Scanner/Heal release bundle checker so mixed-version, scoped-ACK, and rollback fields cannot reuse a generic versions list without proving the expected evidence role.

Require version lists to use source revision identities and include the tested source revision. Also require profile evidence fields to name the core profiling artifacts before release approval.

The release gate remains blocked until measured field evidence is present.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): publish committed MRF runtime checkpoints (#7490)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind profile artifacts in release evidence (#7487)

Require Scanner/Heal release bundles to attach every required profiling artifact to P1 profile evidence with relative paths, artifact formats, non-empty files, hashes, and optional per-artifact measurement-window checks.

Document the tightened release bundle profile contract and cover missing, tampered, and mismatched-window profile artifact regressions in the existing checker self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): harden measured ABBA evidence claims (#7491)

Reject measured Scanner/Heal ABBA manifests whose mixed-version evidence uses the same baseline and candidate source revision or binary hash.

Require crash fault modes and profile artifact names to match the exact supported sets, rejecting missing, duplicate, and unknown values.

Update harness fixtures and regression coverage for same-build mixed-version claims and exact-set release evidence fields.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(e2e): group scanner heal evidence payload (#7494)

Group the EC8+4 Scanner/Heal evidence writer inputs into a typed payload so the distributed e2e crate stays within the clippy argument limit without weakening the lint.

The evidence writer still validates the same S3 bodies, physical shard census, process restart PIDs, and node listings.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF replay bundle fields (#7486)

Bind Scanner/Heal release bundle evidence for MRF durable replay to replay counts, retained responsibility anchors, and successor snapshot publication evidence.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): profile EC84 evidence case runs (#7495)

Give Scanner/Heal evidence cases explicit runtime profiles so EC8+4 background restart and crash cases use their own object count, object size, and partial-progress timeout defaults instead of inheriting the legacy 4x1 case assumptions.

Expose the runtime profile in plan-only output and cover every registry case in the script self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): reject empty release evidence artifacts (#7496)

Require Scanner/Heal release bundle artifact paths to resolve to non-empty files before hashing them.

Cover empty hard-gate artifacts in the existing release bundle checker self-test and keep profile artifact size checking on the shared artifact boundary.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require G14 same-window field coverage (#7489)

Tighten the Scanner/Heal release bundle checker so G14 same-window evidence must name the EC8+4, multi-set, and multi-pool fields covered in that measurement window.

Keep the release gate blocked when same-window evidence omits one of the required G14 fields, without changing production runtime behavior.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require two-hour measured ABBA windows (#7493)

Reject measured Scanner/Heal release ABBA manifests and summaries whose evidence window is shorter than the W21 two-hour release requirement.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF idle checkpoint cleanup (#7497)

Add a runtime cleanup regression test that publishes both retained replay and runtime committed checkpoints, writes scoped and legacy journals, and verifies idle cleanup removes every recovery anchor from the registered local disks.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(e2e): ignore untracked files in build identity (#7498)

Align e2e_test build provenance with Scanner/Heal evidence receipts and server binary provenance by treating only tracked source changes as dirty.

This prevents unrelated untracked worktrees or evidence directories from causing compiled test identity mismatches before real evidence cases can run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF disk-full evidence fields (#7499)

Require the Scanner/Heal release registry and bundle checker to carry explicit G08 MRF capacity, disk-full, and replica-loss evidence fields before release approval can pass.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind release JSON artifact provenance (#7500)

Require scanner/heal release evidence JSON artifacts to repeat their measured source revision, run identity, measurement window, gate, and field identity inside the artifact payload. This keeps a refreshed outer bundle hash from accepting stale summary or profile JSON from another run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF rollback mirror filtering (#7501)

Add a regression oracle that keeps scoped-only MRF responsibilities in the authoritative runtime snapshot while omitting them from the v1 legacy rollback mirror.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require segment activation evidence (#7504)

* test(scanner): require segment activation evidence

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): cover activation proof inputs

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF crash matrix cases (#7505)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require release bundle domain evidence (#7502)

Reject scanner/heal release bundles that omit field-level domain evidence for scoped ACK, durable intent, mixed-version, scheduler pressure, profile cost, and two-hour pressure lanes.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF retention GC evidence (#7506)

Require P4 retained-responsibility release bundle evidence to list the retained replay anchor and idle cleanup cases, prove a two-hour retention window, and record both idle cleanup and verified-proof discharge observations.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF cleanup GC soak evidence (#7507)

Require the P4 release bundle to carry measured cleanup/GC soak evidence for retained MRF replay responsibilities. The bundle now needs a two-hour cleanup window, exact cleanup case coverage, observed verified idle GC, and zero pending responsibilities or stale journals after GC.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF disk-full commit anchors (#7509)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): report port allocator bind errors (#7510)

Surface the scanned port window, attempt count, and last bind error when the e2e port allocator cannot reserve a localhost port. This keeps Scanner/Heal evidence failures actionable when the environment blocks binds before business assertions run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(sse): resolve 1.0.0 SSE/KMS blockers and P1 findings (#7511)

* fix(sse): resolve bucket default encryption per request

PUT and the POST-object/extract path resolved a bucket's default
encryption with a hard-coded "no explicit SSE-C" flag, so the default was
layered onto a request that already carried an SSE-C header triple and
then tripped that request's own mutual-exclusion check. Every bucket with
default encryption refused SSE-C single PUTs with 400 InvalidArgument,
while CreateMultipartUpload on the same bucket succeeded because it
resolves SSE elsewhere. Both call sites now derive the flag from the
request headers, as COPY already did.

The bucket default's KMS key id was also inherited independently of the
effective algorithm, so an explicit AES256 request against an aws:kms
default bucket produced a self-contradictory algorithm/key-id pair and
was rejected. The key id is now inherited only when the effective
algorithm is aws:kms, matching the storage-layer resolver.

Refs backlog#2368 B1, B2.

* fix(sse): refuse SSE-KMS without a running KMS service

A write requesting aws:kms on a node with no KMS service fell back to the
node-local SSE-S3 provider: the data key was wrapped with
RUSTFS_SSE_S3_MASTER_KEY while the object metadata still recorded
aws:kms and the requested KMS key id. The stored object claimed a KMS
protection it never had, under a key that was never consulted, and no
signal distinguished it from a genuine SSE-KMS object.

The managed-encryption path now asks the resolved DEK provider whether it
wraps with a node-local master key and refuses SSE-KMS in that case:
InvalidRequest when KMS was never configured, ServiceUnavailable when a
configured service is not running. The check sits after the per-key
authorization gate so an unauthorized caller still receives AccessDenied
whatever the KMS runtime state is, and asks the provider rather than a
parallel availability signal because the provider is what actually wraps
the key. A missing master key no longer answers an SSE-KMS request with
an SSE-S3-worded configuration error.

The SSE-S3 local fallback is unchanged.

Refs backlog#2368 B4.

* fix(ecstore): restore and archive tiers in stored coordinates

Multipart restore addressed the remote tier in plaintext coordinates
while the copy-back reads the stored representation. Each part received a
misaligned slice of the remote object whose length still satisfied the
range, the hash reader and the completion size check, so the restore
reported success and silently replaced the object's bytes. Encrypted and
compressed multipart objects were both affected. Restore now accumulates
stored part sizes, passes the stored length to the hash reader alongside
the plaintext length, and validates against the stored size.

The copy-back digests stored bytes, so its computed MD5 is not the
object's public ETag. Restore now preserves the object ETag on both the
single-part and multipart paths, and gives each restored part its own
recorded part ETag rather than the object-level value.

Transition also handed the tier the object's SSE headers and its
RustFS-wrapped data key as request headers. Any S3 target rejected an
SSE-C archive outright, an SSE-KMS archive asked the target to encrypt a
second time under a key id it does not own, and the wrapped DEK left the
cluster. The archive request now strips every SSE header and encryption
marker with the predicate the replication path already uses; the local
xl.meta keeps all of it, so read-through and restore are unaffected.

Objects restored by an affected release are not detected or repaired
retroactively and must be re-restored from the tier.

Refs backlog#2368 B3, B5; backlog#2369 P7.1.

* fix(rio): lock the v1 nonce layout within a segment

Decrypting a v1 segment tried three historical nonce layouts per frame,
independently for every frame. The last of them exists for streams
written before 1.0.0-alpha.91, which reused a segment's part nonce for
every block in it; because block zero's derived nonce equals that base
nonce, a frame encrypted at index zero authenticated at any position. An
attacker able to rewrite the underlying shards could replay it and have
the forged plaintext returned with 200 and an unchanged length. Shard
integrity uses a keyed-hash-free checksum, which such an attacker can
recompute, so it is not a barrier.

A segment now locks onto whichever layout decoded its first non-zero-index
frame and rejects any later frame needing a different one. That leaves one
residual shape: a stream built purely from repeats of frame zero has no
later frame to disagree. New RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK
(default true, so pre-alpha.91 objects keep decrypting) drops the third
layout entirely when set to false, which closes it. Turning it off refuses
pre-alpha.91 objects, so migrate them first by rewriting in place.

Refs backlog#2369 P2.

* fix(kms): reload a service that failed to start

POST /rustfs/admin/v3/kms/reload short-circuited whenever the persisted
configuration matched the in-memory one byte for byte. A node whose KMS
failed to start keeps that configuration and sits in Error, so the
documented recovery call returned "reloaded successfully" while leaving
the node down. Peers reached the same path through the reload broadcast,
so a cluster that lost Vault during a rolling restart had no working
recovery route other than the node-local start endpoint. Reload now
short-circuits only for a service that is actually running, and otherwise
reconfigures, which starts a service that is not running.

The AWS backend also advertised key-version enumeration through
kms/status, which its own documentation says it cannot do; the capability
and its golden snapshot now say false.

Refs backlog#2369 P1, P7.3.

* docs: record the SSE and KMS changes for 1.0.0

The Unreleased changelog section carried no entry for any encryption work
merged since 1.0.0-rc.5, including three items with operational impact:
the config-secret variable whose absence persists secrets in cleartext
with only a warning, the v2 frame write switch and its rolling-upgrade
constraint, and per-key authorization making a public bucket incompatible
with SSE-KMS objects. Adds those plus this batch, including the SSE-KMS
refusal as a breaking change with both routes out.

Also corrects four places where documentation contradicted the code: the
cleanup register still called encrypted range seek opt-in after its
default flipped, the Helm README claimed vault_mount_path only applies to
Transit while the template also feeds the KV2 mount, the disaster-recovery
drill listed bundle contents for backends whose export is refused with
501, and the Chinese README capability table predated most of the feature
set. Documents the SSE-S3 local master key as a first-class operational
mode with its rotation dead end, and what the v1 frame layout does and
does not authenticate.

Refs backlog#2369 P5.

* fix(kms): classify data-path KMS failures by what the caller can do

Only "key not found" and a backend outage were classified; every other
KMS failure that reached the S3 data path fell through to
500 InternalError with a generic message. A disabled or pending-deletion
key, a denied KMS grant, an encryption-context mismatch, an unsupported
algorithm, a credential or timeout failure, and a capability the
configured backend does not have all looked identical to a server fault.
SDKs therefore applied exponential backoff to configuration errors no
retry can fix, and monitoring counted every one of them against the
server's own error rate.

Unusable-key and request-side failures now answer 400, a denied grant
403, transient backend failures 503, and a missing backend capability
501. Damaged, unreadable, or unknown-format key material keeps its 500:
it is a server-side integrity fault, and existing tests pin it.

The classifier is deliberately separate from the admin lifecycle
mapping, which answers 404 for a missing key because there a key id is
the resource being addressed; on the data path it arrives inside a
request header or a bucket default. Messages either name what the caller
asked for or stay generic, with deployment-side detail left on the error
source the way the storage-IO mapping already does.

Refs backlog#2368 B6.

* fix(kms): track and renew static Vault tokens

Token authentication hard-coded "this token carries no lease", so the
renewal task never started, the remaining-TTL gauge was never published,
and nothing looked wrong. `vault token create` grants a 768-hour TTL by
default, so a cluster that had been healthy for a month turned every KMS
call into a 403 and could not recover without a restart or a
reconfigure. Production configuration validation only rejects the
literal dev-token, so an ordinary expiring token reaches a whole cluster.

The source now reads `auth/token/lookup-self` at login and adopts what
Vault reports. A token with no expiry behaves exactly as before. An
expiring renewable one is picked up by the existing renewal loop and
renewed at half TTL like every other auth method. An expiring
non-renewable one warns with its remaining lifetime and publishes the
gauge, so the fail-closed window is visible before it arrives.

The probe never fails the login: a policy that omits lookup-self, or a
Vault that is briefly unreachable, warns and falls back to exactly the
previous behaviour rather than taking down a deployment that works
today. The scripted Vault test double answers the lookup out of band so
existing scripts keep describing only the protocol under test.

Refs backlog#2369 P3.

* feat(sse): report SSE-C requests that arrive without TLS

An SSE-C request carries the customer's AES key in a request header, so
AWS S3 and MinIO both refuse one that did not arrive over TLS. RustFS
accepted them on any transport: a plaintext hop hands the key to anyone
on the path, and since the object cannot be read without that same key,
the exposure lasts as long as the object does.

Refusing outright is the correct end state but not a safe default to
adopt inside a release window, because the project's own s3-tests and
e2e lanes and most staging deployments speak plain HTTP. This release
reports instead: each such request increments
rustfs_ssec_plaintext_requests_total and logs one warning per process, so
an operator can confirm nothing would break before the default flips.
RUSTFS_SSE_C_REQUIRE_TLS=true opts into the AWS 400 now.

The verdict is per connection rather than per deployment: the layer is
built with whether this listener terminated TLS, and additionally accepts
an https protocol forwarded by a proxy the trusted-proxy configuration
already vetted. It sits beside the rate limiter, after the layer that
makes a forwarded protocol trustworthy and after the request context, so
a rejection can echo the request id.

Refs backlog#2369 P7.2.

* fix(kms): say what a node-local backend means for a cluster

The Local backend keeps key material on each node's own disk and
generates its Argon2id salt per node, so two nodes derive different keys
from the same master_key and an object encrypted on one node cannot be
decrypted on another. Behind a load balancer that surfaces as
intermittent 500s on reads that succeeded moments earlier, with nothing
tying the symptom to the cause: the only signal was a generic
"development, testing and demos only" positioning warning that says
nothing about what actually breaks.

Configuring or reconfiguring Local while the deployment is distributed
now logs a dedicated event and appends the consequence to the configure
response, so the operator who made the change sees it. The product
decision to warn rather than refuse is unchanged.

Refs backlog#2369 P7.4.

* docs: record the remaining SSE and KMS changes for 1.0.0

Adds changelog entries for the KMS data-path status classification, the
Vault static-token lease probe, the SSE-C plaintext-transport report and
its switch, and the node-local backend warning.

Documents two things the backend security guide never stated: that SSE-C
belongs on a secure transport, with the counter and switch to plan the
change around, and that the Local backend cannot be shared by a
multi-node deployment because each node derives different keys from the
same master key.

Refs backlog#2368 B6; backlog#2369 P3, P5, P7.2, P7.4.

* fix(kms): report an unreadable key store as an outage on the S3 path

A backend now distinguishes a key store it could not read from a key
that is genuinely absent, but the S3 boundary collapsed the first one
back onto 500 InternalError through the fallthrough for integrity
faults. The distinction was therefore invisible to the client: a
temporary key-directory outage looked exactly like a permanently damaged
key record, and neither the status nor the metric said the request was
worth retrying.

An unreadable key store joins the retryable class and answers 503, next
to a backend error and a credential failure. Damaged, unreadable or
unknown-format key material keeps its 500.

Refs backlog#2368 B6; builds on rustfs/rustfs#7470.

* test(storage): cover MRF cleanup delete ENOSPC anchor (#7513)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require heal retry stats evidence (#7514)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add release bundle dry-run fixture (#7515)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): confirm recovery intent accept readback (#7516)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF cleanup delete ENOSPC evidence (#7518)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require bounded retry window evidence (#7517)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): emit scanner heal G09 upgrade evidence (#7519)

Record measured Scanner/Heal G09 evidence artifacts from the upgrade compatibility lanes when a fresh evidence directory is provided.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): persist MRF rollback mirror boundary (#7520)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add G09 upgrade evidence runner (#7522)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(scanner): add G09 upgrade evidence runner

Add a reusable Linux x86_64 runner for the Scanner/Heal G09 mixed-version and rollback upgrade evidence lanes.

The helper reads the pinned previous-release asset metadata from the upgrade workflow, verifies the downloaded binary, builds the current head, runs both ignored E2E tests, and fails unless the expected G09 JSON artifacts exist.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): implement V2 evidence preflights (#7523)

* feat(scanner): wire dirty usage producer identities

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add segment activation preflight proof

Keep scanner segment reuse behind a structured activation preflight so release evidence can prove the production gate remains disabled until every producer, generation, overflow, cold-oracle, and distributed invalidation check is satisfied.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): expose distributed invalidation evidence

Record an explicit distributed segment invalidation evidence summary when remote dirty usage snapshots are bound to the current activity window and the authenticated scoped ACK capability probe succeeds.

Reject peer dirty usage snapshots that contradict the peer activity pending bit so scoped ACKs fail closed instead of clearing an unadvertised remote mutation.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): assemble release evidence bundles (#7526)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add G09 upgrade evidence runner (#7524)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(scanner): add G09 upgrade evidence runner

Add a reusable Scanner/Heal G09 runner for Linux PR-head validation. The script downloads the pinned previous release, builds the current checkout, runs the mixed-version and rollback upgrade E2E lanes, and validates the measured raw artifacts before they can be consumed by the release bundle gate.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): replay recovery intents while disabled (#7521)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): preflight G09 evidence disk space (#7527)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* fix(targets): reject trailing batch items (#7508)

* test(scanner): preflight G09 evidence disk space

Fail the Scanner/Heal G09 upgrade evidence runner before downloading or building when the validation host does not have enough free space for a full raw evidence pass.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): respect cargo target dir in G09 runner (#7528)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* fix(targets): reject trailing batch items (#7508)

* fix(scanner): respect cargo target dir in G09 runner

Write the RustFS feature stamp under the resolved Cargo target directory so remote validation hosts with CARGO_TARGET_DIR set can reuse the built PR-head binary.

Extend the runner plan/self-test path to cover relative target-dir resolution.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): add G09 upgrade evidence runner (#7525)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(e2e): add G09 upgrade evidence runner

Add a Linux x86_64 runner that downloads the pinned previous release, builds the current RustFS binary, runs the mixed-version and rollback upgrade compatibility lanes, and verifies the required Scanner/Heal G09 raw evidence artifacts.

Document the runner and add a shell self-test for help, dry-run, SHA validation, and non-empty artifact directory guards.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): emit G09 release bundle gate evidence (#7530)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* fix(targets): reject trailing batch items (#7508)

* test(scanner): emit G09 release bundle gate evidence

Write a bundle-ready G09 gate descriptor from the Linux upgrade evidence runner and validate the single G09 gate with the shared release-bundle rules without approving the full Scanner/Heal release.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): reject invalid G09 runner test selection (#7531)

Validate the selected G09 evidence lane before process-substitution case expansion so --plan-only cannot turn an unknown --test value into an empty successful plan.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): keep G09 source binary path clean (#7532)

Keep checksum verification output out of the command substitution that resolves the previous-release binary for the G09 runner.

Also tolerate non-GNU sha256sum in local self-tests by falling back to shasum when GNU --check support is unavailable.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF replay proof cleanup (#7534)

Exercise the committed MRF replay checkpoint path with a real ECStore-backed bucket incarnation, retain the replay anchor until an exact verified proof arrives, then delete the proof-discharged checkpoint and prove a subsequent restart does not resurrect the replayed work.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): carry segment activation preflight evidence (#7535)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): surface cold segment reuse oracle (#7536)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require hard evidence artifact payloads (#7537)

Require Scanner/Heal release bundle JSON artifacts to carry hard-domain evidence fields for mixed-version, crash, capacity, disk-full, replica-loss, and MRF cleanup gates. This prevents a descriptor from approving a hard gate while pointing at a generic measured artifact that lacks the boundary-specific oracle fields.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): bind producer evidence to dirty generations (#7538)

Record scanner segment producer identities with the dirty usage generation they invalidated, then expose a cycle-local producer evidence snapshot for segment reuse activation preflight.

Production activation remains fail-closed until durable producer identity and restart-gap proof are available.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): persist segment invalidation proof metadata (#7539)

* test(scanner): wait for EC8+4 heal control readiness (#7540)

* test(scanner): list full P4 release evidence fields (#7542)

Align the Scanner/Heal release requirement registry with the release bundle gate for P4 so the closure checklist advertises MRF scale, replay cost, retained responsibility, and cleanup/GC soak evidence together.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add W16 release evidence runner (#7543)

Add a W16 Scanner/Heal evidence runner that executes the recovery-intent crash-boundary and quota-authority lanes, emits measured G04/G12 JSON artifacts, and validates the resulting single-gate release descriptors.

Wire its shell self-test into script-tests and document the release evidence entry point.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix: address release acceptance regressions (#7541)

* feat(scanner): admit durable segment proof replay (#7544)

* feat(scanner): admit durable segment proof evidence

Validate complete set snapshot segment invalidation proof metadata against the current dirty usage generation window and scanner process epoch before clearing durable producer and restart-gap activation blockers.

Production segment reuse remains gated by the explicit activation flag.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): replay segment proof from root snapshots

Carry segment invalidation proof metadata into root snapshot set states so a complete published baseline can replay the durable producer evidence recorded by each set cache.

Keep the field additive for older readers and leave incomplete or LKG set states unproven.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind MRF release artifacts to evidence kinds (#7545)

Require Scanner/Heal G07/G08/P4 JSON artifacts to declare the exact MRF evidence kind expected for each release-bundle field. This keeps generic measured JSON from satisfying durable replay, ENOSPC, replica-loss, scale, replay-cost, retained-responsibility, or cleanup-GC soak gates.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): enable segment reuse activation gate (#7546)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): run scoped fallback cases on dedicated stack (#7550)

Linux libtest defaults can abort these deep EC-store futures before the assertions run. Wrap the async scoped fallback cases in the repository's dedicated-stack test pattern so the default cargo test path exercises the oracle directly.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): roll out distributed e2e selection (#7549)

Refresh the committed e2e-distributed selection digest after the EC8+4 Scanner/Heal evidence case joined the profile, and document the registry-aware runner entry for the distributed case.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add W13 MRF evidence runner (#7547)

Add a W13 durable MRF evidence runner that emits measured G07, G08, and P4 JSON artifacts and release descriptors through the existing scanner/heal bundle gate.

The runner now executes the ignored MRF replay evidence test with an exact full test path, validates raw artifact kinds and gate decisions, prepares Linux tmpfs-backed ENOSPC roots for G08, and documents the Linux/long-soak boundaries.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): fix segment proof fixtures

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): reduce W13 evidence helper arguments

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* ci(test): enable local KMS for s3 compatibility tests (#7553)

Configure the s3-tests harness with a local KMS key so SSE-KMS cases run in CI without relying on an external KMS service.

Also move the anonymous POST default SSE-KMS regression onto the shared local KMS test environment.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): bind segment proof to typed dirty suffix (#7564)

Track producer identity completeness per pending bucket and retain process coverage across durable acknowledgements. Admit a prior same-process segment proof only for an exact or strictly earlier fully typed generation window.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): validate scoped ACK clear receipts (#7563)

Fail closed when a signed scoped ACK response claims more cleared entries than its request contained, while preserving partial-clear semantics and zero-mutation capability probes.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): bind cold scoped cache reuse (#7565)

Record an explicit cold bucket reuse proof for scoped set scans and treat fully cold sets as valid cold-zero-walk reuse when the baseline and bucket incarnations are bound.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(s3): preserve delete marker read error headers (#7558)

* chore(release): sync configurable nightly build workflow from main (#7567)

feat(nightly): make the build branch configurable (NIGHTLY_BRANCH var + dispatch input) (#7557)

* docs(obs): correct metric export interval and explain alert timing (#7571)

* fix(heal): persist scoped admin admission handoff (#7569)

* fix(heal): persist root admission handoff

Persist administrator root heal ownership before queue publication so crash restart can replay an admitted control-plane start without relying on process-local replay cache state.

Protect queued durable root owners from priority displacement; callers must use forceStart cancellation semantics to replace them.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): persist admin heal handoff types

Extend durable admin heal admission handoff beyond cluster roots while preserving legacy root recovery records. Persist the heal type in schema v2, replay typed admin requests after restart, keep non-admin requests out of the durable control-plane handoff, and allow path cancellation to clear durable-only non-root records.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): preserve disjoint durable admin owners

Limit admin forceStart durable-owner cleanup to overlapping heal requests after the admin recovery journal begins storing non-root work. Add fail-closed and displacement tests so durable bucket owners cannot be silently dropped by unrelated replacement or priority pressure.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): persist admin terminal receipts

Persist terminal receipts for durable administrator heal starts before retiring their pending intent so restart can answer completed or cancelled scoped tasks without replaying them.

Retain pending work when terminal publication fails and keep timeout owners on checkpoint-only replay semantics.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): mark committed replication and tier mutations (#7568)

* fix(scanner): mark committed replication and tier mutations

Bridge ECStore committed replication status writebacks and lifecycle tier expiration cleanup into scanner dirty-usage producer identity tracking. Only successful replication metadata updates mark Replication; validate-only, superseded, and retry paths remain unmarked.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): replay durable dirty producer records

Add a strict scanner dirty-usage producer replay schema that can restore typed producer coverage after restart without treating ordinary process-local mutations as durable evidence. Replay records validate schema, cache key format, generations, scopes, producer identities, duplicate buckets, byte limits, and entry limits before modifying dirty state.

Keep segment activation fail-closed unless the restored bucket state came from durable replay; subsequent local mutations clear the durable authority bit for that bucket. Also fix the release scanner clippy lint in scoped cold-reuse proof creation.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): recover expansion buckets and resume retirement (#7574)

* fix(ecstore): recover partially created expansion bucket volumes

* fix(ecstore): resume retirement after startup safety checks

* fix(ci): align S3 KMS fixtures and default key expectations (#7573)

* fix(heal): prune retained root terminal receipts (#7576)

Bound durable root-heal terminal receipt retention to the completed-status TTL while keeping expired receipts authoritative enough to retire stale pending intents before deleting the receipt itself.

Add a budgeted background GC pass and fail closed on corrupt or non-terminal receipt records.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(storage): preserve retryable publication guard errors (#7561)

* fix(storage): preserve retryable publication guard errors

* fix(scanner): satisfy scoped cache clippy lint

Use bool::then_some for the scoped cold bucket reuse proof construction after merging the latest release branch.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(health): report storage quorum in node readiness (#7566)

* fix: preserve scanner backlog during pool retirement (#7555)

* fix: hand off native scanner backlog before pool retirement

* test: initialize optional proof in data usage fixtures

* perf: bound parallel V3 pool metadata phase writes

* test: keep native backlog fault errors typed

* test: skip unchanged pool metadata in crash barriers

---------

Co-authored-by: cxymds <cxymds@gmail.com>

* test(scanner): bind profile artifact provenance (#7577)

* fix(scanner): persist dirty producer journal (#7580)

Persist scanner dirty producer replay records from committed replication and tier mutations, hydrate journal state on restart, and clear durable records only after acknowledged dirty generations.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test: add scanner heal scheduler pressure evidence (#7584)

Add measured G10/P1/P3 scheduler-pressure release descriptor generation, ABBA scheduler/profile provenance, and focused self-tests for the release bundle gate.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test: add scanner heal G14 multiset evidence (#7583)

Add EC8+4 multi-set and multi-pool scanner/heal release evidence coverage, including e2e registry cases, oracle checks, and a G14 descriptor assembler for measured case artifacts.

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(nightly): push assets with plain git on release — the build fleet has no gh CLI (#7586)

fix(nightly): push assets with plain git — the build fleet has no gh CLI

The sm-standard-4 runners used by the nightly build have no gh binary
(functional-chain workflows run elsewhere, on the jumpbox). The assets
publish step died on 'gh: command not found' at the credential-helper
setup, and the preceding clone failure had been masked by 2>/dev/null,
misleading the step into the orphan path. Swap clone and remote setup
to plain git with the token embedded in the URL; push semantics are
unchanged.

* fix(scanner): stabilize retired backlog survivor commits (#7585)

Treat an authoritative scanner pause-backlog commit as stable when every member in that commit stores the matching stable and committed record. Retired source replicas can remain missing in the observed topology while survivor membership is already stable.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(metrics): prevent duplicate drive reporting across nodes (#7582)

Co-authored-by: houseme <housemecn@gmail.com>

* fix(ecstore): avoid Windows batch-delete cleanup sharing conflicts (#7579)

Co-authored-by: houseme <housemecn@gmail.com>

* feat(ilm): inspect legacy transition version state (#7581)

* test(scanner): add release evidence ingestion gates (#7588)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix: reuse admin error boundary for legacy tier reconciliation (#7587)

* fix(test): stabilize release CI heal fixtures (#7589)

Keep heal admission-only tests off durable root recovery so mock storage does not require local metadata disks. Align forceStart retry assertions with replacement semantics and fix the e2e metric clippy collapse.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(nightly): publish packages as assets of the rolling 'nightly' release (#7592)

Replace the assets-branch scheme with a proper GitHub Release on
rustfs/auto-testing: a single 'nightly' release whose deb/rpm assets
are replaced in place on every build. This is the standard channel —
visible on the repo's Releases page, stable download URLs, no git
history growth (release assets live outside the repository).

- New scripts/release/publish_nightly_assets.sh: resolves-or-creates
  the 'nightly' release via the REST API, deletes same-name assets,
  uploads rustfs-nightly-latest.{deb,rpm}, then PATCHes the release
  body with the build provenance (ref@sha, run link, sizes, SHA256).
  Plain curl + python3, no gh CLI (the build fleet has none — #7586).
- The workflow step shrinks to invoking the script; full flow
  exercised end-to-end against the real release with probe files
  (create / upload / overwrite / download round-trip / body update).

* test(scanner): add release bundle evidence descriptors (#7595)

* test(scanner): merge release evidence descriptors

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add authority and legacy release descriptors

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add scoped ack release descriptor

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require all release evidence fields

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add mrf release descriptor producer

Add a standalone W13 MRF descriptor producer for measured G07/G08/P4 raw artifacts, with fail-closed self-tests for missing responsibility cases, disk-full ENOSPC observations, synthetic artifacts, and short P4 cleanup soaks.

Also bind G12 quota path evidence to explicit case lists so W16 descriptors cannot pass without reset and settlement coverage.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): reuse mrf release descriptor producer

Route the W13 runner through the shared MRF descriptor producer so measured runs and offline re-packaging use the same fail-closed validation.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(ilm): backfill proven legacy transition state (#7591)

* feat(ilm): backfill proven legacy transition state

* fix(ilm): remove redundant reconcile test clone

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(ecstore): remove redundant transition clone (#7597)

Drop an unnecessary clone in the legacy transition state digest regression so release CI clippy lanes can run with warnings denied.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): seed current backlog members before committing (#7594)

* fix(scanner): seed current backlog members before committing

* test(app): run recursive force delete test on large stack

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind raw profile artifacts (#7598)

Require Scanner/Heal release profile wrappers to carry bundled raw profile artifacts plus the matching profile cost metrics.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): surface first failed object in status (#7599)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover retry budget across attempts (#7600)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): harden durable producer evidence gate (#7601)

Require Scanner/Heal G11 maintenance evidence to prove durable dirty producer journal replay, replay-state hydration, generation-bound ACK clearing, and invalid journal fail-closed cases before segment reuse can be activated.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): harden scoped ack evidence proof (#7602)

Require Scanner/Heal G03 scoped ACK evidence fields to carry their own measured provenance and concrete ACK, capability, and mixed-peer observations before release-bundle gate verification.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind status outcome raw provenance (#7603)

Require measured G05/G06/R-D raw status, compatibility, and disposition artifacts to share run identity before producing release bundle descriptors.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): retry fully recoverable batch failures (#7604)

Allow recursive bucket and cluster heal attempts whose retained batch failure is entirely retryable to use the existing bounded manager retry budget. Permanent or mixed batch failures remain terminal so heal outcome semantics stay strict.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): avoid movement debt for stale remote lease (#7605)

Classify a final remote scanner publication lease validation failure from a fresh activity snapshot instead of treating every validation error as data movement. If the peer session and movement generation still match the granted leases and publication is otherwise allowed, keep the publication rejected as an activity-baseline miss without creating pause backlog movement debt. Preserve DataMovement for expired leases, peer restarts, movement generation changes, and active publication blocks.

Tests cover namespace-only validation invalidation and the remote fence-loss cases that must still defer as DataMovement.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh workspace dependencies (#7606)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind G14 case artifacts (#7611)

Require Scanner/Heal G14 release bundle JSON wrappers to mirror their outer evidence and carry self-contained case artifact provenance.

Copy proof-json case artifacts into the generated G14 descriptor bundle so assembled release bundles can validate case file hashes after relocation.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): retry contended decommission cancel target locks (#7612)

* test: align transition fixtures and KMS context expectations (#7610)

* test(scanner): stabilize W13 release gate evidence (#7613)

* feat(nightly): publish packages as assets of the rolling 'nightly' release (sync from release) (#7593)

feat(nightly): publish packages as assets of the rolling 'nightly' release (#7592)

Replace the assets-branch scheme with a proper GitHub Release on
rustfs/auto-testing: a single 'nightly' release whose deb/rpm assets
are replaced in place on every build. This is the standard channel —
visible on the repo's Releases page, stable download URLs, no git
history growth (release assets live outside the repository).

- New scripts/release/publish_nightly_assets.sh: resolves-or-creates
  the 'nightly' release via the REST API, deletes same-name assets,
  uploads rustfs-nightly-latest.{deb,rpm}, then PATCHes the release
  body with the build provenance (ref@sha, run link, sizes, SHA256).
  Plain curl + python3, no gh CLI (the build fleet has none — #7586).
- The workflow step shrinks to invoking the script; full flow
  exercised end-to-end against the real release with probe files
  (create / upload / overwrite / download round-trip / body update).

* test(scanner): stabilize W13 release gate evidence

Treat only real raw-entry windows as replayed scanner enumeration in the restart diagnostic, so final completion rounds without raw entries are not fail-closed as raw replays.

Allow the EC8:4 multi-pool heal evidence case to select an outage object key that routes to an online pool while an entire target pool is down, preserving strict behavior for single-pool cases.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: hector <42570491+majinghe@users.noreply.github.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): bound native backlog fixture resources (#7609)

* fix(scanner): bound native backlog fixture resources

Keep native scanner backlog restart fixtures within low file descriptor limits by reducing the native-only disk layout, making fixture shutdown drain background work, and avoiding long-lived ECStore retention from background loops.

Convert ECStore-backed background refresh/recovery/monitor tasks to upgrade weak owners only while doing work so completed test stores release their disk graph before the next native fixture starts.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(tier): refresh release-merged failure fixtures

Reuse the raw legacy-transition fixture helper so delete-all expiry actually exercises Unknown transitioned history metadata after the release merge.

Rewrite restore-failure disk fixtures through per-disk xl.meta snapshots so stale destination identities can be persisted without tripping ordinary metadata update guards.

Update the SSE KMS mismatch expectation to the InvalidRequest/context_mismatch behavior now returned by the rio-v2 path.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ci): align release E2E gate and repair regression tests (#7607)

* fix(scanner): harden heal release gates (#7614)

* test(scanner): reject synthetic heal evidence inputs (#7617)

Harden the Scanner/Heal checkpoint restart and G14 EC evidence producers so release descriptors cannot be assembled from marked fixture, dry-run, or synthetic case inputs.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): collect status outcome raw evidence (#7618)

Normalize live Scanner/Heal status/outcome observations into the measured raw artifacts consumed by the G05/G06/R-D release descriptor producer.

Reject synthetic observations, incomplete required cases, and reused run/window identities before raw artifacts can enter the release bundle flow.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): bind scoped ack confirmation to generation (#7619)

Require lost scoped dirty-usage ACK reconciliation to observe a clean peer activity generation that covers the requested ACK generation. Apply the same guard in the scanner aggregation path so a stale clean activity snapshot cannot discharge pending maintenance after an uncertain ACK response.

Refs rustfs/backlog#2427

Refs rustfs/backlog#2281

Refs rustfs/backlog#2240

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test: report tier reference proof setup failures without hanging (#7608)

* test(scanner): plan Linux evidence validation (#7620)

Add a Scanner/Heal Linux release-evidence planner that emits a machine-readable execution manifest for the remaining measured validation lanes.

The planner can run only lightweight preflight checks and keeps plan-only output distinct from measured release evidence.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): select G14 replacement drive by evidence (#7622)

Choose the replacement disk from the target node by the presence of complete pool metadata instead of assuming the first configured drive is the scanner metadata holder. This keeps the G14 multi-set harness aligned with multi-drive EC layouts and adds clearer diagnostics when multi-pool outage writes fail closed.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): report Linux evidence plan status (#7624)

Add a status mode for the Scanner/Heal Linux evidence planner so operators can identify missing or incomplete artifacts before final bundle assembly.

The status output stays plan-only and never reports release approval.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add G14 multi-pool background heal evidence (#7628)

test(scanner): align G14 multi-pool outage evidence

Treat the localhost multi-pool topology as whole-pool loss when the target node is down. If that topology cannot admit the outage object while the pool is offline, defer that object write until the pool rejoins and record the oracle marker instead of failing before the real crash/restart evidence runs.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): verify deferred multipool outage evidence (#7629)

Validate the G14 multi-pool oracle field that records down-window outage PUT refusal and requires the deferred post-rejoin outage object to be accepted and checked through S3.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): allow metadata snapshots at read quorum (#7627)

fix(ecstore): admit metadata snapshots at read quorum

Allow guarded bucket metadata snapshot existence checks to use read quorum so degraded erasure sets can continue Object Lock snapshot reads without weakening bucket mutation or object write quorum.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh release dependencies (#7632)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): use pool lock quorum for set writes (#7630)

Use the pool-wide namespace lock client domain for every set in a pool so degraded EC8+4 multi-set writes are gated by node-level lock quorum instead of the narrower per-set endpoint host slice.

Keep namespace-lock domain deduplication tied to both the pool namespace and shared clients, and add regression coverage for three-locker degraded writes plus cross-pool domain separation.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): keep G14 multi-set restart graceful (#7633)

test(e2e): keep multi-set restart graceful

Include the EC8+4 multi-set restart scenario in the graceful interruption lane so the unclean-shutdown marker assertion matches the scenario semantics.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): record deferred G14 outage writes (#7637)

Include optional outage-write diagnostics in G14 evidence oracles so deferred multi-pool outage writes bind their down-window refusals and post-rejoin acceptance.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bound G14 multi-pool evidence size (#7640)

Keep the multi-pool evidence runner within the registry object budget while retaining deferred outage-write diagnostics for release-gate validation.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): find nextest junit fallback for evidence cases (#7643)

Handle cargo-nextest writing JUnit reports under the workspace target directory even when the Rust build uses CARGO_TARGET_DIR. This keeps measured Scanner/Heal evidence cases from passing the real test but failing final receipt packaging.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ilm): preserve cleanup ownership on tiered overwrites (#7639)

* fix(ilm): preserve cleanup ownership on tiered overwrites

* fix(ci): refresh E2E selection for tier overwrite regression

* test(ilm): restore noncurrent compensation tests to serial CI (#7645)

* fix(heal): preserve null-version tags and encoded object paths (#7644)

* fix(e2e): record EC8+4 drive restart set size (#7648)

* fix(e2e): record EC8+4 drive restart set size

Include the erasure set drive count in the distributed EC8+4 drive restart oracle so Scanner/Heal release evidence matches the registry contract.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(s3): tighten s3s footprint ratchet

Replace release-merge s3_error! macro calls with equivalent S3Error constructors so the s3gate migration ratchet does not grow on the PR merge tree.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): target multi-set outage heal candidate (#7653)

* test(e2e): target multi-set outage heal candidate

Require the outage write used by EC8+4 multi-set root-heal evidence to miss the same erasure index owned by the selected replacement drive. This avoids accepting a candidate from a different set and turning a valid heal into a false negative.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(e2e): satisfy G14 heal lint gates

Remove clippy-only noise from the G14 multi-set heal evidence test and align the admin route policy inventory with the registered heal catch-all route.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* update

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): reuse API error boundary for decoding failures (#7647)

* fix(heal): reuse API error boundary for decoding failures

* Update heal.rs

Signed-off-by: houseme <housemecn@gmail.com>

* test(ecstore): synchronize batch cleanup metadata reads

Acquire object read locks while observing unversioned and explicit-version batch cleanup, then release them before waiting for progress. This prevents snapshots from spanning per-disk marker removal while retaining the existing quorum, timeout, and remote delete count assertions.

Validation: cargo fmt --all --check and git diff --check passed. The focused nextest test passed 20 stress iterations each with test-util and test-util,rio-v2, with retries disabled.

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: houseme <housemecn@gmail.com>

* fix(scanner): wait for ABBA telemetry window (#7660)

Size the scanner collector wait budget from the requested sample count and interval instead of using a fixed 120 second timeout. This preserves the full duration/60+1 telemetry sample set for measured ABBA runs.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(ci): add fault-tolerance degradation suite to the functional chain

Adds a fault-tolerance suite that verifies read/write behavior under
drive and node loss against the erasure-coding contract and snapshots
health-endpoint responses at every degradation tier. Scenarios are
derived from product source (default_parity_count, erasure set sizing):

- A: single-node 4 drives (EC:2, read quorum 2): hide 1/2/3 drives
- B: multi-node 4x1 (one set of 4, EC:2): stop 1/2/3 nodes
- C: multi-node 4x4 (one set of 16, EC:4, read quorum 12): 1 node down
  lands exactly on the read-quorum boundary; 2 nodes down breaks it
- C2: multi-node 4x4 with RUSTFS_STORAGE_CLASS_STANDARD=EC:8 (read
  quorum 8, write quorum 9, lock majority 9): 2 nodes down puts reads
  inside the reported divergence window (read quorum met while the
  lock majority is broken)

By default a reads-refused-despite-met-read-quorum observation is
reported as known-divergence without failing the suite; the strict
input escalates it. Chain order becomes:
upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
replication -> fault-tolerance -> performance.

Covers the findings of the 2026-09 external degradation report
(reads at read quorum, health-endpoint readiness truthfulness,
degradedReasons capture) as automated regression probes.

* Revert "feat(ci): add fault-tolerance degradation suite to the functional chain" (#7666)

This reverts commit 6eb200022e.

* fix(ecstore): preserve multi-pool version histories (#7658)

* fix(ecstore): preserve multi-pool version histories

* fix(ecstore): satisfy clippy in rebalance test

* fix(ecstore): preserve decommission delete compatibility

* fix(heal): recover stale peer state after restart (#7665)

* fix(heal): select writable recovery intent owner (#7668)

* feat(ci): add fault-tolerance degradation suite to the functional chain (#7669)

* fix(get): reject pre-header read quorum failures (#7670)

* fix(get): reject pre-header read quorum failures

* fix(ci): unblock get pre-header quorum checks

* ci: stop defaulting suite dispatches to a pinned release (#7681)

The rustfs_version dispatch input defaulted to 1.0.0-rc.4-preview.1,
which shadowed the nightly fallback and started 404ing once that
release was deleted. Manual dispatches with no inputs now fall through
to the nightly package (same contract the pool suite already has);
passing rustfs_version or package_url still pins the build exactly as
before. Chain (repository_dispatch) runs are unaffected: the inputs
context is empty there, so they always used the nightly fallback.

* ci: manage backlog issues by signal instead of per-run filing (#7680)

* ci: manage backlog issues by signal instead of per-run filing

The suite workflows used to file one backlog issue per failed run
(dedup was by run ID, which never matched), so issues accumulated
without bound. Replace the inline filing step in every suite workflow
(s3, kms, tier, storage, heal, pool, security, replication, upgrade,
performance) with a single call to
auto-testing/scripts/issue_manager.py, which:

- dedups by signal: failing cases are searched among open issues by
  label (suite category + case ID); covered cases become a coalesced
  comment on the existing issue, only uncovered cases file a new one
- labels new issues with functional-test, the suite category, one
  label per failing case ID (lazily created), and env for
  bootstrap-class failures (no cases ran, wholesale failure, or
  404/ssh/clone/dpkg signatures in the log)
- closes open issues of the suite after a fully green run, citing the
  run as evidence; cancelled runs never file or close anything

The step is skipped cleanly when auto-testing (private checkout) does
not contain the manager, or when PF_TESTING_GH_TOKEN is unset.

* fix(ci): satisfy actionlint and workflow contract tests for the manager step

- heal and performance workflows have no rustfs_version dispatch input;
  referencing `${{ inputs.rustfs_version }}` in the manager step failed
  actionlint's expression type check. Their package source now resolves
  from package_url with the nightly fallback.
- scripts/test_security_workflow.py pinned the removed inline filing
  step. The wiring assertions now pin the manager step (manager path +
  per-suite report argument), and the evidence/stale-file tests assert
  the skip contract instead: without the private auto-testing checkout
  present, the step exits 0, publishes nothing, and leaves stale
  evidence untouched.

Verified locally: actionlint clean, shellcheck clean,
test_security_workflow.py 21/21.

* fix(storage): alias legacy meta bucket over internode rpc (#7685)

* fix(storage): alias legacy meta bucket over internode rpc

Retry read-only internode RPC metadata access from legacy .minio.sys to .rustfs.sys when mixed-version peers report missing metadata during rolling upgrades.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(ecstore): target durable ILM receipt quorum fixture

Use the actual durable ILM receipt object path when taking target disks offline so the test exercises receipt write quorum instead of whichever set owns the source record path.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(ecstore): stabilize cancelled rename test (#7684)

* test(scanner): report Linux descriptor drift ledger (#7692)

Add a read-only descriptor ledger mode to the Scanner/Heal Linux evidence planner so release operators can separate current-head measured descriptors from old-head measured artifacts and case-level inputs before final bundle assembly.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): retry recovery readback SlowDownRead (#7695)

Allow the Scanner/Heal interruption oracle to retry transient retryable GET failures after replacement recovery has converged. The readback still verifies exact object bytes and keeps a bounded timeout, so permanently unreadable objects continue to fail the case.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(pool): default the node-endpoints fallback to the 4-node fleet (#7696)

RUSTFS_POOL_NODE_ENDPOINTS has no secret/var configured, so the suite
ran with the workflow's inline 3-endpoint fallback and the explicit
--node-endpoints flag overrode the script default fixed in
rustfs/auto-testing#61 — every dispatch died at startup with 'must
provide at least 4 direct node endpoints' (run 34677538650). Add
rustfs-node4 to the fallback; explicit secret/var still wins.

* fix(scanner): require ABBA collector credentials (#7700)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): label G14 recovery read failures (#7701)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): preserve settings in token status (#7691)

* fix(heal): preserve settings in token status

* fix(heal): group status response context

* fix(heal): decode partial status settings

* ci: green runs for case failures; red only for harness breakdowns (#7698)

A failing product case used to turn the whole workflow red, so the run
conclusion carried no signal beyond 'something failed' and the report
was suppressed. New semantics across the functional suites:

- Suite steps run with continue-on-error: the outcome is still recorded
  for the report and the backlog issue manager (security/tier already
  carried the flag).
- Generate report always publishes the full per-case table plus a
  'Product result: N passed, M failed' summary, and its exit gate is
  harness health: red only when the suite never reached case level (no
  case verdicts), failed wholesale (zero passes, >=3 failures), or was
  cancelled/skipped. performance is unchanged (parked).
- tier's structured gate no longer fails on case failures; it keeps red
  for evidence-init and missing-gate-result breakdowns.
- pool/performance keep their existing red sources (install/benchmark).

Workflow contract tests updated to the new exit semantics: the security
report matrix keys green off the suite outcome, the evidence matrix
expects green for failure outcomes with recorded case rows (except
performance), the heal staged-rerun block expects the per-step table to
always publish, and run steps are now required to carry
continue-on-error.

Verified locally: actionlint clean; test_security_workflow.py 21/21.

* test(heal): retry G14 deferred outage put (#7703)

Treat SlowDownRead as a bounded retryable deferred PUT response after the target pool rejoins, and label terminal deferred outage PUT failures with stage context.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): retry G14 outage put probes (#7704)

Treat SlowDownRead as a retryable outage PUT probe response in the G14 multi-pool runner, and label terminal candidate failures with stage context.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): remove unused G14 put helper (#7705)

Remove the superseded ServiceUnavailable-only PUT helper after the G14 outage PUT probe switched to the shared retry classifier.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(tables): authorize reserved metadata reads (#7672)

* ci: harden report steps after first full new-semantics run (#7708)

First full serial pass with the green-on-case-failure semantics
(run 34693745171 / 34695021651) exposed three report-layer defects:

security: the report step referenced LOG_FILE, which is undefined in
this workflow (set -u killed the step before writing report.md), and
the verdict greps could not match the ANSI-escaped [PASS]/[FAIL] tags
in the real suite log. Point it at the artifacts suite.log, allow any
number of color escapes before the verdict tag, and count [SKIP]
lines separately (45 passed, 6 failed, 3 skipped was reported as an
unbound-variable crash).

pool: warp is stopped early (SIGINT) at the storage threshold and
only writes its final report on a clean exit, so an empty warp.log is
the expected shape of a healthy run - require its presence, not its
size. A mid-script die() abort or a FAIL step verdict must also turn
the validator red now that the run step is continue-on-error.

tier: add the standard 'Product result: N passed, M failed' summary
line computed from the case table, matching the other suites.

The contract test fixture previously injected LOG_FILE into the
environment and wrote verdict lines without ANSI escapes, which hid
both real-world defects; the fixture now mirrors the real suite
(stdout+tee with color tags) and asserts the pass/fail/skip counters.

* ci: tolerate ANSI reset escapes in security verdicts; fix tier product counts (#7712)

The overnight run after #7708 proved the security verdict greps still
counted zero: real verdict lines are '\e[1;31m[FAIL]\e[0m STS-105 ...'
— the reset escape sits between the tag and the case id, and the
pattern only tolerated escapes before the tag. Allow escapes on both
sides; the fixture now emits the reset too, mirroring the real suite.

The tier case table is produced by rustfs_tier_report.py and its rows
lead with the topology column, so the case-ID-first table grep matched
nothing ('Product result: 0 passed, 0 failed'). Parse the PASS/FAIL
counts from the '## Case Summary' bullets the report always emits,
falling back to a topology-aware table grep.

* fix(heal): reject overlapping admin heal owners (#7723)

* fix(heal): reject invalid admin selectors before admission (#7724)

* fix(heal): pass endpoints to overlap receipt test (#7738)

Reuse the heal-control endpoint fixture in the overlap receipt regression so the test matches the updated execution helper signature and selector validation boundary.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

Co-authored-by: hehutu <hehutu@gmail.com>

* fix(heal): verify inline shard bitrot during deep scans (#7731)

* fix(heal): verify inline shard bitrot during deep scans

* test(heal): fix CI lint and topology fixtures

* fix(heal): persist and retry partial-write repairs (#7729)

* fix(heal): persist and retry partial-write repairs

* test(heal): pass topology to overlap RPC tests

Use the existing coordinator endpoint fixture for the three overlap-test
calls to the endpoint-aware heal control executor. This repairs the E0061
test-build failure inherited from the release base.

* fix(scanner): prove dirty ACK after confirmed root write (#7732)

Allow a confirmed scanner root data-usage CAS write to prove its own publication when the follow-up root readback cannot provide a proof. Keep AlreadyDurable and all stale or companion paths on the existing readback-only proof boundary.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): rebuild truncated xl.meta from healthy quorum (#7730)

* fix(heal): rebuild truncated xl.meta from healthy quorum

* fix(test): pass topology to heal overlap RPC regression

* fix(test): drive heal admission alongside partial PUT

Poll the partial PUT and its mock heal receiver together, bound their handshake, and retain the existing repair-scope assertions.

* fix(test): prepare durable MRF fixtures and Linux heal stack

* fix(test): drive tier cleanup recovery after deferred attempts

---------

Co-authored-by: Hauser <housemecn@gmail.com>

* fix(heal): prove completed historical version cleanup (#7736)

* fix(heal): prove completed historical version cleanup

* test(heal): use debug runtime stack for C06 regression

* test(ecstore): acknowledge partial PUT heal admission concurrently

* test(mrf): fix journal fault and ownership fixtures

* fix(tables): retain dropped warehouse protection (#7675)

* fix(ecstore): gate shard integrity and preserve legacy recovery (#7741)

* fix(ecstore)!: bind bitrot shards to immutable part identities

Verify part, coding-index, and block identity across write, GET, and Heal
paths. Preserve identities across metadata-only copies and repair, include
them in multipart quorum selection, and require payload proof for receipts.

Keep legacy decoding with conservative parity and target-digest validation,
and document its unsupported cases and additional verification I/O.

BREAKING CHANGE: New bound-v1 shards require compatible readers throughout
the fleet. Legacy objects without sufficient integrity evidence return an
error; binary rollback after new writes requires verified data migration.

Refs: rustfs/backlog#2497

* fix(ecstore): preserve shard framing with independent integrity

Commit immutable part-generation Merkle roots and replicated proof indexes without changing existing checksum frames. Verify reads, reconstruction and Deep Heal against metadata quorum; keep legacy reads and explicitly defer unproven legacy data repair.

Preserve multipart rollback generations, require acknowledged durable index publication, and add decoder compatibility and donor-shard regression coverage.

* fix(heal): verify protected partial-write replay

* fix(heal): retire stale delete markers after bucket recreation (#7743)

* fix(heal): prove completed historical version cleanup

* test(heal): use debug runtime stack for C06 regression

* fix(heal): retire stale delete markers after bucket recreation

* test(ecstore): fix Clippy in retired marker regressions

* fix(heal): preserve historical null version identity (#7749)

* fix(s3): preserve version identities on suspended reads (#7751)

* fix(rebalance): retry contended runtime metadata access (#7752)

* fix(rebalance): retry contended runtime metadata access

* chore(rebalance): shrink typed error ratchet baseline

* fix(heal): bind bucket recovery to its original incarnation (#7744)

* fix(heal): bind bucket recovery to its original incarnation

* fix(heal): preserve stable bucket heal task errors

* fix(s3): classify copy source part read failures (#7746)

Map PartMissingOrCorrupt to SlowDownRead only at the GetObject/CopyObject source-reader boundary so quota metadata corruption keeps its internal fail-closed response.

Add store and e2e coverage for Harbor-style multipart staging CopyObject boundaries.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(tier): classify invalid remote credentials (#7753)

* fix(tier): classify invalid remote credentials

Classify deterministic remote-tier auth rejections before the probe cleanup fallback, and preserve the admin 4xx status for invalid credential responses.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): stabilize delete-marker movement regressions

Ignore target-local bucket incarnation fence metadata when comparing data-movement delete-marker identities, keep the release null-version listing fixture valid, and fix the nextest decommission-family filter so fault-hook tests actually join the serial group.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(ecstore): serialize tier cleanup owner matrix (#7765)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): run bootstrap usage rebuild promptly (#7758)

Treat non-authoritative usage floor startup as pending bootstrap rebuild work so reset-published bootstrap markers cannot sit behind clean-idle or empty pause-backlog delay.

Wire recovery wakeups into the normal scanner cycle wait and expose the pending rebuild state in scanner status.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(tier): preserve cleanup scheduling for overwrites (#7766)

Queue committed tier free-version cleanup receipts for PUT and materialized CopyObject overwrites of transitioned null versions, while keeping remote deletion behind the existing persisted free-version cleanup path.

Tighten data-movement delete-marker retry equivalence by ignoring local bucket-incarnation fencing metadata, avoid retry fallback to the source pool, and keep version-list pagination from manufacturing an empty final page.

Refresh the e2e-distributed selector hash for the current release test set.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(release): backport main fixes and stabilize tier cleanup tests (#7793)

* fix(ecstore): stop pruning at nonempty directories (#7616)

* fix(ecstore): stop pruning at nonempty directories

* test(ecstore): release pruning fixtures before temp cleanup

(cherry picked from commit 8f150d1d8e)

* fix(heal): preserve retryable batch failures during recovery (#7642)

* fix(heal): preserve retryable batch failures during recovery

* test(heal): pin prebuilt hooks binaries in ci

(cherry picked from commit 5cd58319ed)

* fix(s3): reject oversize single PUT early and map body errors to 4xx (#7635)

* fix(s3): reject oversize single PUT early and map body errors to 4xx

A single PutObject above the 5 GiB single-request ceiling was only
rejected after the client had streamed 5 GiB into s3s's read-time body
budget, and the resulting BodySizeLimitExceeded surfaced from the erasure
writer as 500 InternalError. A body whose connection hit EOF before
Content-Length bytes arrived (hyper's IncompleteBody) was also a 500.
SDKs retry 500s, so one oversize upload was resent from offset 0 five
times.

- PutObject and UploadPart reject a declared length above
  MAX_SINGLE_PUT_OBJECT_SIZE with 400 EntityTooLarge before reading the
  body; the constant moves to rustfs_config so the s3s limit and the
  admission check share one value.
- ApiError maps BodySizeLimitExceeded to EntityTooLarge and a hyper body
  EOF to IncompleteBody across both io::Error conversions.

Fixes #7596.

* test(s3): cover UploadPart admission, aws-chunked length, real s3s limit

- Poll-counting test body proves PutObject and UploadPart reject a
  declared size above the ceiling with zero body polls; exact-cap and
  zero-length parts pass admission.
- A STREAMING-* aws-chunked PUT whose framed Content-Length exceeds the
  cap is admitted when the decoded length is within it and rejected when
  the decoded length is over it.
- The display-based BodySizeLimitExceeded matcher is checked against the
  real error produced by the pinned s3s Body budget.

(cherry picked from commit 50b31bc75b)

* fix(ecstore): make directory mtime fixture portable (#7623)

* fix(ecstore): make directory mtime fixture portable

* style(ecstore): format mtime fixture assertion

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
(cherry picked from commit b1cc286cac)

* fix(storage): prevent readiness after native migration failures (#7652)

* fix(storage): prevent readiness after native migration failures

* fix(storage): skip unsupported IAM records before reading

* fix(storage): use stable typed migration metadata errors

* fix(storage): include migration record in startup errors

* test(storage): cover native migration startup failures

* test(storage): use array chunks in migration fixture

---------

Co-authored-by: RJ Regenold <214054+rjregenold@users.noreply.github.com>
Co-authored-by: cxymds <cxymds@gmail.com>
(cherry picked from commit 0cbc3ffe61)

* fix(admin): expose OIDC account display fields (#7654)

Expose verified OIDC username and email claims as display-only metadata on self-account responses while preserving the virtual parent as the authorization identity.\n\nKeep rustfs-madmin public response structs unchanged by adding the optional wire fields through private handler response wrappers.

(cherry picked from commit f02bc947cd)

* fix(replication): correct peer joins and remote-state reporting (#7650)

* fix(replication): propagate verified peer deployment identities

* fix(replication): report actual remote peer state

* fix(replication): defer initial sync until all peers join

* test(replication): shut down TLS fixtures cleanly

---------

Co-authored-by: houseme <housemecn@gmail.com>
(cherry picked from commit 853ae63b6a)

* fix(s3): bound stalled UploadPart request bodies (#7659)

* fix(s3): bound stalled UploadPart request bodies

* fix(ci): preserve the S3S footprint ratchet

(cherry picked from commit 666dfd9f9f)

* fix(tables): reject reserved warehouse locations (#7671)

Co-authored-by: cxymds <cxymds@gmail.com>
(cherry picked from commit 414176c47f)

* fix(ci): bind nightly lanes to one resolved source (#7688)

(cherry picked from commit 01d8e4347f)

* fix(replication): close the pre-stable convergence gaps from backlog#2367 (#7626)

* fix(replication): total-order rule sort and honor V1 top-level Prefix

Rule matching had two defects from the pre-GA replication audit
(rustfs/backlog#2367 C-1 and C-2):

- The actionable-rule sort compared same-destination rules by priority but
  answered Equal for any other pair, which is not a total order; the
  standard library sort panics on such comparators once a slice exceeds the
  insertion-sort threshold, so an object matching more than 20 enabled rules
  across two or more targets could panic the PUT or DELETE task. Rules now
  sort by priority descending with destination and id as tie-breakers, and
  filter_target_arns preserves that order instead of draining a HashSet.

- A V1 rule written without a <Filter> carries its prefix at the top level;
  that field was never read, so <Prefix>logs/</Prefix> matched every object.
  ReplicationRuleExt::prefix now falls back to it, with a <Filter> keeping
  precedence. The existing prefix fixtures were built this way and had been
  asserting nothing.

* fix(admin): advertise data-usage and listen capabilities to rc

The rc client gated `rc du` and `rc watch` on a pinned contract that
matched server versions by the string prefix `1.0.0-rc.`; a server that
reports `1.0.0` no longer matches, and the dynamic `advertised` list did
not carry either name, so `rc du` against a GA server fails with an
unsupported-capability error (rustfs/backlog#2367 E-2).

Advertise `admin.data-usage` from the admin route inventory like the IAM
entries, and `listen_notification` for the bucket `?events=` extension
route the admin router dispatches. The client merges advertised entries
ahead of its pinned contract, so no version sniffing is needed.

* fix(site-replication): stop notifying the local site on remove and rotate

The pending-remove and pending-rotation notification loops skipped the
local site by endpoint only, while finalization identifies it by
deployment id or endpoint. The reconcile tick resolves the local peer from
the node's own listen address (and a handler from the request Host), so
`remove --all` dialed the site's registered endpoint, waited out the
request timeout against the lifecycle lock it was holding, and answered
`Partial: failed to notify 1 peer(s)` for a removal that had succeeded
(rustfs/backlog#2367 A-4, backlog#2195 item 3).

Both loops now iterate the peers still awaiting notification through one
helper that applies the finalization identity.

* fix(site-replication): promote and settle IAM retries without a tick of slack

Two retry-queue behaviours kept an IAM change from converging for ten to
twenty minutes after a peer came back (rustfs/backlog#2367 A-1 and A-3,
backlog#2305):

- The lightweight 30-second pass filtered its reachability probe to bucket
  ops, so a backed-off IAM or bucket-metadata snapshot waited for the
  600-second tick to notice the peer. It now probes every backed-off class
  and still replays only bounded bucket ops; promotion is a state flip the
  heavyweight tick acts on.

- Backoffs are multiples of the tick interval, so a failure stamped δ
  seconds after a tick was 600 − δ old at the next tick and slipped a whole
  extra interval. The heavyweight drain now evaluates backoff halfway to its
  next tick.

- An IAM entry first created by a non-deletion failure (the add bootstrap's
  snapshot send, the drain's own replay, an import-iam schedule) was never
  stamped `deletions_recorded`, so a later recorded deletion could not
  settle it and it escalated to the marker only `replicate repair` clears.
  Entries created by this binary now start recorded; a row persisted by an
  older binary keeps the escalation semantics.

* fix(site-replication): reload peer node caches after bucket wiring writes

Every S3 bucket-config write ends by asking the other nodes of the cluster
to reload the bucket's metadata; the site-replication writers never did.
On a multi-node site the node that ran the pairing (or applied a peer's
bucket-meta item) rewrote the bucket targets and the derived replication
rules on disk, while every other node kept serving its cached copy for up
to the 15-minute refresh. A `resync start` routed to such a node reported
every freshly wired bucket as `Config not found` and a bucket whose
operator target the pairing had replaced as `recorded remote target no
longer exists` (rustfs/backlog#2367 A-5, backlog#2195 item 2; functional
SITE-105).

Add one best-effort reload helper in the site-replication hooks and call it
after the bucket setup, versioning, peer bucket-meta apply, removed-peer
cleanup, make-with-versioning, and endpoint-refresh writes; the ensure
helpers now report whether they wrote so unchanged passes stay silent. The
resync manifest and start now read the persisted wiring instead of the
node-local cache, matching the target read the start path already did.

The new four-node e2e pairs two clusters and starts a resync through a
non-coordinator node right after pairing; it also covers an IAM user
created on a non-coordinator node converging to the peer site.

* test(e2e): cover delete-marker replication from a multi-node source

The functional suite reported delete markers created on a 3-node source
never reaching the target (rustfs/backlog#2195 item 4, REP-105). The report
was a probe defect, but the shape had no coverage: the existing
delete-marker e2e runs a single-node source. Pin it against a four-node
source replicating to a four-node peer and to a single-node target, with
the write and the delete issued through different nodes.

* ci(e2e): refresh the distributed selection for the new replication cases

Four distributed cases were added (two site-replication, two delete-marker
replication). The linux digest is derived from the last CI listing of the
lane (34 cases, matching the previous pin) plus the four new names; the
darwin digest is the local listing, which selects the same 38 cases.

(cherry picked from commit aeaba86d73)

* fix(e2e): require a verified server binary for every e2e run (#7687)

* fix(ci): share quick checks and lint workflows

* fix(ci): install actionlint from its verified release

* fix(ci): reject dependencies on required quick checks

* feat(test): verify the E2E server build and source identity

* test(e2e): register verified Darwin test membership

* test(e2e): record verified Linux receipt test membership

* test(e2e): record compiled Darwin receipt test membership

* test(e2e): record compiled Linux receipt test membership

* test(e2e): record Darwin e2e-full membership after merging main

* fix(test): route scanner/heal evidence E2E runs through the verified server binary

The evidence runners built rustfs with plain cargo and then ran e2e_test directly, which now fails without a run receipt. They build through scripts/e2e_binary.py and run the e2e_test invocations under e2e_binary.py run; the obsolete rustfs.features stamp is removed.

* docs(e2e): run server-backed e2e commands through the verified binary wrapper

* test(e2e): record Linux e2e-full membership from the branch CI listing

(cherry picked from commit 2909b1bfe1)

* fix(kms): classify KMS/SSE error contracts and SSE-S3 headers (#7697)

* fix(sse): classify bare SSE-KMS writes when no KMS is available

A `aws:kms` request without a key id, on a bucket without a default key,
returned `500 InternalError` whenever no KMS service was running: the
"no KMS key available" branch exited with an untyped storage error before
the availability classification that the keyed form already received.

Route that branch through the same split: `503 ServiceUnavailable` while
a configured KMS is stopped, `400 InvalidRequest` when KMS was never
configured, and `400 InvalidRequest` naming the missing key id when a
running KMS has no default key. `CreateMultipartUpload` shares the path.

Adds a unit test for the bare form and an e2e module that stops KMS
through the admin API, runs a master-key-only node, and runs a Local KMS
without a default key; refreshes the e2e-full selection digests.

(cherry picked from commit c3259dadc3d603a9185a5b0ad9f83dfb884e61c8)

* fix(sse): keep KMS error classes on the encrypted read path

GetObject, CopyObject and UploadPartCopy on an SSE-KMS object whose key
no longer exists answered `500 InternalError` ("KMS key not found") while
PutObject under the same key already answered `400 KMS.NotFoundException`.
The read path carries its classification through ecstore's
`EncryptionResolutionErrorKind`, which had no kind for a missing key, a
denied KMS grant or a missing backend capability, so all three folded
onto `DecryptionFailed` and the S3 layer reported an internal fault.

Add `KeyNotFound`, `AccessDenied` and `NotImplemented` kinds, map them on
both sides of the boundary, and give an envelope the configured backend
cannot unwrap a diagnosable message while keeping its `500`.

Unit tests cover the kind round trip and the reader wrapping; a new e2e
test deletes a key immediately and checks GET/Copy return 400 with
`KMS.NotFoundException` while HEAD stays 200. The e2e-full selection
digests are refreshed from the current listing (the previous digests
predated the delete-authorization tests) and the e2e `create_default_key`
helper is updated to the accepted `EncryptDecrypt` spelling.

(cherry picked from commit 2523a9814e97caea318d4ff1a51bef3a4d4445b2)

* fix(kms): classify key-management errors on the admin routes

`POST /kms/keys`, the legacy `create-key` alias and `generate-data-key`
reported every backend refusal as `500`: a blank key name (which each
backend failed on differently, the Local backend by writing a key file
with an empty stem), a name already taken, an unknown key, a disabled key
and a capability the backend lacks. `delete` and the lifecycle routes
already classified the same errors.

Refuse a blank or whitespace name in `KmsManager::create_key` before any
backend sees it, and share one `KmsError` to status mapping across
create, delete and generate-data-key (400 for validation and key state,
404 for an unknown key, 409 for a taken name, 501 for a missing
capability, 500 only for damaged material). The XML-error routes carry
the same status explicitly since s3s derives none for a custom code.

The read-only Static backend now reports create, delete and
cancel-deletion as `UnsupportedCapability`, matching its rotate and
enable/disable answers, so the admin API returns 501 for all of them.

(cherry picked from commit e33cac5493c4d9d6662e0d2980b58ba2b24a6d1b)

* fix(sse): stop SSE-S3 responses from naming the wrapping KMS key

`x-amz-server-side-encryption-aws-kms-key-id` is defined for `aws:kms`
objects only, but PutObject, CopyObject, CreateMultipartUpload and
GetObject returned it for `AES256` objects too, carrying the KMS key that
wraps the SSE-S3 data key (the service default, or the literal `default`
on a node without KMS). The write paths copied `kms_key_id` from the
encryption material unconditionally, and the single-decrypt GET
classification did the same after resolving the key for authorization.

Add `EncryptionMaterial::response_kms_key_id`, which yields the id only
for SSE-KMS, use it at the four write-response sites, and gate the GET
classification the same way. CompleteMultipartUpload and HeadObject
already omitted the header.

Unit tests pin both directions; a new e2e test covers Put/Get/Head/Copy
and CreateMultipartUpload for AES256 with an aws:kms control. The
e2e-full selection digests are refreshed from the current listing.

(cherry picked from commit 29d793a63352b0b60fd53c565e80fdbede8964bb)

* fix(s3): validate PutBucketEncryption rules before storing them

A default-encryption rule naming an unknown `SSEAlgorithm` (for example
`AES128`), a rule without `ApplyServerSideEncryptionByDefault`, an empty
rule list, or a `KMSMasterKeyID` on an `AES256` rule was stored as
written: the only algorithm check on the route decided whether to fill
in the default KMS key. `GetBucketEncryption` then advertised that
configuration while the write path encrypted header-less writes under
its `AES256` fallback, so the bucket's declared and actual schemes
disagreed. Two comments claimed the route already refused unknown
algorithms.

Validate the configuration before any of it is applied: `MalformedXML`
for a malformed rule set or unknown algorithm, `InvalidArgument` for a
key id on a non-KMS rule, and nothing stored on refusal. Correct the two
comments to describe when the AES256 fallback is still reachable.

Unit tests cover every refusal and the accepted shapes; an e2e test
checks the refusals leave the previous configuration in place. The
e2e-full selection digests are refreshed from the current listing.

(cherry picked from commit 29e4486dce41197ed93f5253cdbabc57d27a4ddb)

* test(e2e): refresh e2e-full selection for the combined KMS/SSE fixes

* test: align two unit tests with the new KMS and bucket-encryption contracts

`scheduled_deletion_carries_a_deadline_and_can_be_cancelled` still
expects the state error (`InvalidOperation`) for cancelling a key that
is not pending deletion; only the Static backend's mutations moved to
`UnsupportedCapability`. The uninitialized-store PutBucketEncryption
test now sends a well-formed AES256 rule so it reaches the store lookup
instead of the new configuration validation.

(cherry picked from commit e2e6a2535a)

* fix(site-replication): keep an operator's bucket-level target to a peer instead of taking it over (#7709)

* fix(site-replication): keep an operator's bucket-level target to a peer instead of taking it over

Site replication wired each bucket by looking for an existing replication
target "to the same peer" and rewriting the first match in place as its own
same-name target. An operator's bucket-level target that happened to point
at that site (different target bucket, operator credentials) was the first
match whenever it pre-dated the join, and the reconciler repeats the pass
every 600s, so the takeover also depended on target order afterwards. The
operator's rule then named an ARN no target backed and their bucket
replication stopped silently, while the inherited bucket-level reset id
made every site resync report the bucket as owned by another resync
(rustfs/backlog#2479, rustfs/backlog#2489).

Follow MinIO's `getRemoteARN` / `getRemoteARNForPeer` shape instead:

- Wiring updates a target in place only under the same ARN, or when it is
  recognisably the site's own under an older ARN shape (same peer,
  same-name target bucket, site replication service account). Anything
  else gets the site target added next to it.
- The site resync manifest takes the target the derived
  `site-repl-<deployment id>` rule names (same-name shape as fallback), so
  an operator target to the peer neither aborts the bucket as "multiple
  remote targets matched peer" nor gets resynced into.
- Peer removal prunes only targets a pruned derived rule names or the
  same-name target bucket; operator targets stamped with the peer's
  deployment id survive together with their rules.

Unit tests cover the three predicates. e2e
`test_site_replication_keeps_operator_bucket_target_to_peer` runs a
bucket-level replication plus `replication-reset` to the future peer, joins
the sites, and requires the operator target untouched, both paths
delivering, the site resync completing against the site target, and the
operator target and rule surviving `replicate remove --all`; without the
fix it fails at the join with the operator target gone. The repl-nightly
selection digest is refreshed for the new case.

* test(site-replication): drop a redundant clone flagged by clippy

The reconcile unit test cloned the remote peer into the state map although
the binding is not used afterwards; workspace clippy (-D warnings) rejects
that as redundant_clone.

(cherry picked from commit ecdc55fa4b)

* fix: enforce S3 permissions for recursive force deletion (#7661)

* fix: enforce S3 authorization for recursive deletion

* fix: satisfy the s3s footprint guard

* fix: restore list versions policy compatibility (#7686)

(cherry picked from commit 3fd1ce414d)

* fix(ci): repair functional defaults and chain regression checks (#7664)

* fix(ci): default functional suites to nightly packages

* test(ci): follow the fault-tolerance chain handoff

(cherry picked from commit 509a0fa90c)

* fix(ci): align security workflow tests with chain (#7679)

(cherry picked from commit d9e47d2813)

* test(ecstore): keep tier cleanup tests stable after immediate receipt queueing

Release PR #7766 made PUT/CopyObject overwrites queue the tier free-version cleanup receipt immediately, which broke two ecstore tests on release CI. In tier_overwrite_put_and_self_copy_recover_persisted_cleanup_owners the restarted store already runs expiry workers from the second iteration on, so they deleted the remote bytes before the test could assert that the commit leaves them in place; the test now fails the first remote DELETE via set_remove_failure(true) so the cleanup owner stays durable and the later restart still has to rediscover it from xl.meta (the failed remove does not bump remove_count, and the test re-enables removes before the recovery wait). In batch_transitioned_delete_post_commit_failures_roll_back_without_free_version_receipt the convergence loop now treats a transient InsufficientReadQuorum as "not yet converged", because cleanup rewrites xl.meta disk by disk and a racing read can briefly miss quorum (seen in the rio-v2 lane); any other error still panics.

* Revert "fix(ci): align security workflow tests with chain (#7679)"

This reverts commit 4544359f6d.

* Revert "fix(ci): repair functional defaults and chain regression checks (#7664)"

This reverts commit 5ba7ec0291.

* fix(ecstore): pass shard integrity to backported ingest-mode test

The stalled-reader test backported with #7659 used main's four-argument encode_with_ingest_mode, but release's signature takes an optional IntegrityBuilder, so pass None to keep the test focused on ingest-mode cleanup.

---------

Co-authored-by: Henry Guo <marshawcoco@gmail.com>
Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: RJ Regenold <rregenold@teamraft.com>
Co-authored-by: RJ Regenold <214054+rjregenold@users.noreply.github.com>
Co-authored-by: cxymds <cxymds@gmail.com>
Co-authored-by: GatewayJ <835269233@qq.com>
Co-authored-by: Jason Kossis <jkossis@gmail.com>

* fix(ci): restore release test compilation and e2e membership (#7799)

* fix(s3): return stored content language on local GET (#7755)

* fix(s3): return resolved object tagging version identities (#7757)

* fix(tables): bound warehouse index recovery (#7677)

Co-authored-by: Chris <anzhengchao@gmail.com>

* fix(upgrade): probe legacy IAM namespace before migration (#7816)

* fix(release): backport IAM migration startup fixes and recent main fixes (#7825)

* fix(auth): reject unsigned x-amz headers on header-signed SigV4 requests (release) (#7813)

* fix(s3): preserve missing version errors on object reads (#7800)

* fix(s3): preserve missing version errors on object reads

* test(s3): align missing-version assertions with read contract

* fix(heal): bind admin traversal to bucket incarnation (#7802)

* fix(heal): bind admin traversal to bucket incarnation

* fix(ecstore): reopen healthy hedged readers after peer loss

* test(ecstore): isolate tier overwrite recovery scheduling

* test(ecstore): observe tier cleanup under object read locks

* fix(ecstore): bound decommission entry tracing

* test(ecstore): drain incarnation heal fixture writes

* test(heal): match debug server stack in deep heal fixtures

* test(heal): include identities in C06 count failures

* test(heal): drain PUT tails before inspecting B920 fixtures

* test(scanner): isolate retained MRF retry slots

* fix(ecstore): separate PUT cleanup intent from persisted metadata

* fix(ecstore): capture failed object lock acquisitions (#7803)

* fix(ecstore): capture failed object lock acquisitions

* test(heal): include identities in C06 count failures

* test(heal): drain PUT tails before inspecting B920 fixtures

* fix(ecstore): reopen healthy hedged readers after peer loss

* test(ecstore): isolate tier overwrite recovery scheduling

* test(ecstore): observe tier cleanup under object read locks

* fix(ecstore): bound decommission entry tracing

* test(ecstore): drain incarnation heal fixture writes

* test(heal): match debug server stack in deep heal fixtures

* test(scanner): isolate retained MRF retry slots

* fix(ecstore): separate PUT cleanup intent from persisted metadata

* fix(heal): retain terminal outcomes across restart (#7805)

* fix(heal): retain terminal outcomes across restart

* test(heal): use octal mode for terminal recovery fixture

* fix(ecstore): reopen healthy hedged readers after peer loss

* test(ecstore): isolate tier overwrite recovery scheduling

* test(ecstore): observe tier cleanup under object read locks

* fix(ecstore): bound decommission entry tracing

* test(ecstore): drain incarnation heal fixture writes

* test(heal): match debug server stack in deep heal fixtures

* test(heal): include identities in C06 count failures

* test(heal): drain PUT tails before inspecting B920 fixtures

* test(scanner): isolate retained MRF retry slots

* fix(ecstore): separate PUT cleanup intent from persisted metadata

* fix(heal): recover replacement ownership across VM restarts (#7810)

* fix(heal): recover replacement ownership across VM restarts

* test(ecstore): isolate tier overwrite recovery scheduling

* test(ecstore): observe tier cleanup under object read locks

* fix(ecstore): bound decommission entry tracing

* test(ecstore): drain incarnation heal fixture writes

* fix(ecstore): reopen healthy hedged readers after peer loss

* test(heal): match debug server stack in deep heal fixtures

* test(heal): include identities in C06 count failures

* test(heal): drain PUT tails before inspecting B920 fixtures

* test(scanner): isolate retained MRF retry slots

* fix(ecstore): separate PUT cleanup intent from persisted metadata

* fix(tables): isolate protected S3 listings (#7676)

Co-authored-by: overtrue <anzhengchao@gmail.com>

* fix(ci): restore Linux E2E selection and split offline enrollment (#7843)

* fix(heal): assign stable identities to scanner repair requests (#7823)

* fix(heal): assign stable identities to scanner repair requests

* test(s3): isolate UploadPart inactivity timing

* fix(heal): backport pool metadata scope to release (#7846)

* fix(heal): scope replacement pool metadata to its owning set

(cherry picked from commit a72a75569c)

* fix(heal): use domain imports in pool metadata regression test

(cherry picked from commit e8c5448e21)

* test(heal): set pool metadata regression recursion limit

---------

Co-authored-by: Hiroaki KAWAI <1468181+hkwi@users.noreply.github.com>

* fix(heal): persist pool/set outcomes and recovery scope (#7839)

* fix(heal): persist pool/set outcomes and recovery scope

* test(heal): verify selector receipts and process-crash recovery

* fix(heal): scope replacement pool metadata to its owning set

(cherry picked from commit a72a75569c)

* fix(heal): use domain imports in pool metadata regression test

(cherry picked from commit e8c5448e21)

* test(heal): set pool metadata regression recursion limit

---------

Co-authored-by: Hiroaki KAWAI <1468181+hkwi@users.noreply.github.com>

* fix(ecstore): scope recursive walks to object prefixes (#7850)

* fix(heal): scope nested walks and reject admin read-repair (#7851)

* fix(heal): scope nested walks and reject admin read-repair

* fix(ci): allocate stack for heal metadata regression

* test: cover overlapped multipart staging copy (#7865)

Add Harbor-style E2E coverage for a CopyObject racing with CompleteMultipartUpload. The test allows the copy to observe an unpublished source as NoSuchKey, but rejects 5xx leakage and proves retrying the completed source remains readable.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(release): repair acceptance checks and prepare 1.0.0 (#7866)

* test(e2e): stabilize release acceptance checkpoints

* chore(release): prepare 1.0.0

* test(scanner): stabilize W13 release evidence gates (#7870)

Add the object-only completion diagnostic case and make the evidence runner resolve CARGO_TARGET_DIR before recording the rustfs binary path.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(kms): cover non-default Vault Transit paths (#7657)

Backport the Vault coverage from 4d31621f0a. Keep the release cleanup polls synchronized with object read locks.

* fix(replication): count a bodiless 405 as a replicated delete marker (#7756)

* fix(replication): accept a bodiless 405 as a replicated delete marker during resync

A resync verifies each delete marker with `HEAD ?versionId=<marker>` on the
target. S3 targets (RustFS, MinIO, AWS) answer that with 405 and no body,
and the SDK only synthesizes an error code for 404, so the error arrived
with `code() == None`. `is_retryable_delete_replication_head_error` then
treated it as an ambiguous failure: every delete marker counted as a failed
object with `target service error`, and a site resync over any bucket that
holds a delete marker reported the whole bucket as failed
(rustfs/backlog#2479, SITE-105 on rc.6 and nightly).

Use the raw HTTP status the way the 404 path already does: a 405 without a
code confirms the marker propagated. Ambiguous statuses still fail.

- Unit tests drive `verify_resync_head_result` against a scripted target
  answering 405 (accepted) and 503 (still failed).
- e2e `test_site_replication_resync_replicates_delete_marker` joins two
  sites, converges a live object and a delete marker, and requires the site
  resync to complete with zero failed objects; the repl-nightly selection
  digest is refreshed for the new case.

* fix(replication): verify marker-version purges by absence during resync

A delete-marker resync entry with a pending or failed version purge asks
the target to remove the marker, so the bodiless 405 that proves a
created marker propagated proves the purge did not happen. Accept the
405-as-success mapping only for marker creation (empty
version_purge_status); a purge counts as replicated only when the target
answers not found, and any other HEAD outcome stays failed. Unit tests
drive both purge statuses against the 405 fixture and the 404 fixture.

(cherry picked from commit 1ef3974bad)

* fix(kms): refuse key ids that leave the key prefix on the Vault backends (#7727)

Only the Local backend refused a key identifier containing a path
separator. On Vault KV2 a create with the name bad/name succeeded and
produced a nested KV2 path that the listing then reported as a
directory rather than a key, and an identifier containing .. addressed
a record outside the configured key prefix once the HTTP client
normalised the URL; Vault Transit built its transit key name and its
metadata path from the same unchecked identifier.

Lift the Local backend's containment rule into a shared segment check
(empty, /, backslash, NUL, . and ..) and apply it at the single point
where each backend turns the identifier into a path or a Transit key
name, so create, describe, encrypt, delete and the metadata writes all
refuse with InvalidKey before any request reaches Vault. The admin API
already maps that to 400. The AWS backend is untouched: it addresses
keys by ARN and alias, both of which contain /.

Refs rustfs/backlog#2474 (KMS-213 CreateNegatives on vault-kv2).

Co-authored-by: Hauser <housemecn@gmail.com>
(cherry picked from commit 2f2e775655)

* fix: resume GET body after peer short EOF (#7852)

Backport GET recovery from afc8861fec. The Connect TLS classification change requires the main-only proxy transport layer.

* fix(s3): honor presigned UploadPart checksum queries (#7748)

* fix(s3): honor presigned UploadPart checksum queries

* fix(s3): keep checksum errors within gateway boundary

---------

Co-authored-by: Hauser <housemecn@gmail.com>
(cherry picked from commit a8ace89516)

* fix(table-catalog): map transient lock failures to unavailable (#7841)

(cherry picked from commit 5c73a3d602)

* fix(ecstore): preserve ready read errors during encoding (#7798)

Backport the erasure fix from c95b65a7fc while retaining release shard integrity support. Dependency upgrades and the main-only Connect proxy and diagnostic fixes are excluded.

* ci: add the S3 Tables (Iceberg REST Catalog) functional suite workflow (#7873)

New workflow rustfs-table-test.yml following the shared suite contract:
Initialize functional evidence (FUNCTIONAL_ARTIFACTS_DIR/LOG_FILE/
REPORT_FILE/TMPDIR), checkout for the report parser, auto-testing
clone with retry, continue-on-error on the run step, report generation
through scripts/functional_case_report.py with the product gate (case
failures stay green; harness breakdowns turn red), dashboard upload
under functional-reports/table/, backlog issue manager wiring
(--suite table --category table), artifact upload (report.md, cases.md,
suite.log), before/after environment cleanup, and the chain handoff
step (repository_dispatch only, next: pool expansion).

The suite script itself lives in rustfs/auto-testing (TBL-101..107,
paired PR). The run step passes the product smoke script path
(scripts/table-catalog/pyiceberg_smoke.py) via --smoke-script and the
log via --log-file, matching the other suites.

Contract tests: the table suite is registered in the generic evidence
and workflow matrices (JOBS/DIRECT_TESTS/allowlist) plus a dedicated
TableSuiteTests wiring check. Full run: 21/21.

* test(ecstore): tolerate slow read-repair diagnostics CI (#7892)

* fix(heal): report verified normal shard repairs (#7890)

* fix(heal): retry interrupted internode shard writes

(cherry picked from commit 0bd75f47b6326edd8167ee43b8776f6276692e92)

* fix(deps): update rustls for RUSTSEC-2026-0285

(cherry picked from commit 938e1259d13da7832d85ac5c066a59ab3a28bc63)

* test(heal): wait for replacement disk and coordinator readiness

(cherry picked from commit feeaa6f053aac4b7c8e32477c0ab5ebbd3dcdff3)

* fix(heal): retain RPC transport failures across metadata probes

(cherry picked from commit f48ec9f0c6e37b1f941620c666a9357c4b892d68)

* fix(s3): resume GET bodies after erasure read quorum loss

(cherry picked from commit a38e859905)

* test(heal): keep outage objects in the replacement erasure set

(cherry picked from commit a669f50a366e35505e7745f8b680d8ecf1aa31be)

* fix(rpc): retry authenticated requests after peer epoch changes

Backport the stale-epoch recovery fix from main follow-up #7889. Keep invalid signatures and nonce replays terminal.

* test(rpc): assert retryable rejection of stale boot epochs

(cherry picked from commit 1c4b39b99)

* ci: bound concurrent heal clusters in the full E2E gate

Apply the #7889 scheduling fix while preserving release test membership and zero retries.

* fix(ecstore): hide pending purge entries from listings (#7895)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(ecstore): scope pending purge listing regression (#7897)

Keep the store-level pending purge listing regression focused on the ordinary recursive path and exact-prefix shortcut. The deeper delimiter CommonPrefix case is a separate physical-walker boundary and should not leave the release test suite red.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(obs): expose scanner usage freshness metrics (#7896)

Add Prometheus scanner metrics for dirty-usage pending buckets, usage-cache save/publication timestamps, publication result codes, and deferred publication state. This gives operators a direct scrape path for confirming scanner dirty ACK behavior without relying on admin JSON.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): backport delete quorum rollback to release (#7902)

* ci: rotate the shared functional-tests concurrency group to -v2 (#7900)

* fix(s3): render null write version IDs consistently (#7904)

* fix(heal): quarantine corrupt root recovery records (#7905)

* fix(heal): quarantine corrupt root recovery records

Keep invalid root-heal records durable while isolating them from healthy recovery owners. Serialize startup replay with administrator admission and fence quarantined task IDs so independent compensation heals can proceed safely.

* fix(ci): align release workflow contract tests

* fix: satisfy root recovery clippy checks

* fix(heal): fence canonical repair receipts (#7909)

* fix(heal): fence canonical repair receipts

* fix(heal): preserve unversioned receipt identity

* fix(heal): persist replacement intent on remote survivor (#7912)

* chore(release): merge main into release (#7913)

* feat(nightly): publish packages as assets of the rolling 'nightly' release (sync from release) (#7593)

feat(nightly): publish packages as assets of the rolling 'nightly' release (#7592)

Replace the assets-branch scheme with a proper GitHub Release on
rustfs/auto-testing: a single 'nightly' release whose deb/rpm assets
are replaced in place on every build. This is the standard channel —
visible on the repo's Releases page, stable download URLs, no git
history growth (release assets live outside the repository).

- New scripts/release/publish_nightly_assets.sh: resolves-or-creates
  the 'nightly' release via the REST API, deletes same-name assets,
  uploads rustfs-nightly-latest.{deb,rpm}, then PATCHes the release
  body with the build provenance (ref@sha, run link, sizes, SHA256).
  Plain curl + python3, no gh CLI (the build fleet has none — #7586).
- The workflow step shrinks to invoking the script; full flow
  exercised end-to-end against the real release with probe files
  (create / upload / overwrite / download round-trip / body update).

* fix(ecstore): stop pruning at nonempty directories (#7616)

* fix(ecstore): stop pruning at nonempty directories

* test(ecstore): release pruning fixtures before temp cleanup

* fix(replication): close the pre-stable convergence gaps from backlog#2367 (#7626)

* fix(replication): total-order rule sort and honor V1 top-level Prefix

Rule matching had two defects from the pre-GA replication audit
(rustfs/backlog#2367 C-1 and C-2):

- The actionable-rule sort compared same-destination rules by priority but
  answered Equal for any other pair, which is not a total order; the
  standard library sort panics on such comparators once a slice exceeds the
  insertion-sort threshold, so an object matching more than 20 enabled rules
  across two or more targets could panic the PUT or DELETE task. Rules now
  sort by priority descending with destination and id as tie-breakers, and
  filter_target_arns preserves that order instead of draining a HashSet.

- A V1 rule written without a <Filter> carries its prefix at the top level;
  that field was never read, so <Prefix>logs/</Prefix> matched every object.
  ReplicationRuleExt::prefix now falls back to it, with a <Filter> keeping
  precedence. The existing prefix fixtures were built this way and had been
  asserting nothing.

* fix(admin): advertise data-usage and listen capabilities to rc

The rc client gated `rc du` and `rc watch` on a pinned contract that
matched server versions by the string prefix `1.0.0-rc.`; a server that
reports `1.0.0` no longer matches, and the dynamic `advertised` list did
not carry either name, so `rc du` against a GA server fails with an
unsupported-capability error (rustfs/backlog#2367 E-2).

Advertise `admin.data-usage` from the admin route inventory like the IAM
entries, and `listen_notification` for the bucket `?events=` extension
route the admin router dispatches. The client merges advertised entries
ahead of its pinned contract, so no version sniffing is needed.

* fix(site-replication): stop notifying the local site on remove and rotate

The pending-remove and pending-rotation notification loops skipped the
local site by endpoint only, while finalization identifies it by
deployment id or endpoint. The reconcile tick resolves the local peer from
the node's own listen address (and a handler from the request Host), so
`remove --all` dialed the site's registered endpoint, waited out the
request timeout against the lifecycle lock it was holding, and answered
`Partial: failed to notify 1 peer(s)` for a removal that had succeeded
(rustfs/backlog#2367 A-4, backlog#2195 item 3).

Both loops now iterate the peers still awaiting notification through one
helper that applies the finalization identity.

* fix(site-replication): promote and settle IAM retries without a tick of slack

Two retry-queue behaviours kept an IAM change from converging for ten to
twenty minutes after a peer came back (rustfs/backlog#2367 A-1 and A-3,
backlog#2305):

- The lightweight 30-second pass filtered its reachability probe to bucket
  ops, so a backed-off IAM or bucket-metadata snapshot waited for the
  600-second tick to notice the peer. It now probes every backed-off class
  and still replays only bounded bucket ops; promotion is a state flip the
  heavyweight tick acts on.

- Backoffs are multiples of the tick interval, so a failure stamped δ
  seconds after a tick was 600 − δ old at the next tick and slipped a whole
  extra interval. The heavyweight drain now evaluates backoff halfway to its
  next tick.

- An IAM entry first created by a non-deletion failure (the add bootstrap's
  snapshot send, the drain's own replay, an import-iam schedule) was never
  stamped `deletions_recorded`, so a later recorded deletion could not
  settle it and it escalated to the marker only `replicate repair` clears.
  Entries created by this binary now start recorded; a row persisted by an
  older binary keeps the escalation semantics.

* fix(site-replication): reload peer node caches after bucket wiring writes

Every S3 bucket-config write ends by asking the other nodes of the cluster
to reload the bucket's metadata; the site-replication writers never did.
On a multi-node site the node that ran the pairing (or applied a peer's
bucket-meta item) rewrote the bucket targets and the derived replication
rules on disk, while every other node kept serving its cached copy for up
to the 15-minute refresh. A `resync start` routed to such a node reported
every freshly wired bucket as `Config not found` and a bucket whose
operator target the pairing had replaced as `recorded remote target no
longer exists` (rustfs/backlog#2367 A-5, backlog#2195 item 2; functional
SITE-105).

Add one best-effort reload helper in the site-replication hooks and call it
after the bucket setup, versioning, peer bucket-meta apply, removed-peer
cleanup, make-with-versioning, and endpoint-refresh writes; the ensure
helpers now report whether they wrote so unchanged passes stay silent. The
resync manifest and start now read the persisted wiring instead of the
node-local cache, matching the target read the start path already did.

The new four-node e2e pairs two clusters and starts a resync through a
non-coordinator node right after pairing; it also covers an IAM user
created on a non-coordinator node converging to the peer site.

* test(e2e): cover delete-marker replication from a multi-node source

The functional suite reported delete markers created on a 3-node source
never reaching the target (rustfs/backlog#2195 item 4, REP-105). The report
was a probe defect, but the shape had no coverage: the existing
delete-marker e2e runs a single-node source. Pin it against a four-node
source replicating to a four-node peer and to a single-node target, with
the write and the delete issued through different nodes.

* ci(e2e): refresh the distributed selection for the new replication cases

Four distributed cases were added (two site-replication, two delete-marker
replication). The linux digest is derived from the last CI listing of the
lane (34 cases, matching the previous pin) plus the four new names; the
darwin digest is the local listing, which selects the same 38 cases.

* feat(console): support a configurable console URL prefix (#7634)

Co-authored-by: houseme <housemecn@gmail.com>

* chore(release): merge release into main for rc.6 (#7638)

* test(heal): cover MRF manifest CAS legacy transition (#7443)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ci): enable release branch checks and repair test imports (#7441)

* test(scanner): structure heal release evidence lanes (#7442)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): require segment producer identities (#7444)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): expose recovery intent identity (#7445)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): summarize failed heal perf reports (#7446)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(common): retain unmatched MRF repair proofs (#7447)

Add a recorded verified-repair consumer that discharges only exact retained anchors for the requested bucket while leaving unmatched proofs in the event ring. This gives the future durable successor writer a fail-closed primitive before any tombstone or GC path is enabled.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(ci): route replication read plan through boundary (#7448)

* test(scanner): cover flat-bucket quantum fairness (#7449)

Add a production-entry scanner cohort regression that combines a wide flat bucket with a small bucket under a fixed object budget. The test keeps partial budgeted rounds unpublished, verifies small buckets are only marked after real execution, and confirms a later unbudgeted round can publish the complete aggregate.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): reuse same admission request id (#7451)

Keep a retried heal start with the same request id from bypassing admission deduplication when the transport replay cache is unavailable but the manager still owns the task.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require perf summary evidence fields (#7453)

Fail closed when measured passing Scanner/Heal ABBA summaries omit W10/W11 foreground pressure, lock wait, or attempt-cost evidence.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind release evidence fields (#7452)

Require explicit scanner/heal release evidence field contracts for the scoped ACK and mixed-version rollback gates.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): tag dirty usage producer identities (#7454)

Record production-facing segment invalidation producer identities when existing object-level dirty usage hooks observe PUT, CopyObject, DeleteObject/DeleteMarker, and CompleteMultipartUpload mutations. Keep the data non-authoritative and process-local so segment reuse activation still requires durable generation-window proof.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): gate release evidence bundles (#7457)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require hard evidence ABBA manifest (#7459)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(heal): add MRF committed snapshot writer (#7458)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover EC8+4 restart shard rebuild (#7461)

Retry heal-control RPCs once after transport auth rejects a stale replay-scope epoch, and add a distributed EC8+4 restart heal evidence case that rebuilds a replaced drive with exact shard/body assertions.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): echo measured release evidence in ABBA fake adapter (#7460)

Keep the synthetic ABBA test adapter aligned with the measured release evidence contract so result validation covers release_evidence drift.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require complete ABBA summary matrix (#7462)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* heal: retain MRF replay journal after accepted/merged replays

Keep startup replay journal until a durable successor snapshot exists after Accepted/Merged admission.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* heal: reclaim superseded MRF snapshots after readback

Add a committed snapshot predecessor cleanup primitive that deletes only an older slot after the successor is read back as the current committed snapshot.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* heal: require exact owner for object receipts

Require object heal receipts to match the expected bucket incarnation before they can be recorded as positive repair evidence. This prevents stale or cross-incarnation receipts from clearing the wrong heal responsibility.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* heal: latch object receipt owner before repair

Capture the expected bucket incarnation before invoking object repair so a post-repair owner change cannot rewrite the responsibility that a storage receipt is allowed to prove.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* heal: replay committed MRF checkpoints durably (#7465)

Prefer committed MRF checkpoints during startup replay, retain accepted replay responsibilities until exact verified repair proofs arrive, and reclaim committed manifests only after discharge.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind hard evidence bundle provenance (#7467)

Require Scanner/Heal release bundle fields to carry source, run, window, timestamp, command, and artifact format provenance before a measured gate can pass.

Keep EC8+4 and performance gate fields tied to a single measurement window so unrelated artifacts cannot be stitched into a release approval.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* heal: reject dry-run object repair receipts

Do not record positive storage repair receipts for dry-run object heal tasks, even if a producer accidentally returns a matching receipt.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* heal: reject cancelled object repair receipts

Do not record positive storage repair receipts once an object heal task has been cancelled, even if the receipt still matches the requested owner and object identity.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): reject failed object repair receipts

Cover the receipt consumer path where a storage repair result carries both an error and a matching positive receipt. The failure may be recorded, but the receipt must not create repaired, healthy, or absent proof.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover bucket object repair receipts (#7468)

Cover bucket and root heal sweeps recording authoritative object outcomes only when storage receipts match the latched bucket incarnation.

Verify unavailable or stale receipt ownership keeps object repair execution intact while leaving canonical outcome proof as Unknown.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add EC8+4 heal restart evidence (#7469)

Register and wire Scanner/Heal background target restart and crash evidence cases for the 3x4 EC8+4 topology.

Validate the observed data/parity geometry in scanner-heal evidence receipts so multi-drive runs cannot satisfy the gate without proving EC8+4 metadata.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* heal: verify replacement pool metadata repair (#7471)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover quorum and mixed repair receipts

Add focused oracles for transient quorum results that carry a matching receipt and for mixed grace plus repaired receipt batches. Only the repaired object may produce positive proof.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* heal: verify admin recreate pool metadata (#7474)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* heal: preserve merged result for duplicate submits

Keep same-request-id replay receipts accepted for the receipt API, but preserve the legacy submit_heal_request duplicate admission result as Merged.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): preserve EC84 restart semantics (#7478)

Keep the EC8+4 background restart lane on the graceful-stop path and assert clean-restart marker absence only for restart scenarios. This prevents the hard evidence gate from silently exercising the crash path when it claims restart coverage.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF snapshot torn successor recovery

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): write EC84 distributed restart oracle (#7481)

Bind the distributed EC8+4 restart evidence lane to a scanner/heal oracle artifact so release validation can consume the real nextest run instead of accepting only a passing test.

Require the registry to assert 8+4 erasure geometry for the three-node, four-drive case.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): harden MRF replay boundaries (#7483)

Reject journal records with unknown version-presence flags even when their CRC is valid, so rollback/future payloads cannot be accepted as known records.

Gate committed checkpoint cleanup by the writer owner captured from the replay source, preserving retained manifests from other owners inside the same sequence window.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(ecstore): bind MRF manifest CAS dirsync recovery (#7482)

Cover the committed MRF manifest path through LocalDisk conditional CAS when the metadata directory fsync fails. The fixture proves the previous manifest anchor survives rollback, an unanchored first successor is removed, and the legacy MRF journal remains readable even while global durability is relaxed.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): derive evidence runner profile from registry (#7484)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require mixed-version evidence roles (#7485)

Tighten the Scanner/Heal release bundle checker so mixed-version, scoped-ACK, and rollback fields cannot reuse a generic versions list without proving the expected evidence role.

Require version lists to use source revision identities and include the tested source revision. Also require profile evidence fields to name the core profiling artifacts before release approval.

The release gate remains blocked until measured field evidence is present.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(heal): publish committed MRF runtime checkpoints (#7490)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind profile artifacts in release evidence (#7487)

Require Scanner/Heal release bundles to attach every required profiling artifact to P1 profile evidence with relative paths, artifact formats, non-empty files, hashes, and optional per-artifact measurement-window checks.

Document the tightened release bundle profile contract and cover missing, tampered, and mismatched-window profile artifact regressions in the existing checker self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): harden measured ABBA evidence claims (#7491)

Reject measured Scanner/Heal ABBA manifests whose mixed-version evidence uses the same baseline and candidate source revision or binary hash.

Require crash fault modes and profile artifact names to match the exact supported sets, rejecting missing, duplicate, and unknown values.

Update harness fixtures and regression coverage for same-build mixed-version claims and exact-set release evidence fields.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(e2e): group scanner heal evidence payload (#7494)

Group the EC8+4 Scanner/Heal evidence writer inputs into a typed payload so the distributed e2e crate stays within the clippy argument limit without weakening the lint.

The evidence writer still validates the same S3 bodies, physical shard census, process restart PIDs, and node listings.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF replay bundle fields (#7486)

Bind Scanner/Heal release bundle evidence for MRF durable replay to replay counts, retained responsibility anchors, and successor snapshot publication evidence.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): profile EC84 evidence case runs (#7495)

Give Scanner/Heal evidence cases explicit runtime profiles so EC8+4 background restart and crash cases use their own object count, object size, and partial-progress timeout defaults instead of inheriting the legacy 4x1 case assumptions.

Expose the runtime profile in plan-only output and cover every registry case in the script self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): reject empty release evidence artifacts (#7496)

Require Scanner/Heal release bundle artifact paths to resolve to non-empty files before hashing them.

Cover empty hard-gate artifacts in the existing release bundle checker self-test and keep profile artifact size checking on the shared artifact boundary.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require G14 same-window field coverage (#7489)

Tighten the Scanner/Heal release bundle checker so G14 same-window evidence must name the EC8+4, multi-set, and multi-pool fields covered in that measurement window.

Keep the release gate blocked when same-window evidence omits one of the required G14 fields, without changing production runtime behavior.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require two-hour measured ABBA windows (#7493)

Reject measured Scanner/Heal release ABBA manifests and summaries whose evidence window is shorter than the W21 two-hour release requirement.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF idle checkpoint cleanup (#7497)

Add a runtime cleanup regression test that publishes both retained replay and runtime committed checkpoints, writes scoped and legacy journals, and verifies idle cleanup removes every recovery anchor from the registered local disks.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(e2e): ignore untracked files in build identity (#7498)

Align e2e_test build provenance with Scanner/Heal evidence receipts and server binary provenance by treating only tracked source changes as dirty.

This prevents unrelated untracked worktrees or evidence directories from causing compiled test identity mismatches before real evidence cases can run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF disk-full evidence fields (#7499)

Require the Scanner/Heal release registry and bundle checker to carry explicit G08 MRF capacity, disk-full, and replica-loss evidence fields before release approval can pass.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): bind release JSON artifact provenance (#7500)

Require scanner/heal release evidence JSON artifacts to repeat their measured source revision, run identity, measurement window, gate, and field identity inside the artifact payload. This keeps a refreshed outer bundle hash from accepting stale summary or profile JSON from another run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF rollback mirror filtering (#7501)

Add a regression oracle that keeps scoped-only MRF responsibilities in the authoritative runtime snapshot while omitting them from the v1 legacy rollback mirror.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require segment activation evidence (#7504)

* test(scanner): require segment activation evidence

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): cover activation proof inputs

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF crash matrix cases (#7505)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require release bundle domain evidence (#7502)

Reject scanner/heal release bundles that omit field-level domain evidence for scoped ACK, durable intent, mixed-version, scheduler pressure, profile cost, and two-hour pressure lanes.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF retention GC evidence (#7506)

Require P4 retained-responsibility release bundle evidence to list the retained replay anchor and idle cleanup cases, prove a two-hour retention window, and record both idle cleanup and verified-proof discharge observations.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF cleanup GC soak evidence (#7507)

Require the P4 release bundle to carry measured cleanup/GC soak evidence for retained MRF replay responsibilities. The bundle now needs a two-hour cleanup window, exact cleanup case coverage, observed verified idle GC, and zero pending responsibilities or stale journals after GC.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover MRF disk-full commit anchors (#7509)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): report port allocator bind errors (#7510)

Surface the scanned port window, attempt count, and last bind error when the e2e port allocator cannot reserve a localhost port. This keeps Scanner/Heal evidence failures actionable when the environment blocks binds before business assertions run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(sse): resolve 1.0.0 SSE/KMS blockers and P1 findings (#7511)

* fix(sse): resolve bucket default encryption per request

PUT and the POST-object/extract path resolved a bucket's default
encryption with a hard-coded "no explicit SSE-C" flag, so the default was
layered onto a request that already carried an SSE-C header triple and
then tripped that request's own mutual-exclusion check. Every bucket with
default encryption refused SSE-C single PUTs with 400 InvalidArgument,
while CreateMultipartUpload on the same bucket succeeded because it
resolves SSE elsewhere. Both call sites now derive the flag from the
request headers, as COPY already did.

The bucket default's KMS key id was also inherited independently of the
effective algorithm, so an explicit AES256 request against an aws:kms
default bucket produced a self-contradictory algorithm/key-id pair and
was rejected. The key id is now inherited only when the effective
algorithm is aws:kms, matching the storage-layer resolver.

Refs backlog#2368 B1, B2.

* fix(sse): refuse SSE-KMS without a running KMS service

A write requesting aws:kms on a node with no KMS service fell back to the
node-local SSE-S3 provider: the data key was wrapped with
RUSTFS_SSE_S3_MASTER_KEY while the object metadata still recorded
aws:kms and the requested KMS key id. The stored object claimed a KMS
protection it never had, under a key that was never consulted, and no
signal distinguished it from a genuine SSE-KMS object.

The managed-encryption path now asks the resolved DEK provider whether it
wraps with a node-local master key and refuses SSE-KMS in that case:
InvalidRequest when KMS was never configured, ServiceUnavailable when a
configured service is not running. The check sits after the per-key
authorization gate so an unauthorized caller still receives AccessDenied
whatever the KMS runtime state is, and asks the provider rather than a
parallel availability signal because the provider is what actually wraps
the key. A missing master key no longer answers an SSE-KMS request with
an SSE-S3-worded configuration error.

The SSE-S3 local fallback is unchanged.

Refs backlog#2368 B4.

* fix(ecstore): restore and archive tiers in stored coordinates

Multipart restore addressed the remote tier in plaintext coordinates
while the copy-back reads the stored representation. Each part received a
misaligned slice of the remote object whose length still satisfied the
range, the hash reader and the completion size check, so the restore
reported success and silently replaced the object's bytes. Encrypted and
compressed multipart objects were both affected. Restore now accumulates
stored part sizes, passes the stored length to the hash reader alongside
the plaintext length, and validates against the stored size.

The copy-back digests stored bytes, so its computed MD5 is not the
object's public ETag. Restore now preserves the object ETag on both the
single-part and multipart paths, and gives each restored part its own
recorded part ETag rather than the object-level value.

Transition also handed the tier the object's SSE headers and its
RustFS-wrapped data key as request headers. Any S3 target rejected an
SSE-C archive outright, an SSE-KMS archive asked the target to encrypt a
second time under a key id it does not own, and the wrapped DEK left the
cluster. The archive request now strips every SSE header and encryption
marker with the predicate the replication path already uses; the local
xl.meta keeps all of it, so read-through and restore are unaffected.

Objects restored by an affected release are not detected or repaired
retroactively and must be re-restored from the tier.

Refs backlog#2368 B3, B5; backlog#2369 P7.1.

* fix(rio): lock the v1 nonce layout within a segment

Decrypting a v1 segment tried three historical nonce layouts per frame,
independently for every frame. The last of them exists for streams
written before 1.0.0-alpha.91, which reused a segment's part nonce for
every block in it; because block zero's derived nonce equals that base
nonce, a frame encrypted at index zero authenticated at any position. An
attacker able to rewrite the underlying shards could replay it and have
the forged plaintext returned with 200 and an unchanged length. Shard
integrity uses a keyed-hash-free checksum, which such an attacker can
recompute, so it is not a barrier.

A segment now locks onto whichever layout decoded its first non-zero-index
frame and rejects any later frame needing a different one. That leaves one
residual shape: a stream built purely from repeats of frame zero has no
later frame to disagree. New RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK
(default true, so pre-alpha.91 objects keep decrypting) drops the third
layout entirely when set to false, which closes it. Turning it off refuses
pre-alpha.91 objects, so migrate them first by rewriting in place.

Refs backlog#2369 P2.

* fix(kms): reload a service that failed to start

POST /rustfs/admin/v3/kms/reload short-circuited whenever the persisted
configuration matched the in-memory one byte for byte. A node whose KMS
failed to start keeps that configuration and sits in Error, so the
documented recovery call returned "reloaded successfully" while leaving
the node down. Peers reached the same path through the reload broadcast,
so a cluster that lost Vault during a rolling restart had no working
recovery route other than the node-local start endpoint. Reload now
short-circuits only for a service that is actually running, and otherwise
reconfigures, which starts a service that is not running.

The AWS backend also advertised key-version enumeration through
kms/status, which its own documentation says it cannot do; the capability
and its golden snapshot now say false.

Refs backlog#2369 P1, P7.3.

* docs: record the SSE and KMS changes for 1.0.0

The Unreleased changelog section carried no entry for any encryption work
merged since 1.0.0-rc.5, including three items with operational impact:
the config-secret variable whose absence persists secrets in cleartext
with only a warning, the v2 frame write switch and its rolling-upgrade
constraint, and per-key authorization making a public bucket incompatible
with SSE-KMS objects. Adds those plus this batch, including the SSE-KMS
refusal as a breaking change with both routes out.

Also corrects four places where documentation contradicted the code: the
cleanup register still called encrypted range seek opt-in after its
default flipped, the Helm README claimed vault_mount_path only applies to
Transit while the template also feeds the KV2 mount, the disaster-recovery
drill listed bundle contents for backends whose export is refused with
501, and the Chinese README capability table predated most of the feature
set. Documents the SSE-S3 local master key as a first-class operational
mode with its rotation dead end, and what the v1 frame layout does and
does not authenticate.

Refs backlog#2369 P5.

* fix(kms): classify data-path KMS failures by what the caller can do

Only "key not found" and a backend outage were classified; every other
KMS failure that reached the S3 data path fell through to
500 InternalError with a generic message. A disabled or pending-deletion
key, a denied KMS grant, an encryption-context mismatch, an unsupported
algorithm, a credential or timeout failure, and a capability the
configured backend does not have all looked identical to a server fault.
SDKs therefore applied exponential backoff to configuration errors no
retry can fix, and monitoring counted every one of them against the
server's own error rate.

Unusable-key and request-side failures now answer 400, a denied grant
403, transient backend failures 503, and a missing backend capability
501. Damaged, unreadable, or unknown-format key material keeps its 500:
it is a server-side integrity fault, and existing tests pin it.

The classifier is deliberately separate from the admin lifecycle
mapping, which answers 404 for a missing key because there a key id is
the resource being addressed; on the data path it arrives inside a
request header or a bucket default. Messages either name what the caller
asked for or stay generic, with deployment-side detail left on the error
source the way the storage-IO mapping already does.

Refs backlog#2368 B6.

* fix(kms): track and renew static Vault tokens

Token authentication hard-coded "this token carries no lease", so the
renewal task never started, the remaining-TTL gauge was never published,
and nothing looked wrong. `vault token create` grants a 768-hour TTL by
default, so a cluster that had been healthy for a month turned every KMS
call into a 403 and could not recover without a restart or a
reconfigure. Production configuration validation only rejects the
literal dev-token, so an ordinary expiring token reaches a whole cluster.

The source now reads `auth/token/lookup-self` at login and adopts what
Vault reports. A token with no expiry behaves exactly as before. An
expiring renewable one is picked up by the existing renewal loop and
renewed at half TTL like every other auth method. An expiring
non-renewable one warns with its remaining lifetime and publishes the
gauge, so the fail-closed window is visible before it arrives.

The probe never fails the login: a policy that omits lookup-self, or a
Vault that is briefly unreachable, warns and falls back to exactly the
previous behaviour rather than taking down a deployment that works
today. The scripted Vault test double answers the lookup out of band so
existing scripts keep describing only the protocol under test.

Refs backlog#2369 P3.

* feat(sse): report SSE-C requests that arrive without TLS

An SSE-C request carries the customer's AES key in a request header, so
AWS S3 and MinIO both refuse one that did not arrive over TLS. RustFS
accepted them on any transport: a plaintext hop hands the key to anyone
on the path, and since the object cannot be read without that same key,
the exposure lasts as long as the object does.

Refusing outright is the correct end state but not a safe default to
adopt inside a release window, because the project's own s3-tests and
e2e lanes and most staging deployments speak plain HTTP. This release
reports instead: each such request increments
rustfs_ssec_plaintext_requests_total and logs one warning per process, so
an operator can confirm nothing would break before the default flips.
RUSTFS_SSE_C_REQUIRE_TLS=true opts into the AWS 400 now.

The verdict is per connection rather than per deployment: the layer is
built with whether this listener terminated TLS, and additionally accepts
an https protocol forwarded by a proxy the trusted-proxy configuration
already vetted. It sits beside the rate limiter, after the layer that
makes a forwarded protocol trustworthy and after the request context, so
a rejection can echo the request id.

Refs backlog#2369 P7.2.

* fix(kms): say what a node-local backend means for a cluster

The Local backend keeps key material on each node's own disk and
generates its Argon2id salt per node, so two nodes derive different keys
from the same master_key and an object encrypted on one node cannot be
decrypted on another. Behind a load balancer that surfaces as
intermittent 500s on reads that succeeded moments earlier, with nothing
tying the symptom to the cause: the only signal was a generic
"development, testing and demos only" positioning warning that says
nothing about what actually breaks.

Configuring or reconfiguring Local while the deployment is distributed
now logs a dedicated event and appends the consequence to the configure
response, so the operator who made the change sees it. The product
decision to warn rather than refuse is unchanged.

Refs backlog#2369 P7.4.

* docs: record the remaining SSE and KMS changes for 1.0.0

Adds changelog entries for the KMS data-path status classification, the
Vault static-token lease probe, the SSE-C plaintext-transport report and
its switch, and the node-local backend warning.

Documents two things the backend security guide never stated: that SSE-C
belongs on a secure transport, with the counter and switch to plan the
change around, and that the Local backend cannot be shared by a
multi-node deployment because each node derives different keys from the
same master key.

Refs backlog#2368 B6; backlog#2369 P3, P5, P7.2, P7.4.

* fix(kms): report an unreadable key store as an outage on the S3 path

A backend now distinguishes a key store it could not read from a key
that is genuinely absent, but the S3 boundary collapsed the first one
back onto 500 InternalError through the fallthrough for integrity
faults. The distinction was therefore invisible to the client: a
temporary key-directory outage looked exactly like a permanently damaged
key record, and neither the status nor the metric said the request was
worth retrying.

An unreadable key store joins the retryable class and answers 503, next
to a backend error and a credential failure. Damaged, unreadable or
unknown-format key material keeps its 500.

Refs backlog#2368 B6; builds on rustfs/rustfs#7470.

* test(storage): cover MRF cleanup delete ENOSPC anchor (#7513)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require heal retry stats evidence (#7514)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add release bundle dry-run fixture (#7515)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): confirm recovery intent accept readback (#7516)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require MRF cleanup delete ENOSPC evidence (#7518)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): require bounded retry window evidence (#7517)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(e2e): emit scanner heal G09 upgrade evidence (#7519)

Record measured Scanner/Heal G09 evidence artifacts from the upgrade compatibility lanes when a fresh evidence directory is provided.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(heal): persist MRF rollback mirror boundary (#7520)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add G09 upgrade evidence runner (#7522)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(scanner): add G09 upgrade evidence runner

Add a reusable Linux x86_64 runner for the Scanner/Heal G09 mixed-version and rollback upgrade evidence lanes.

The helper reads the pinned previous-release asset metadata from the upgrade workflow, verifies the downloaded binary, builds the current head, runs both ignored E2E tests, and fails unless the expected G09 JSON artifacts exist.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): implement V2 evidence preflights (#7523)

* feat(scanner): wire dirty usage producer identities

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add segment activation preflight proof

Keep scanner segment reuse behind a structured activation preflight so release evidence can prove the production gate remains disabled until every producer, generation, overflow, cold-oracle, and distributed invalidation check is satisfied.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): expose distributed invalidation evidence

Record an explicit distributed segment invalidation evidence summary when remote dirty usage snapshots are bound to the current activity window and the authenticated scoped ACK capability probe succeeds.

Reject peer dirty usage snapshots that contradict the peer activity pending bit so scoped ACKs fail closed instead of clearing an unadvertised remote mutation.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): assemble release evidence bundles (#7526)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add G09 upgrade evidence runner (#7524)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(scanner): add G09 upgrade evidence runner

Add a reusable Scanner/Heal G09 runner for Linux PR-head validation. The script downloads the pinned previous release, builds the current checkout, runs the mixed-version and rollback upgrade E2E lanes, and validates the measured raw artifacts before they can be consumed by the release bundle gate.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): replay recovery intents while disabled (#7521)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* test(scanner): preflight G09 evidence disk space (#7527)

* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics…

* fix(list): reconcile split overwrite metadata (#7910)

fix(list): fail closed on split overwrite metadata

* fix(heal): persist explicit delete-marker purges (#7908)

* fix(heal): persist explicit delete-marker purges

* fix(ecstore): remove unused object info wrapper

* test(rpc): use valid incarnation in digest gate

---------

Signed-off-by: Hauser <housemecn@gmail.com>
Co-authored-by: Hauser <housemecn@gmail.com>

* ci(table): pass --outcome to the backlog issue manager (#7914)

The manager step failed with 'the following arguments are required:
--outcome' in the first table run (hidden by continue-on-error): every
other suite workflow passes the test step outcome through.

* fix(list): reconcile encoded directory objects (#7915)

* fix(ci): restore release checks after marker purge changes (#7918)

* fix(rpc): recover tier reloads after peer capability revocation (#7920)

* test(ecstore): drain expiry workers before restart (#7923)

* test: report tier cleanup recovery progress on timeout (#7922)

* fix(heal): resume replacement generation after target restart (#7926)

* ci(release): use sm-standard-4 for Linux builds (#7925)

* feat(release): add workspace cargo publish tooling

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(release): prepare 1.0.1 metadata

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix

* revert

---------

Signed-off-by: houseme <housemecn@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Hauser <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: cxymds <cxymds@gmail.com>
Co-authored-by: hector <42570491+majinghe@users.noreply.github.com>
Co-authored-by: xiaomage <devops008@sina.com>
Co-authored-by: GatewayJ <835269233@qq.com>
Co-authored-by: hehutu <hehutu@gmail.com>
Co-authored-by: Henry Guo <marshawcoco@gmail.com>
Co-authored-by: RJ Regenold <rregenold@teamraft.com>
Co-authored-by: RJ Regenold <214054+rjregenold@users.noreply.github.com>
Co-authored-by: Jason Kossis <jkossis@gmail.com>
Co-authored-by: Hiroaki KAWAI <1468181+hkwi@users.noreply.github.com>
Co-authored-by: Hiroaki KAWAI <hiroaki.kawai@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 18:15:35 +08:00
cxymds a58ac62618 fix(rebalance): defer retryable source cleanup failures (#7977)
Source cleanup treated a retryable conflict such as a lock acquisition
timeout as a permanent cleanup warning. The warning was persisted, mapped
the pool to failed_ignored, and blocked pool completion for the rest of
the run while the source replica stayed on disk. Retryable storage
failures now defer the bucket instead, and only non-retryable failures
keep a permanent warning, so completion still requires a resolved delete
or a successful not-found check.

Recording a bucket deferral no longer overwrites the pool's pending
migration-deferral marker, because that marker is the only signal keeping
a pool from completing at the free-space goal while an entry is retried.

Teach the transient matchers the rendered lock timeout text, unwrap
data-movement stage context before classifying not-found and transient
cleanup failures, and raise the bounded source cleanup deferral budget.
2026-09-17 10:14:54 +00:00
Hauser 36c6c2e34b fix(lock): contain slow peer RPC admission storms (#7970)
Add per-peer acquisition backoff, degraded in-flight admission, deadline propagation, bounded late-release cleanup, and rate-limited diagnostics for slow lock endpoints. Preserve quorum and fail-closed held-lock refresh semantics.

Refs: rustfs/backlog#2579

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-17 10:01:16 +00:00
Hauser d6729a1138 fix(heal): prevent MRF starvation of integrity repairs (#7963)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-17 08:18:26 +00:00
Hauser 07a9d15ab2 fix(readiness): smooth pool metadata timeout flaps (#7956)
Keep recent writable observations during transient pool metadata inspection timeouts while preserving confirmed write blocks and fail-closed behavior. Consolidate the runtime observation cache into one ordered state machine and expose timeout metrics.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-17 06:53:55 +00:00
186 changed files with 15439 additions and 1497 deletions
@@ -101,6 +101,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### SSE and on-disk storage invariants
- `GHSA-xrrf-67jm-3c2r`: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
- `GHSA-wqmc-vjgv-jrpw`: SSE-C persisted and listed the plaintext MD5 as the ETag, allowing list-only users to confirm or brute-force low-entropy content and correlate equal plaintext across keys. Lesson: encrypted-object ETags must not expose deterministic plaintext fingerprints; protect them with per-object key material and keep listing, conditional, copy, and multipart semantics consistent.
### Object Lock and retention invariants
@@ -126,7 +127,7 @@ rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonical
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
rg -n "TONIC_RPC_PREFIX|verify_rpc_signature|check_auth|NodeServiceServer|x-rustfs-signature" rustfs crates
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
rg -n "HashReader|EncryptReader|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
rg -n "HashReader|EncryptReader|try_resolve_etag|ETag|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
rg -n "ObjectLock|object_lock|retention|COMPLIANCE|GOVERNANCE|delete_prefix|lifecycle|scanner" rustfs crates
rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
```
@@ -145,5 +146,5 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths.
+1
View File
@@ -29,6 +29,7 @@ script-tests: ## Run shell script tests
./scripts/test_entrypoint_credentials.sh
./scripts/test_internode_grpc_ab_bench.sh
./scripts/test_object_batch_bench_enhanced.sh
./scripts/test_package_service_scripts.sh
./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh
+9
View File
@@ -24,8 +24,11 @@ on:
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_service_scripts.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
@@ -40,8 +43,11 @@ on:
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_service_scripts.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
@@ -158,6 +164,9 @@ jobs:
- name: Check package version contract
run: ./scripts/test_package_versions.sh
- name: Check package service lifecycle contract
run: ./scripts/test_package_service_scripts.sh
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
+1 -3
View File
@@ -818,9 +818,7 @@ jobs:
echo ""
echo "🐳 Docker Images:"
if [[ "$BUILD_TYPE" == "preview" ]]; then
echo "⏭️ Preview tags do not publish Docker images"
elif [[ "$INPUT_BUILD_DOCKER" == "false" ]]; then
if [[ "$INPUT_BUILD_DOCKER" == "false" ]]; then
echo "⏭️ Docker image build was skipped (binary only build)"
elif [[ "$BUILD_STATUS" == "success" ]]; then
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
+68 -7
View File
@@ -47,7 +47,7 @@ on:
default: true
type: boolean
version:
description: "Version to build (latest for stable release, or specific version like v1.0.0, v1.0.0-alpha1)"
description: "Version to build (latest, v1.0.0, v1.0.0-alpha1, or v1.0.1-preview.1)"
required: false
default: "latest"
type: string
@@ -82,8 +82,7 @@ jobs:
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main' &&
!contains(github.event.workflow_run.head_branch, '-preview'))
github.event.workflow_run.head_branch != 'main')
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
@@ -197,7 +196,14 @@ jobs:
version="${version#v}"
fi
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
if [[ "$version" =~ -preview\.[0-9]+$ ]]; then
build_type="preview"
is_prerelease=true
echo "🔍 Building Docker image for preview: $version (version tags only)"
elif [[ "$version" == *"-preview"* ]]; then
echo "❌ Invalid preview tag: $version (expected suffix: -preview.<number>)" >&2
exit 1
elif [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
build_type="prerelease"
is_prerelease=true
# Pre-GA policy: prereleases update latest until the first stable tag exists.
@@ -249,11 +255,14 @@ jobs:
echo "🚀 Building with latest stable release version"
;;
*-preview*)
if [[ ! "$input_version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?-preview\.[0-9]+$ ]]; then
echo "❌ Invalid preview version: $input_version" >&2
exit 1
fi
build_type="preview"
is_prerelease=true
should_build=false
should_push=false
echo "⏭️ Preview tags do not publish Docker images"
version="${input_version#v}"
echo "🔍 Building with preview version: $version (version tags only)"
;;
# Prerelease versions (must match first, more specific)
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
@@ -542,6 +551,54 @@ jobs:
if-no-files-found: error
retention-days: 30
# Docker Hub's overview is a separate `full_description` field that
# `docker push` never touches, so it drifted from README.md and kept
# linking to pages that no longer exist (#7995). Republish it from the
# commit the images were built from, only when images were pushed.
sync-dockerhub-description:
name: Sync Docker Hub Description
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
sparse-checkout: README.md
sparse-checkout-cone-mode: false
- name: Sync Docker Hub description
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.REGISTRY_DOCKERHUB }}
readme-filepath: ./README.md
# README.md links to docs/ and CONTRIBUTING.md by relative path;
# rewrite those to github.com URLs so they resolve on Docker Hub.
enable-url-completion: true
# Docker Hub caps `full_description` at 25,000 bytes and the action
# truncates to fit with only a warning, which would publish a README
# cut off mid-sentence. README.md is already near the cap, so read the
# published overview back and fail if it hit the limit.
- name: Check published description was not truncated
env:
REPOSITORY: ${{ env.REGISTRY_DOCKERHUB }}
run: |
limit=25000
published=$(curl -sSf "https://hub.docker.com/v2/repositories/${REPOSITORY}/" \
| jq -j '.full_description' | wc -c | tr -d ' ')
echo "Published overview: ${published} bytes (Docker Hub limit: ${limit})"
# Truncation lands within one UTF-8 character of the cap.
if [ "$published" -ge $((limit - 4)) ]; then
echo "::error file=README.md::Docker Hub overview was truncated at ${limit} bytes; trim README.md before the next release."
exit 1
fi
# Docker build summary
docker-summary:
name: Docker Build Summary
@@ -563,6 +620,10 @@ jobs:
echo ""
case "$BUILD_TYPE" in
"preview")
echo "🔍 Preview Docker image has been built with ${VERSION} tags"
echo "⏭️ Preview images do not update latest or prerelease channels"
;;
"release")
echo "🚀 Release Docker image has been built with ${VERSION} tags"
echo "✅ This image is ready for production use"
+26 -62
View File
@@ -117,10 +117,15 @@ jobs:
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
# dpkg versions must start with a digit and cannot contain hyphens;
@@ -146,37 +151,13 @@ jobs:
/etc/default/rustfs
CONFFILES
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
POSTINST
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
@@ -214,10 +195,15 @@ jobs:
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
fpm -s dir -t rpm \
@@ -231,35 +217,13 @@ jobs:
--description "High-performance distributed object storage" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--after-install <(cat << 'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTINST
) \
--before-remove <(cat << 'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
) \
--after-remove <(cat << 'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
) \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
--config-files /etc/default/rustfs \
"rustfs-nightly-${DEB_DATE}/usr/bin/rustfs=/usr/bin/rustfs" \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
+26 -62
View File
@@ -328,10 +328,15 @@ jobs:
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
cat > "${PKG_DIR}/DEBIAN/control" << EOF
@@ -354,37 +359,13 @@ jobs:
/etc/default/rustfs
CONFFILES
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
POSTINST
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
@@ -422,10 +403,15 @@ jobs:
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
fpm -s dir -t rpm \
@@ -439,35 +425,13 @@ jobs:
--description "High-performance distributed object storage" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--after-install <(cat <<'POSTINST'
#!/bin/bash
set -e
if ! getent passwd rustfs > /dev/null 2>&1; then
useradd -r -s /bin/false -d /opt/rustfs rustfs
fi
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTINST
) \
--before-remove <(cat <<'PRERM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
systemctl stop rustfs
fi
PRERM
) \
--after-remove <(cat <<'POSTRM'
#!/bin/bash
set -e
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
POSTRM
) \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
--config-files /etc/default/rustfs \
./bin/rustfs=/usr/bin/rustfs \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
@@ -0,0 +1,221 @@
# RustFS Fault-Tolerance (degradation) Matrix Sweep
#
# Exhaustive topology x EC outage sweep, complementary to the scenario suite
# (rustfs-fault-tolerance-test.yml). For each topology (nodes x drives per
# node) and each EC level P in 1..N*drives/2 the runner installs that exact
# cluster shape and drives progressive node outages plus recovery:
#
# M-<topo>EC<P>-full full-capacity reads/writes must succeed
# M-<topo>EC<P>-n<k> with k nodes online: reads succeed iff the online
# drives cover the read quorum (N-P), writes iff they
# cover the write quorum (N-P+1); probes below quorum
# assert the expected outage
# M-<topo>EC<P>-rec after restoring every node, reads/writes succeed
#
# 10 topologies x EC levels = 38 combinations, 320 cases. The full sweep runs
# 3-5 hours and is dispatched on demand (e.g. after a nightly chain goes
# green), not nightly. Case ids and expectations come from product source
# (default_parity_count, erasure set sizing) via rustfs/auto-testing.
name: RustFS Fault-Tolerance Matrix
on:
workflow_dispatch:
inputs:
package_url:
description: 'Direct .deb URL. Required unless the nightly default is wanted.'
required: false
type: string
auto_testing_ref:
description: 'auto-testing branch, tag, or SHA (defaults to main)'
required: false
default: main
type: string
matrix_topo:
description: 'Topology filter like 4x2; empty sweeps all ten topologies'
required: false
default: ''
type: string
matrix_ec:
description: 'EC/parity filter; empty sweeps every EC level'
required: false
default: ''
type: string
cleanup_before:
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
type: boolean
default: true
cleanup_after:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
permissions:
contents: read
# The suite stops services and revokes drive access on the shared fleet; only one
# functional suite may touch the environment at a time.
concurrency:
group: rustfs-shared-functional-tests-v2
cancel-in-progress: false
defaults:
run:
shell: bash
env:
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
fault-tolerance-matrix:
runs-on: smoke-testing
# The full 38-combination sweep takes 3-5h; 360 is the runner job ceiling.
timeout-minutes: 360
steps:
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-fault-tolerance-matrix-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch" "${FUNCTIONAL_ARTIFACTS_DIR}/evidence"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'RESULTS_FILE=%s/results.json\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'EVIDENCE_DIR=%s/evidence\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
- name: Checkout auto-testing scripts
timeout-minutes: 5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_ref }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Record auto-testing revision
run: |
set -euo pipefail
AUTO_TESTING_SHA="$(git -C auto-testing rev-parse HEAD)"
printf 'AUTO_TESTING_SHA=%s\n' "${AUTO_TESTING_SHA}" >> "${GITHUB_ENV}"
echo "auto-testing revision: ${AUTO_TESTING_SHA}"
- name: Show environment
run: |
uname -a
jq --version
aws --version
echo "auto-testing: ${AUTO_TESTING_SHA}"
df -h /data | tail -1
- name: Cleanup environment (before)
timeout-minutes: 5
if: ${{ inputs.cleanup_before != 'false' }}
run: |
./auto-testing/rustfs-fault-tolerance-test.sh --cleanup -y --log-file "${LOG_FILE}"
- name: Run matrix sweep (topology x EC outage combinations)
timeout-minutes: 350
id: test
# No continue-on-error: an UNEXPECTED verdict (a probe that should
# fail by quorum math but passes, or vice versa) is the finding this
# sweep exists to surface, and the red run is the signal until matrix
# runs get backlog integration like the scenario suite has.
env:
MATRIX_EC: ${{ inputs.matrix_ec }}
run: |
set -euo pipefail
ARGS=(--matrix -y --package-url "${{ inputs.package_url || env.RUSTFS_NIGHTLY_PACKAGE_URL }}" --log-file "${LOG_FILE}" --results-file "${RESULTS_FILE}")
if [ -n "${{ inputs.matrix_topo }}" ]; then
# Trailing value form: consumed as `--matrix-topo <topo> [ec]`.
ARGS+=(--matrix-topo "${{ inputs.matrix_topo }}")
if [ -n "${MATRIX_EC}" ]; then
ARGS+=("${MATRIX_EC}")
fi
fi
./auto-testing/rustfs-fault-tolerance-test.sh "${ARGS[@]}"
- name: Generate report
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
{
echo "# RustFS fault-tolerance matrix report"
echo ""
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Package: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "- auto-testing: \`${AUTO_TESTING_SHA}\`"
echo "- Topology filter: ${{ inputs.matrix_topo || 'all (10 topologies)' }}"
echo "- EC filter: ${{ inputs.matrix_ec || 'all levels' }}"
echo ""
echo "## Per-probe results"
echo ""
echo '```'
grep -E '^FT-(CASE|COMPLETE|SUMMARY|REPORT)' "${LOG_FILE}" || echo "(no FT-CASE lines found)"
echo '```'
echo ""
echo "## Health snapshots"
echo ""
for f in "${FUNCTIONAL_ARTIFACTS_DIR}"/evidence/*.code; do
[ -e "${f}" ] || continue
printf '%s -> %s\n' "$(basename "${f}" .code)" "$(cat "${f}")"
done
} > "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
# An unfiltered sweep must land exactly 320 cases (38 topology x EC
# combinations at 2*(nodes+1) cases each); filtered sweeps are held
# to internal consistency. Missing/harness-error rows always fail.
EXPECTED_TOTAL=""
if [ -z "${{ inputs.matrix_topo }}" ] && [ -z "${MATRIX_EC}" ]; then
EXPECTED_TOTAL=320
fi
jq -e --arg testing_sha "${AUTO_TESTING_SHA}" --argjson expected_total "${EXPECTED_TOTAL:-null}" '
.schema_version == 1 and
.auto_testing_sha == $testing_sha and
.complete == true and
.counts.expected == ($expected_total // .counts.expected) and
.counts.expected == .counts.seen and
.counts.harness_errors == 0 and
(.expected_cases | length) == .counts.expected and
(.expected_cases | unique | length) == .counts.expected and
(.seen_cases | length) == .counts.seen and
(.missing_cases | length) == 0 and
(.harness_errors | length) == 0 and
(.cases | length) == .counts.seen and
([.cases[].case_id] | unique | length) == .counts.seen and
([.cases[].case_id] | sort) == (.expected_cases | sort) and
all(.cases[]; (.case_id | startswith("M-")) and (.verdict == "pass" or .verdict == "known-divergence" or .verdict == "UNEXPECTED")) and
.counts.unexpected == ([.cases[] | select(.verdict == "UNEXPECTED")] | length) and
.counts.known_divergence == ([.cases[] | select(.verdict == "known-divergence")] | length)
' "${RESULTS_FILE}" >/dev/null
- name: Upload test logs & evidence
timeout-minutes: 2
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-fault-tolerance-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results.json
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/evidence/
if-no-files-found: warn
- name: Cleanup environment (after)
timeout-minutes: 5
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./auto-testing/rustfs-fault-tolerance-test.sh --cleanup -y --log-file "${LOG_FILE}" || true
@@ -4,13 +4,14 @@
# behavior under drive and node loss against the erasure-coding contract and
# snapshots health-endpoint responses at every tier.
#
# A single-node 4 drives (SNMD): hide 1/2/3 drives, restore
# A single-node 4 drives (SNMD): revoke access to 1/2/3 drives, restore
# B multi-node 4x1 (one drive per node): stop 1/2/3 nodes, restore
# C multi-node 4x4 (16 drives, EC:4): stop 1 node (read-quorum boundary),
# stop 2 nodes, restore
# C2 multi-node 4x4 with EC:8: 2 nodes down puts 8 drives online -- reads
# satisfy the EC read quorum while the lock majority is broken (the
# reported divergence window: reads 503 with lock_quorum_unavailable)
# D multi-node 4x1: stop 1/2 nodes, then restore
#
# Expectations come from product source (default_parity_count, erasure set
# sizing). By default a "reads refused although the read quorum is met"
@@ -32,6 +33,11 @@ on:
description: 'Direct .deb URL. Required unless the nightly default is wanted.'
required: false
type: string
auto_testing_ref:
description: 'auto-testing branch, tag, or SHA (defaults to main)'
required: false
default: main
type: string
strict:
description: 'Fail the suite when reads are refused despite a met read quorum'
type: boolean
@@ -52,7 +58,7 @@ on:
permissions:
contents: read
# The suite stops services and hides drive dirs on the shared fleet; only one
# The suite stops services and revokes drive access on the shared fleet; only one
# functional suite may touch the environment at a time.
concurrency:
group: rustfs-shared-functional-tests-v2
@@ -92,6 +98,7 @@ jobs:
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'RESULTS_FILE=%s/results.json\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'EVIDENCE_DIR=%s/evidence\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
@@ -109,16 +116,24 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: ${{ steps.chain.outputs.testing_sha || 'main' }}
ref: ${{ steps.chain.outputs.testing_sha || inputs.auto_testing_ref || 'main' }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Record auto-testing revision
run: |
set -euo pipefail
AUTO_TESTING_SHA="$(git -C auto-testing rev-parse HEAD)"
printf 'AUTO_TESTING_SHA=%s\n' "${AUTO_TESTING_SHA}" >> "${GITHUB_ENV}"
echo "auto-testing revision: ${AUTO_TESTING_SHA}"
- name: Show environment
run: |
uname -a
jq --version
aws --version
echo "auto-testing: ${AUTO_TESTING_SHA}"
df -h /data | tail -1
- name: Cleanup environment (before)
@@ -134,14 +149,14 @@ jobs:
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
run: python3 scripts/prepare_functional_package.py prepare
- name: Run fault-tolerance scenarios (A, B, C, C2)
- name: Run fault-tolerance scenarios (A, B, C, C2, D, E)
timeout-minutes: 45
id: test
# Standalone case failures are reported by the backlog manager.
# Chain evidence separately requires complete passing cases.
continue-on-error: true
run: |
ARGS=(--all -y --package-url "${{ steps.chain_package.outputs.package_url || inputs.package_url || env.RUSTFS_NIGHTLY_PACKAGE_URL }}" --log-file "${LOG_FILE}")
ARGS=(--all -y --package-url "${{ steps.chain_package.outputs.package_url || inputs.package_url || env.RUSTFS_NIGHTLY_PACKAGE_URL }}" --log-file "${LOG_FILE}" --results-file "${RESULTS_FILE}")
if [ "${{ inputs.strict }}" = "true" ]; then
ARGS+=(--strict)
fi
@@ -158,12 +173,13 @@ jobs:
echo ""
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Package: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "- auto-testing: \`${AUTO_TESTING_SHA}\`"
echo "- Strict mode: ${{ inputs.strict || 'false' }}"
echo ""
echo "## Per-probe results"
echo ""
echo '```'
grep -E '^FT-(CASE|SUMMARY|REPORT)' "${LOG_FILE}" || echo "(no FT-CASE lines found)"
grep -E '^FT-(CASE|COMPLETE|SUMMARY|REPORT)' "${LOG_FILE}" || echo "(no FT-CASE lines found)"
echo '```'
echo ""
echo "## Health snapshots"
@@ -174,19 +190,30 @@ jobs:
done
} > "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
# Product gate: failing FT cases keep the run green — the report above and
# the backlog issue manager carry the signal. Only harness/environment
# breakdowns (no case verdicts at all) turn the workflow red.
FT_CASES="$(grep -cE '^FT-CASE:' "${LOG_FILE}" 2>/dev/null || true)"
FT_FAILED="$(grep -cE '^FT-CASE: .* verdict=UNEXPECTED' "${LOG_FILE}" 2>/dev/null || true)"
FT_PASSED=$(( ${FT_CASES:-0} - ${FT_FAILED:-0} ))
HARNESS_OK=0
if [ "${{ steps.test.outcome }}" = "success" ]; then
HARNESS_OK=1
elif [ "${{ steps.test.outcome }}" = "failure" ] && [ "${FT_CASES}" -gt 0 ] && [ "${FT_PASSED}" -ge 1 ]; then
HARNESS_OK=1
fi
[ "${HARNESS_OK}" = "1" ]
printf 'complete=false\n' >> "${GITHUB_OUTPUT}"
jq -e --arg testing_sha "${AUTO_TESTING_SHA}" '
.schema_version == 1 and
.auto_testing_sha == $testing_sha and
.selected_scenarios == ["A", "B", "C", "C2", "D", "E"] and
.complete == true and
.counts.expected == 44 and
.counts.seen == 44 and
.counts.harness_errors == 0 and
(.expected_cases | length) == 44 and
(.expected_cases | unique | length) == 44 and
(.seen_cases | length) == 44 and
(.seen_cases | unique | length) == 44 and
(.seen_cases | sort) == (.expected_cases | sort) and
(.missing_cases | length) == 0 and
(.harness_errors | length) == 0 and
(.cases | length) == 44 and
([.cases[].case_id] | sort) == (.expected_cases | sort) and
([.cases[].case_id] | unique | length) == 44 and
all(.cases[]; .verdict == "pass" or .verdict == "known-divergence" or .verdict == "UNEXPECTED") and
.counts.unexpected == ([.cases[] | select(.verdict == "UNEXPECTED")] | length) and
.counts.known_divergence == ([.cases[] | select(.verdict == "known-divergence")] | length)
' "${RESULTS_FILE}" >/dev/null
printf 'complete=true\n' >> "${GITHUB_OUTPUT}"
- name: Manage backlog issues (dedup / label / auto-close)
timeout-minutes: 2
@@ -196,7 +223,7 @@ jobs:
# green run. Never acts on cancelled runs. Logic lives in
# auto-testing/scripts/issue_manager.py, which parses the
# FT-CASE verdict lines from the suite log.
if: ${{ always() && steps.evidence.outcome == 'success' }}
if: ${{ always() && steps.evidence.outcome == 'success' && steps.chain_report.outputs.complete == 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
@@ -272,6 +299,7 @@ jobs:
name: rustfs-fault-tolerance-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results.json
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/evidence/
if-no-files-found: warn
@@ -54,7 +54,10 @@ jobs:
- name: Resolve published candidate
id: candidate
env:
GH_TOKEN: ${{ github.token }}
# The staleness probe in resolve_functional_candidate.py reads the
# private rustfs/auto-testing repo; github.token cannot, and a probe
# failure silently keeps a stale testing_sha pinned for the chain.
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
CHAIN_OUTPUT: ${{ runner.temp }}/chain-candidate.json
@@ -61,10 +61,13 @@ on:
permissions:
contents: read
# The default performance nodes overlap the other suites' remote VMs, even
# though the runner differs. Hold the shared lock through cleanup as well.
# Workflow-level so the lock is held through cleanup as well.
concurrency:
group: rustfs-shared-functional-tests-v2
# Own group: the performance suite runs on the pf-testing fleet - a fully
# separate environment from the smoke-testing fleet the functional suites
# use. No shared node, no shared runner: it must never queue behind them
# (or block them).
group: rustfs-performance-suite
cancel-in-progress: false
defaults:
Generated
+91 -77
View File
@@ -685,9 +685,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.47"
version = "0.4.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef217a77a86a6e3dab9a5b3c81dc445b603fe743a90c1cb10a2f2144628d8cfa"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
dependencies = [
"compression-codecs",
"compression-core",
@@ -812,7 +812,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -961,9 +961,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.9.2"
version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -989,9 +989,9 @@ dependencies = [
[[package]]
name = "aws-sdk-kms"
version = "1.118.0"
version = "1.120.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c243864bc3754be9f0001414e0162fdf1370fa62c70b0cfbe1da6a6221d553c7"
checksum = "d6b0fe38fee2ba5b6cd24d32d08365b314ae1adea123649e061a7eb6300b6f5b"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1015,9 +1015,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.146.1"
version = "1.148.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd651b4400d4011b8927b83a9552bf90ff11e6e5da0b9f0a7583247aceec971"
checksum = "8a61c1db3987ab6c8740fb87f248864687d50549401115244a84f02c3047b2d5"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1052,9 +1052,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.109.0"
version = "1.111.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3cfe74df5d9ad2fedd691973ad3521ebf4f27a3c68c792556686aedb5519bab"
checksum = "65700d75f6da78c89a61a49f1539979400a4609d3329376b1dc2133d7a1b2b90"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1078,9 +1078,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.111.0"
version = "1.113.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81b0ec31ed6191bd11350aae4b2004198f2db21350cb0a20c57e0a92e55dd161"
checksum = "eb1147c254af7b6cee3ace90ae7da6f21366ce9a9b899035fa8a0fdf586eb75a"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1104,9 +1104,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.114.0"
version = "1.116.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef45745026107ec30c4ef86bd8ae4b002e7e5f6a86e4225240bdf6b06a0b944a"
checksum = "035c7392e751d20bf1060d625d023587eb14099646110a9cff7c697bfe4b253a"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1131,9 +1131,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.5.1"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f"
checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -1737,7 +1737,7 @@ dependencies = [
"prettyplease 0.3.0",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1984,9 +1984,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.6"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1996,9 +1996,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
@@ -2141,7 +2141,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2222,9 +2222,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.42"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "257c7085cbb71be72d8fb97edff08b03d86d5d9f2222b9cc34a6bec87093bc10"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
dependencies = [
"brotli 8.0.4",
"bzip2",
@@ -2798,7 +2798,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2842,7 +2842,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -3343,7 +3343,7 @@ checksum = "3a711bd5a06ec85d9683e142d28a98ad0679a7220669f5c1b8589e57e9b29e46"
dependencies = [
"datafusion-doc",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -3715,7 +3715,7 @@ checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -3792,9 +3792,9 @@ dependencies = [
[[package]]
name = "dial9"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b11bce55173f77d9003cce8f129b4af889a4639005c53c3498cef933bc895c"
checksum = "bab33c9f10e909fb3f1f6af2a9d976398ea793b90d1d286b4e23647a7d131d53"
dependencies = [
"bon",
"dial9-core",
@@ -3809,9 +3809,9 @@ dependencies = [
[[package]]
name = "dial9-core"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e8cbbc8955394be626249a3b52ddd6bf664373661eaeae70d87eb12bf6f20b6"
checksum = "ffd7231006b074b02765ae929bded4757a414224b06c7db324d3e6e902f182ee"
dependencies = [
"arc-swap",
"bon",
@@ -3842,9 +3842,9 @@ dependencies = [
[[package]]
name = "dial9-metrique"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d42fe0e3e14e8780c4ecf9ee14c183dbcb9b1ae3612cc9a689e7dc83b13adef1"
checksum = "f987e56dbafcab5998acc8e77eb91aaf66fa5441a691b38e99c8058c70725f58"
dependencies = [
"dial9-core",
"dial9-trace-format",
@@ -3856,9 +3856,9 @@ dependencies = [
[[package]]
name = "dial9-perf-self-profile"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f65948455c504bf08576c7b5cfe93bfcaa486d661dc4ee85c2a6309ab89629"
checksum = "d81f74ada03919614ca5b1ba767271d00980376a5a0e0fc43f8c265bf90413d4"
dependencies = [
"blazesym",
"bon",
@@ -3875,9 +3875,9 @@ dependencies = [
[[package]]
name = "dial9-tokio-telemetry"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1244091367c805b5d98a6a590f8ab992efda06e6d2560a6967ef898ffd30a2"
checksum = "934c027f628c59ae4c9664ca4dce758843c3da027dee5052d358f86a5d292ae6"
dependencies = [
"bon",
"bytes",
@@ -3991,7 +3991,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -4410,9 +4410,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "findshlibs"
@@ -4597,7 +4597,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -5306,9 +5306,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.25.1"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366c5db5a8e4643bdde121a1d6f35cfa035e7850b8b8ea94c6960a139d44e93a"
checksum = "f37234f85a8d1bda6b84d486245dd0847c5b33cca10d5f3691375e66704de0f8"
dependencies = [
"arc-swap",
"async-channel",
@@ -5318,7 +5318,7 @@ dependencies = [
"flate2",
"futures-channel",
"futures-core",
"futures-util",
"futures-sink",
"hdrhistogram",
"hotpath-macros",
"http 1.5.0",
@@ -5339,9 +5339,9 @@ dependencies = [
[[package]]
name = "hotpath-macros"
version = "0.25.1"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "846bde0d9600d98434e1aac376977d7718bfe3d2f5312a041b7c59a6a466c51a"
checksum = "2ac39b5517f540cab511bdbd5a4dc8cfc62858d6cd26a095463c1d2acfb3f290"
dependencies = [
"proc-macro2",
"quote",
@@ -6751,7 +6751,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -8220,7 +8220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -9054,7 +9054,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -10408,6 +10408,8 @@ dependencies = [
"opentelemetry_sdk",
"percent-encoding",
"pyroscope",
"rcgen",
"reqwest",
"rustfs-audit",
"rustfs-common",
"rustfs-config",
@@ -10421,6 +10423,8 @@ dependencies = [
"rustfs-storage-api",
"rustfs-utils",
"rustix",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sysinfo",
@@ -10428,6 +10432,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-util",
"tracing",
"tracing-appender",
@@ -11111,9 +11116,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -11259,8 +11264,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s3s"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d29a4db6a51f1cd3269a36354ce4aa7db2be8816cee42a642b9c85cd3769577"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"arc-swap",
"arrayvec",
@@ -11278,7 +11282,6 @@ dependencies = [
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"httparse",
"hyper",
"itoa",
"jiff",
@@ -11291,6 +11294,7 @@ dependencies = [
"pin-project-lite",
"quick-xml",
"regex",
"s3s-multipart",
"s3s-rfc2047",
"s3s-sigv2",
"s3s-sigv4",
@@ -11315,11 +11319,23 @@ dependencies = [
"zeroize",
]
[[package]]
name = "s3s-multipart"
version = "0.17.0-alpha.1"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"arrayvec",
"bytes",
"futures-core",
"httparse",
"memchr",
"thiserror 2.0.20",
]
[[package]]
name = "s3s-rfc2047"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9370a2f353a7929b79ae7b5f692b4758c59aa19526b18feff6a977e8ff17da5a"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"base64-simd",
"thiserror 2.0.20",
@@ -11328,8 +11344,7 @@ dependencies = [
[[package]]
name = "s3s-sigv2"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f277b63ffc9a2135c49d52726199b7436c2f86746d625496984f5bb5cabef050"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"base64-simd",
"hmac 0.13.0",
@@ -11342,8 +11357,7 @@ dependencies = [
[[package]]
name = "s3s-sigv4"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a979a401f6ae4035bc22e4ac0f7a9a5923ba93841c931e8e28652fe211d991bc"
source = "git+https://github.com/s3s-project/s3s.git?rev=50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6#50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6"
dependencies = [
"arrayvec",
"base64-simd",
@@ -11575,7 +11589,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11639,7 +11653,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11684,7 +11698,7 @@ dependencies = [
"darling 0.24.1",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11719,7 +11733,7 @@ checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -12316,9 +12330,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.5"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
@@ -12365,7 +12379,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -12559,7 +12573,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -12696,7 +12710,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -13212,9 +13226,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
@@ -13456,7 +13470,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"wasm-bindgen-shared",
]
@@ -14031,7 +14045,7 @@ checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"synstructure 0.14.0",
]
@@ -14072,7 +14086,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"synstructure 0.14.0",
]
@@ -14126,7 +14140,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
+9 -9
View File
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.1" }
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.47" }
async-compression = { version = "0.4.48" }
async-recursion = "1.1.1"
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
@@ -244,9 +244,9 @@ atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.118.0" }
aws-sdk-s3 = { default-features = false, version = "1.146.1" }
aws-sdk-sts = { default-features = false, version = "1.114.0" }
aws-sdk-kms = { default-features = false, version = "1.120.0" }
aws-sdk-s3 = { default-features = false, version = "1.148.0" }
aws-sdk-sts = { default-features = false, version = "1.116.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.2" }
aws-smithy-runtime-api = { version = "1.16.2" }
@@ -310,11 +310,11 @@ regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.7.0" }
rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.4" }
rustix = { version = "1.1.5" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { version = "0.16.0", features = ["minio"] }
s3s-sigv4 = { version = "0.16.0" }
s3s = { git = "https://github.com/s3s-project/s3s.git", rev = "50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6", features = ["minio"] }
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s.git", rev = "50c94aeb1e5ea9ef8e7393ef6bbc87b72f6891c6" }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
@@ -351,7 +351,7 @@ zstd = "0.14.0"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.0", default-features = false }
dial9 = { version = "0.5.1", default-features = false }
opentelemetry = { version = "0.32.0" }
opentelemetry-appender-tracing = { version = "0.32.0" }
opentelemetry-otlp = { version = "0.32.0" }
@@ -375,7 +375,7 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.4" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.25.1", default-features = false }
hotpath = { version = "0.26.0", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
+9
View File
@@ -663,6 +663,15 @@ pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DET
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
/// Environment variable for the maximum number of in-flight lock acquisition
/// RPCs admitted to one peer. Requests beyond this bound fail fast as
/// retryable contention so a slow endpoint cannot accumulate an unbounded
/// queue while its channel remains healthy.
pub const ENV_OBJECT_LOCK_RPC_REQUEST_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_REQUEST_LIMIT";
/// Default per-peer in-flight lock acquisition RPC admission limit.
pub const DEFAULT_OBJECT_LOCK_RPC_REQUEST_LIMIT: usize = 128;
/// Environment variable to enable object namespace lock diagnostics.
///
/// When enabled, RustFS emits slow lock acquisition and long lock hold
+14
View File
@@ -30,6 +30,9 @@ pub const ENV_OBS_ENDPOINT_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_TIMEOUT_M
pub const ENV_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS";
pub const ENV_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS";
pub const ENV_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS: &str = "RUSTFS_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS";
/// Absolute path to a PEM bundle whose certificates are added to the system
/// trust roots used by OTLP/HTTP exporters.
pub const ENV_OBS_TLS_CA_FILE: &str = "RUSTFS_OBS_TLS_CA_FILE";
pub const ENV_OBS_PROFILING_ENDPOINT: &str = "RUSTFS_OBS_PROFILING_ENDPOINT";
pub const ENV_OBS_USE_STDOUT: &str = "RUSTFS_OBS_USE_STDOUT";
pub const ENV_OBS_SAMPLE_RATIO: &str = "RUSTFS_OBS_SAMPLE_RATIO";
@@ -55,6 +58,13 @@ pub const ENV_OBS_LOG_FILENAME: &str = "RUSTFS_OBS_LOG_FILENAME";
pub const ENV_OBS_LOG_ROTATION_TIME: &str = "RUSTFS_OBS_LOG_ROTATION_TIME";
pub const ENV_OBS_LOG_KEEP_FILES: &str = "RUSTFS_OBS_LOG_KEEP_FILES";
/// Span lifecycle events emitted by the JSON log layers (`none`|`close`|`full`).
///
/// Defaults to `none`: a record per span lifecycle multiplies log volume on
/// object hot paths, and emitting the ancestor span list on every record
/// produced multi-megabyte lines that journald truncated (backlog#2642).
pub const ENV_OBS_LOG_SPAN_EVENTS: &str = "RUSTFS_OBS_LOG_SPAN_EVENTS";
/// Log cleanup related configurations
pub const ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES: &str = "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES";
pub const ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES: &str = "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES";
@@ -99,6 +109,8 @@ pub const DEFAULT_OBS_LOG_DRY_RUN: bool = false;
pub const DEFAULT_OBS_LOG_MATCH_MODE_PREFIX: &str = "prefix";
pub const DEFAULT_OBS_LOG_MATCH_MODE: &str = "suffix";
pub const DEFAULT_OBS_LOG_SPAN_EVENTS: &str = "none";
/// Default values for observability configuration
// ### Supported Environment Values
// - `production` - Secure file-only logging
@@ -141,6 +153,7 @@ mod tests {
assert_eq!(ENV_OBS_LOG_FILENAME, "RUSTFS_OBS_LOG_FILENAME");
assert_eq!(ENV_OBS_LOG_ROTATION_TIME, "RUSTFS_OBS_LOG_ROTATION_TIME");
assert_eq!(ENV_OBS_LOG_KEEP_FILES, "RUSTFS_OBS_LOG_KEEP_FILES");
assert_eq!(ENV_OBS_LOG_SPAN_EVENTS, "RUSTFS_OBS_LOG_SPAN_EVENTS");
assert_eq!(ENV_OBS_TRACES_EXPORT_ENABLED, "RUSTFS_OBS_TRACES_EXPORT_ENABLED");
assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED");
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
@@ -178,6 +191,7 @@ mod tests {
assert_eq!(DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM_GZIP, "gzip");
assert_eq!(DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM_ZSTD, "zstd");
assert_eq!(DEFAULT_OBS_LOG_MATCH_MODE_PREFIX, "prefix");
assert_eq!(DEFAULT_OBS_LOG_SPAN_EVENTS, "none");
assert_eq!(DEFAULT_OBS_LOG_MATCH_MODE, "suffix");
assert_eq!(DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, "zstd");
}
@@ -184,7 +184,7 @@ async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent()
let (status, metrics_body) = admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
"/rustfs/admin/v3/realtime?n=1&by-host=true&by-disk=true",
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
@@ -257,7 +257,7 @@ async fn node_admin_body(dist: &DistCluster, node: usize, path: &str) -> TestRes
}
async fn http_put_counts(dist: &DistCluster, node: usize) -> TestResult<[u64; 2]> {
let body = node_admin_body(dist, node, "/rustfs/admin/v3/metrics?types=512&by-host=true&n=1").await?;
let body = node_admin_body(dist, node, "/rustfs/admin/v3/realtime?types=512&by-host=true&n=1").await?;
let sample: RealtimeMetrics = serde_json::from_str(body.lines().next().ok_or("empty HTTP metrics stream")?)?;
assert!(sample.errors.is_empty(), "HTTP metrics returned errors: {:?}", sample.errors);
let http = sample.aggregated.http.ok_or("HTTP metrics missing at WARN log level")?;
@@ -400,17 +400,36 @@ async fn verify_write_observations_during_peer_failure(dist: &DistCluster, bucke
let observations = storage["info"]["observations"]
.as_array()
.ok_or("recovery omitted observations")?;
let disks = storage["info"]["disks"].as_array().ok_or("recovery omitted disks")?;
Ok(observations.len() == 4
&& observations
.iter()
.all(|item| item["status"] == "succeeded" && item["cached"] == false))
.all(|item| item["status"] == "succeeded" && item["cached"] == false)
&& disks.len() == 16
&& disks.iter().all(|disk| {
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
&& disk["runtimeState"]
.as_str()
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
}))
},
"peer probes recover to fresh successful observations",
"peer probes and remote disks recover to fresh successful observations",
)
.await?;
wait_for_ready(&dist.cluster).await?;
assert!(observed_put(dist, 0, bucket, "recovered").await?.is_success());
assert_eq!(http_put_counts(dist, 0).await?, [baseline[0][0] + 1, baseline[0][1] + 2]);
let recovered_before = http_put_counts(dist, 0).await?;
wait_until(
Duration::from_secs(90),
|| async { Ok(observed_put(dist, 0, bucket, "recovered").await?.is_success()) },
"recovered PUT after remote disk and lock convergence",
)
.await?;
let recovered_after = http_put_counts(dist, 0).await?;
assert_eq!(recovered_after[0], recovered_before[0] + 1);
assert!(
recovered_after[1] >= baseline[0][1] + 2,
"recovery retries must retain the two intentional sub-quorum failures: before={recovered_before:?}, after={recovered_after:?}"
);
for node in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node)?, bucket, "healthy-0", b"write-observation").await?;
assert_object_bytes(&dist.client(node)?, bucket, "recovered", b"write-observation").await?;
+10 -2
View File
@@ -391,8 +391,9 @@ pub mod data_usage {
#[cfg(feature = "test-util")]
pub use crate::data_movement::scanner_backlog::test_util::NativeScannerPauseBacklogWriteFault;
pub use crate::data_movement::scanner_backlog::{
MAX_SCANNER_PAUSE_BACKLOG_BYTES, ScannerPauseBacklogRetirementPlan, ScannerPauseBacklogRetirementPlanner,
ScannerPauseBacklogRetirementReplica, register_scanner_pause_backlog_retirement_planner,
MAX_SCANNER_PAUSE_BACKLOG_BYTES, ScannerPauseBacklogRetirementError, ScannerPauseBacklogRetirementPlan,
ScannerPauseBacklogRetirementPlanner, ScannerPauseBacklogRetirementReplica,
register_scanner_pause_backlog_retirement_planner,
};
pub use crate::data_usage::{
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
@@ -503,6 +504,13 @@ pub mod notification {
};
}
pub mod integrity {
pub use crate::services::integrity::{
IntegrityError, InventoryItem, InventoryPage, ItemRequest, ItemResult, ItemState, Job, JobMode, JobRequest, JobState,
Protection, Readiness, control_job, create_job, get_job, inventory, readiness, resume_job,
};
}
pub mod object {
pub use crate::object_api::{
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
@@ -35,6 +35,7 @@ use crate::bucket::lifecycle::tier_sweeper::{
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::services::notification_sys::acquire_remote_version_state_writer_fleet_proof;
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
use crate::storage_api_contracts::{
list::ListOperations as _,
@@ -1237,6 +1238,12 @@ pub async fn delete_transition_candidate_for_operator(
lease
.validate_remote_version_id(remote_version_id)
.map_err(TransitionOperatorError::Remote)?;
if remote_version_requires_fleet_proof(remote_version_id) && acquire_remote_version_state_writer_fleet_proof().is_none() {
return Err(TransitionOperatorError::Remote(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"opaque remote tier version cleanup requires the operator-attested live fleet capability gate",
)));
}
let before_delete_probe = lease
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
.await
@@ -1845,6 +1852,11 @@ async fn recover_unknown_upload_outcome(
))
}
TransitionCandidateProbe::VersionedPresent(version_id) => {
if remote_version_requires_fleet_proof(&version_id) && acquire_remote_version_state_writer_fleet_proof().is_none() {
return Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
IlmRecoveryErrorCode::RemoteVersionUnknown,
));
}
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
}
TransitionCandidateProbe::Ambiguous => Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
@@ -1940,6 +1952,14 @@ fn transition_source_lookup_options(transaction: &TransitionTransaction) -> Obje
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
if version_id_exact
&& remote_version_requires_fleet_proof(version_id)
&& acquire_remote_version_state_writer_fleet_proof().is_none()
{
return Err(Error::other(
"opaque remote tier version cleanup requires the operator-attested live fleet capability gate",
));
}
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
&transaction.remote_object,
version_id,
@@ -1953,6 +1973,10 @@ async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &Tra
.map_err(Error::other)
}
fn remote_version_requires_fleet_proof(version_id: &str) -> bool {
!version_id.is_empty() && Uuid::parse_str(version_id).is_err()
}
pub async fn recover_transition_transaction_records(
api: Arc<ECStore>,
limit: usize,
@@ -2439,6 +2463,14 @@ mod tests {
}
}
#[test]
fn opaque_remote_versions_require_the_fleet_proof_boundary() {
assert!(!remote_version_requires_fleet_proof(""));
assert!(!remote_version_requires_fleet_proof(&Uuid::new_v4().to_string()));
assert!(remote_version_requires_fleet_proof("null"));
assert!(remote_version_requires_fleet_proof("b2-opaque-version"));
}
#[test]
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
+6 -2
View File
@@ -1464,9 +1464,13 @@ pub(crate) async fn load_bucket_metadata_parse_with_presence(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
)));
}
} else if incarnation.is_some() {
return Err(Error::other("bucket incarnation sidecar exists without bucket metadata"));
}
// A sidecar without `.metadata.bin` is the window between the two legacy
// migration writes (rustfs/rustfs#8003): the sidecar lands first, and a
// crash or lost namespace lease before the metadata write leaves the
// bucket in this state on every node. Report it as not persisted so the
// migration runs again; the writers adopt the stored incarnation instead
// of minting a new one, so the sidecar keeps its authority.
bm.default_timestamps();
+124
View File
@@ -1291,6 +1291,28 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_in(
}
}
/// Inspect all migration-relevant settings while the caller holds an Object Lock
/// snapshot's lifecycle and metadata transaction guards. Cached settings are not
/// sufficient to authorize a direct storage writer that cannot apply S3 defaults.
pub(crate) async fn integrity_migration_metadata_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<Arc<BucketMetadata>> {
let sys = bucket_metadata_sys_of(ctx)?.read().await.clone();
match sys
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
.await?
{
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
Ok(metadata)
}
BucketMetadataAuthority::MissingBucket => Err(Error::BucketNotFound(bucket.to_string())),
_ => Err(Error::other("migration requires authoritative bucket metadata")),
}
}
/// Re-read the quota configuration and bucket incarnation from the same
/// authoritative metadata blob while the caller holds the bucket metadata
/// transaction read lock.
@@ -2504,6 +2526,16 @@ impl BucketMetadataSys {
if !persisted {
metadata = BucketMetadata::new(bucket);
metadata.created = bucket_info.created.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// An interrupted migration may already have published this
// bucket's incarnation; re-read it under the transaction lock so
// the retry never replaces an identity other nodes fenced on. A
// retired incarnation is residue of a deleted bucket and must not
// come back, or heal would reclaim the bucket's new objects.
if let Some(stored) = load_bucket_incarnation(self.object_store(), bucket).await?
&& !crate::bucket::retirement::is_retired(self.object_store(), bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if metadata.bucket_incarnation_id.is_nil() {
metadata.bucket_incarnation_id = Uuid::new_v4();
}
@@ -3556,6 +3588,98 @@ mod tests {
assert!(err.to_string().contains("sidecar is missing"));
}
/// rustfs/rustfs#8003: the legacy migration writes the incarnation sidecar
/// before `.metadata.bin`. A crash or lost namespace lease between the two
/// left every pre-existing bucket answering 500 on every request, with no
/// path back. The sidecar-only state must load as a legacy bucket, and the
/// retried migration must keep the stored incarnation.
#[tokio::test]
async fn issue_8003_sidecar_without_metadata_loads_as_legacy_and_migrates_with_its_incarnation() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore.clone());
let bucket = "issue-8003-sidecar-only";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).unwrap();
}
let stored = Uuid::new_v4();
save_bucket_incarnation(ecstore.clone(), bucket, stored)
.await
.expect("simulate the interrupted migration's first write");
let (fabricated, persisted) = load_bucket_metadata_parse_with_presence(ecstore.clone(), bucket, true)
.await
.expect("a sidecar without metadata must read as a legacy bucket, not fail closed");
assert!(!persisted);
assert!(!fabricated.bucket_incarnation_sidecar);
let (_, fabricated_read) = sys
.get_config(bucket)
.await
.expect("request-path metadata reads must not fail closed on the sidecar-only state");
assert!(fabricated_read);
let migrated = sys
.get_authoritative_metadata(bucket)
.await
.expect("the retried legacy migration must complete");
assert!(migrated.bucket_incarnation_sidecar);
assert_eq!(
migrated.bucket_incarnation_id, stored,
"the retry must adopt the published incarnation instead of minting a new one"
);
assert_eq!(sys.get_bucket_incarnation_id(bucket).await.unwrap(), stored);
let on_disk = sys.get_config_from_disk(bucket).await.expect("metadata is now persisted");
assert!(on_disk.bucket_incarnation_sidecar);
assert_eq!(on_disk.bucket_incarnation_id, stored);
assert_eq!(load_bucket_incarnation(ecstore, bucket).await.unwrap(), Some(stored));
}
/// A sidecar left behind by a deleted bucket names a retired incarnation.
/// The migration must mint a new identity for a same-name volume instead
/// of re-publishing the retired one, or heal would treat the bucket's new
/// objects as reclaimable residue.
#[tokio::test]
async fn issue_8003_sidecar_only_migration_does_not_adopt_a_retired_incarnation() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore.clone());
let bucket = "issue-8003-retired-sidecar";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).unwrap();
}
let retired = Uuid::new_v4();
save_bucket_incarnation(ecstore.clone(), bucket, retired)
.await
.expect("residual sidecar");
crate::bucket::retirement::commit_retirement(
ecstore.clone(),
bucket,
retired,
&ObjectOptions {
max_parity: true,
..Default::default()
},
)
.await
.expect("retirement record");
let migrated = sys
.get_authoritative_metadata(bucket)
.await
.expect("the migration must still complete for the same-name volume");
assert!(migrated.bucket_incarnation_sidecar);
assert_ne!(
migrated.bucket_incarnation_id, retired,
"a retired incarnation must never be re-published"
);
assert!(!migrated.bucket_incarnation_id.is_nil());
assert_eq!(
load_bucket_incarnation(ecstore, bucket).await.unwrap(),
Some(migrated.bucket_incarnation_id),
"the sidecar must be rewritten to the new incarnation"
);
}
/// Concurrent cache misses for one bucket must collapse into a single disk
/// load.
///
@@ -199,6 +199,38 @@ fn has_raw_status(err: &SdkError<HeadObjectError>, status: u16) -> bool {
err.raw_response().is_some_and(|r| r.status().as_u16() == status)
}
fn raw_header_value(err: &SdkError<HeadObjectError>, name: &str) -> Option<String> {
err.raw_response()
.and_then(|response| response.headers().get(name))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DeleteMarkerHeadOutcome {
Missing,
Exists,
Ambiguous,
}
fn classify_delete_marker_head_error(err: &SdkError<HeadObjectError>) -> DeleteMarkerHeadOutcome {
let (is_not_found, code) = err
.as_service_error()
.map(|service_err| (service_err.is_not_found(), service_err.code()))
.unwrap_or((false, None));
// Prefer the raw HTTP status when present. HEAD responses commonly have no
// body, and a contradictory XML code must not turn a 403/5xx into success.
match err.raw_response().map(|response| response.status().as_u16()) {
Some(404) => DeleteMarkerHeadOutcome::Missing,
Some(405) => DeleteMarkerHeadOutcome::Exists,
Some(_) => DeleteMarkerHeadOutcome::Ambiguous,
None if is_not_found => DeleteMarkerHeadOutcome::Missing,
None if !is_retryable_delete_replication_head_error(false, code) => DeleteMarkerHeadOutcome::Exists,
None => DeleteMarkerHeadOutcome::Ambiguous,
}
}
fn metadata_requires_existing_target(op_type: ReplicationType, object_info: &ObjectInfo) -> bool {
op_type == ReplicationType::Metadata
&& object_info
@@ -1809,32 +1841,14 @@ async fn verify_resync_head_result(
(roi.size, None)
}
Err(err) if roi.delete_marker => {
// Verifying a replicated delete marker: only a
// definitive 404/NoSuchKey or 405/MethodNotAllowed
// confirms the marker propagated. Any other
// (retryable/ambiguous) HEAD error leaves the outcome
// unverified, so it must count as failed — not as a
// blanket success (backlog#862 / #799 B13).
// A HEAD carries no body, so the SDK only synthesizes an error
// code for 404; a target answering the delete marker's version
// with 405 (RustFS, MinIO, AWS) leaves `code()` empty and the
// raw status is the only evidence (rustfs/backlog#2479).
// A marker *version purge* (`version_purge_status` set) asks
// for the opposite outcome: the marker must be gone from the
// target, so only absence verifies it and a 405 proves the
// marker is still there.
let retryable = {
let (is_not_found, code) = err
.as_service_error()
.map(|se| (se.is_not_found(), se.code()))
.unwrap_or((false, None));
let not_found = is_not_found || has_raw_status(&err, 404);
if roi.version_purge_status.is_empty() {
let code = code.or_else(|| has_raw_status(&err, 405).then_some("405"));
is_retryable_delete_replication_head_error(not_found, code)
} else {
!not_found
}
// A marker creation is verified by either absence (404) or
// presence (405). A marker-version purge has the opposite goal:
// only absence proves completion. All other outcomes remain
// failed (backlog#862 / #799 B13).
let retryable = if roi.version_purge_status.is_empty() {
matches!(classify_delete_marker_head_error(&err), DeleteMarkerHeadOutcome::Ambiguous)
} else {
!matches!(classify_delete_marker_head_error(&err), DeleteMarkerHeadOutcome::Missing)
};
if retryable {
st.failed_count += 1;
@@ -3553,7 +3567,7 @@ async fn replicate_delete_to_target(
}
}
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
if !is_version_purge && dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
match head_object_for_worker(
tgt_client.as_ref(),
&tgt_client.bucket,
@@ -3562,20 +3576,48 @@ async fn replicate_delete_to_target(
)
.await
{
Ok(_) => {}
Ok(head) if head.delete_marker == Some(true) => {
// Some targets expose an existing marker as a successful HEAD
// with the marker bit instead of the usual 405. Treat that as
// positive convergence evidence and retain the target version
// id for any later marker purge.
rinfo.target_delete_marker_version_id = head.version_id.filter(|version_id| !version_id.is_empty());
rinfo.replication_status = ReplicationStatusType::Completed;
return rinfo;
}
Ok(_) => {
// A successful HEAD without the delete-marker bit proves that
// the addressed version is a regular object (or that the
// target omitted the marker contract). Creating a marker in
// that state would hide a live version, so fail closed.
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some("target version is not a delete marker".to_string());
return rinfo;
}
Err(e) => {
let non_retryable = matches!(
e.as_ref(),
SdkError::ServiceError(service_err)
if is_retryable_delete_replication_head_error(
service_err.err().is_not_found(),
service_err.err().code(),
)
);
if non_retryable {
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(e.to_string());
return rinfo;
match classify_delete_marker_head_error(e.as_ref()) {
DeleteMarkerHeadOutcome::Exists => {
// An explicit HEAD of a delete-marker version is expected
// to answer 405 (often without an SDK error code). That
// is positive evidence the marker already exists, so the
// creation is complete and must not issue another
// versionless DELETE that would mint a second marker.
if let Some(version_id) = raw_header_value(e.as_ref(), "x-amz-version-id") {
rinfo.target_delete_marker_version_id = Some(version_id);
}
rinfo.replication_status = ReplicationStatusType::Completed;
return rinfo;
}
DeleteMarkerHeadOutcome::Missing => {}
DeleteMarkerHeadOutcome::Ambiguous => {
// Only a definitive not-found means the marker is absent
// and may be created. Keep transport and ambiguous
// failures fail-closed instead of creating blindly.
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(e.to_string());
mark_replication_target_offline_if_needed(&tgt_client, &e).await;
return rinfo;
}
}
}
}
@@ -6529,6 +6571,110 @@ mod tests {
}
}
fn marker_creation_dobj(target_arn: &str) -> DeletedObjectReplicationInfo {
let mut state = ReplicationState::default();
state.targets.insert(target_arn.to_string(), ReplicationStatusType::Pending);
DeletedObjectReplicationInfo {
bucket: "source".to_string(),
target_arn: target_arn.to_string(),
op_type: ReplicationType::Heal,
delete_object: ReplicationDeletedObject {
object_name: "gone.txt".to_string(),
delete_marker: true,
delete_marker_version_id: Some(Uuid::new_v4()),
replication_state: Some(state),
..Default::default()
},
..Default::default()
}
}
/// A target that already has the marker answers a versioned HEAD with a
/// bodiless 405. The live worker must settle the marker as completed and
/// avoid minting another marker with a versionless DELETE.
#[tokio::test]
async fn live_delete_marker_worker_accepts_bodiless_405_without_delete() {
let (endpoint, server) = spawn_scripted_target_server(1, |_| {
"HTTP/1.1 405 Method Not Allowed\r\nX-Amz-Version-Id: target-marker\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string()
});
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Completed, "{rinfo:?}");
assert!(rinfo.error.is_none(), "{rinfo:?}");
assert_eq!(rinfo.target_delete_marker_version_id.as_deref(), Some("target-marker"));
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 1, "an existing marker must not be re-created: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
}
/// A successful HEAD that does not identify a delete marker is not
/// convergence evidence: creating a marker would hide a live version.
#[tokio::test]
async fn live_delete_marker_worker_rejects_successful_regular_head_without_delete() {
let (endpoint, server) = spawn_scripted_target_server(1, |_| empty_response("200 OK"));
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Failed, "{rinfo:?}");
assert_eq!(rinfo.error.as_deref(), Some("target version is not a delete marker"));
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 1, "a regular object must not be hidden by a new marker: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
}
/// A definitive 404 means the marker is absent, so exactly one marker
/// creation DELETE is still required.
#[tokio::test]
async fn live_delete_marker_worker_creates_marker_after_404() {
let (endpoint, server) = spawn_scripted_target_server(2, |line| {
if line.starts_with("HEAD ") {
empty_response("404 Not Found")
} else if line.starts_with("DELETE ") {
empty_response("204 No Content")
} else {
empty_response("500 Unexpected")
}
});
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Completed, "{rinfo:?}");
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 2, "a missing marker should be created once: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
assert!(seen[1].starts_with("DELETE "), "{seen:?}");
assert!(!seen[1].contains("versionId="), "marker creation must remain versionless: {seen:?}");
}
/// Ambiguous service errors must remain failed and must not fall through
/// to a blind marker-creation DELETE.
#[tokio::test]
async fn live_delete_marker_worker_keeps_ambiguous_head_failure_closed() {
let (endpoint, server) = spawn_scripted_target_server(1, |_| empty_response("503 Service Unavailable"));
let target = test_target_client(endpoint);
register_test_target(&target).await;
let dobj = marker_creation_dobj(&target.arn);
let rinfo = replicate_delete_to_target(&dobj, target.clone(), None).await;
assert_eq!(rinfo.replication_status, ReplicationStatusType::Failed, "{rinfo:?}");
assert!(rinfo.error.is_some(), "{rinfo:?}");
let seen = server.join().expect("test HTTP server should finish");
assert_eq!(seen.len(), 1, "an ambiguous HEAD must not trigger DELETE: {seen:?}");
assert!(seen[0].starts_with("HEAD "), "{seen:?}");
}
/// A target answers `HEAD ?versionId=<delete marker>` with a bodiless
/// 405, so the SDK reports no error code. That is the marker having
/// propagated, not a failure (rustfs/backlog#2479).
@@ -23,7 +23,7 @@ use std::{
future::Future,
io::ErrorKind,
pin::Pin,
sync::{Arc, OnceLock},
sync::{Arc, OnceLock, atomic::AtomicBool},
task::{Context, Poll},
time::Duration,
};
@@ -234,6 +234,8 @@ pub struct ListPathRawOptions {
pub skip_walkdir_total_timeout: bool,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
/// Shared terminal state for bounded producer walks.
pub producer_limit_reached: Option<Arc<AtomicBool>>,
pub agreed: Option<AgreedFn>,
pub partial: Option<PartialFn>,
pub finished: Option<FinishedFn>,
@@ -267,6 +269,7 @@ impl Clone for ListPathRawOptions {
skip_walkdir_total_timeout: self.skip_walkdir_total_timeout,
walkdir_timeout: self.walkdir_timeout,
walkdir_stall_timeout: self.walkdir_stall_timeout,
producer_limit_reached: self.producer_limit_reached.clone(),
#[cfg(test)]
test_reader_behaviors: self.test_reader_behaviors.clone(),
#[cfg(test)]
@@ -292,6 +295,7 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
skip_total_timeout: opts.skip_walkdir_total_timeout,
timeout_ms: opts.walkdir_timeout.map(duration_millis),
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
producer_limit_reached: opts.producer_limit_reached.clone(),
..Default::default()
}
}
+338 -1
View File
@@ -1170,6 +1170,11 @@ impl RemoteDisk {
self.health.force_runtime_state_for_test(state);
}
#[cfg(test)]
pub fn force_offline_for_test(&self) {
self.health.force_offline_for_test();
}
/// Same as [`DiskHealthTracker::reset_for_store_init_retry`]: undo a transient faulty mark before another format load attempt.
pub fn reset_health_for_store_init_retry(&self) {
self.health.reset_for_store_init_retry(&self.endpoint);
@@ -2136,7 +2141,7 @@ impl RemoteDisk {
let file_info_bin = encode_file_info_msgpack(fi)?;
let mut client = self.get_client().await?;
let mut request = Request::new(RenameDataRequest {
bucket_incarnation_id: crate::store::bucket_heal_scope(dst_volume)
bucket_incarnation_id: crate::store::bucket_heal_scope_for_object(dst_volume, dst_path)
.map(|scope| scope.incarnation.as_bytes().to_vec().into())
.unwrap_or_default(),
disk: self.endpoint.to_string(),
@@ -4303,6 +4308,13 @@ mod tests {
object_read_all_disks: Arc<StdMutex<Vec<String>>>,
format_data: Bytes,
read_all_data: Bytes,
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
}
#[derive(Debug, Default)]
struct TestFormatReadGate {
entered: tokio::sync::Notify,
release: CancellationToken,
}
impl AuthenticatedReadPeer {
@@ -4314,9 +4326,16 @@ mod tests {
object_read_all_disks: Arc::default(),
format_data,
read_all_data,
next_format_read_gate: Arc::default(),
}
}
fn pause_next_format_read(&self) -> Arc<TestFormatReadGate> {
let gate = Arc::new(TestFormatReadGate::default());
*self.next_format_read_gate.lock().expect("format gate lock poisoned") = Some(Arc::clone(&gate));
gate
}
fn disk_info_calls(&self) -> u32 {
self.disk_info_calls.load(Ordering::Acquire)
}
@@ -4424,6 +4443,11 @@ mod tests {
let disk = request.disk;
peer.read_all_calls.fetch_add(1, Ordering::AcqRel);
let data = if is_format_read {
let gate = peer.next_format_read_gate.lock().expect("format gate lock poisoned").take();
if let Some(gate) = gate {
gate.entered.notify_one();
gate.release.cancelled().await;
}
peer.format_data.clone()
} else {
peer.object_read_all_disks
@@ -7126,6 +7150,319 @@ mod tests {
peer.stop().await;
}
async fn missing_read_set(peer: &TestGrpcPeer, format: crate::layout::format::FormatV3) -> Arc<crate::set_disk::SetDisks> {
let count = format.erasure.sets[0].len();
let endpoints = (0..count)
.map(|index| Endpoint {
url: url::Url::parse(&format!("{}/data/rustfs{index}", peer.addr)).expect("endpoint should parse"),
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: i32::try_from(index).expect("fixture index fits"),
})
.collect();
crate::set_disk::SetDisks::new(
"missing-read-test".to_string(),
Arc::new(tokio::sync::RwLock::new(vec![None; count])),
count,
0,
0,
0,
endpoints,
format,
Vec::new(),
)
.await
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_reconnect_before_periodic_monitor_and_coalesce() {
runtime_sources::ensure_test_rpc_secret();
let mut format = crate::layout::format::FormatV3::new(1, 1);
let disk_id = format.erasure.sets[0][0];
format.erasure.this = disk_id;
let Some(peer) = TestGrpcPeer::spawn(
Bytes::from(format.to_json().expect("format should serialize")),
Bytes::from_static(b"startup-recovered-data"),
)
.await
else {
panic!("authenticated regression peer is required");
};
let set = missing_read_set(&peer, format).await;
let snapshots = futures::future::join_all((0..8).map(|_| set.get_disks_for_data_read())).await;
let disk = snapshots[0][0]
.as_ref()
.expect("a ready peer must be usable before the periodic reconnect");
for snapshot in &snapshots {
assert!(Arc::ptr_eq(
disk,
snapshot[0]
.as_ref()
.expect("every waiting read should observe the same handle")
));
}
assert_eq!(peer.peer.read_all_calls(), 1, "concurrent requests must share the format probe");
assert_eq!(disk.get_disk_id().await.expect("disk identity should be available"), Some(disk_id));
let raw = disk
.read_all("bucket", "object")
.await
.expect("reconnected handle should read data");
assert_eq!(raw, Bytes::from_static(b"startup-recovered-data"));
assert_eq!(peer.peer.object_read_all_disks(), vec![disk_id.to_string()]);
let healthy = set.get_disks_for_data_read().await;
assert!(Arc::ptr_eq(disk, healthy[0].as_ref().expect("healthy handle must remain registered")));
assert_eq!(peer.peer.read_all_calls(), 2, "healthy reads must not re-probe the format");
disk.close().await.expect("reconnected disk should close");
peer.stop().await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_reject_foreign_format_and_back_off() {
runtime_sources::ensure_test_rpc_secret();
let reference = crate::layout::format::FormatV3::new(1, 1);
let mut foreign = reference.clone();
foreign.id = Uuid::new_v4();
foreign.erasure.this = reference.erasure.sets[0][0];
let Some(peer) = TestGrpcPeer::spawn(
Bytes::from(foreign.to_json().expect("foreign format should serialize")),
Bytes::from_static(b"foreign-data"),
)
.await
else {
panic!("authenticated regression peer is required");
};
let set = missing_read_set(&peer, reference).await;
assert!(
set.get_disks_for_data_read().await[0].is_none(),
"a matching disk ID cannot authorize a foreign deployment"
);
assert_eq!(peer.peer.read_all_calls(), 1, "the foreign format must actually be inspected");
assert!(set.get_disks_for_data_read().await[0].is_none());
assert_eq!(peer.peer.read_all_calls(), 1, "failed probes must not repeat on every degraded GET");
assert!(peer.peer.object_read_all_disks().is_empty(), "foreign object data must never be read");
peer.stop().await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_reject_wrong_slot_and_invalid_formats() {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 2);
reference.erasure.this = reference.erasure.sets[0][0];
let mut nil = reference.clone();
nil.erasure.this = Uuid::nil();
let mut unknown = reference.clone();
unknown.erasure.this = Uuid::new_v4();
let mut future = reference.clone();
future.erasure.version = crate::layout::format::FormatErasureVersion::Unknown;
for (name, payload, first_accepted) in [
("duplicate slot zero", reference.to_json().expect("format should serialize"), true),
("nil drive", nil.to_json().expect("nil format should serialize"), false),
("unknown drive", unknown.to_json().expect("unknown format should serialize"), false),
("future version", future.to_json().expect("future format should serialize"), false),
("malformed", "not-json".to_string(), false),
] {
let peer = TestGrpcPeer::spawn(Bytes::from(payload), Bytes::new())
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference.clone()).await;
let disks = set.get_disks_for_data_read().await;
assert_eq!(disks[0].is_some(), first_accepted, "{name}: slot zero identity decision");
assert!(disks[1].is_none(), "{name}: a peer cannot claim another topology slot");
assert_eq!(peer.peer.read_all_calls(), 2, "{name}: both missing slots must be probed");
assert!(peer.peer.object_read_all_disks().is_empty(), "{name}: no object payload may be read");
for disk in disks.into_iter().flatten() {
disk.close().await.expect("accepted disk should close");
}
peer.stop().await;
}
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_do_not_claim_local_or_misconfigured_endpoints() {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
let peer = TestGrpcPeer::spawn(Bytes::from(reference.to_json().expect("format should serialize")), Bytes::new())
.await
.expect("authenticated regression peer is required");
for (local, pool, set_index, disk_index) in [(true, 0, 0, 0), (false, -1, 0, 0), (false, 0, 1, 0), (false, 0, 0, 1)] {
let mut set = missing_read_set(&peer, reference.clone()).await;
let endpoint = &mut Arc::get_mut(&mut set)
.expect("fixture set must be uniquely owned")
.set_endpoints[0];
endpoint.is_local = local;
endpoint.pool_idx = pool;
endpoint.set_idx = set_index;
endpoint.disk_idx = disk_index;
assert!(set.get_disks_for_data_read().await[0].is_none());
}
assert_eq!(peer.peer.read_all_calls(), 0, "invalid endpoint ownership must fail before I/O");
peer.stop().await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_preserve_concurrently_published_handles() {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
for reader_first in [false, true] {
let peer = TestGrpcPeer::spawn(Bytes::from(reference.to_json().expect("format should serialize")), Bytes::new())
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference.clone()).await;
let gate = peer.peer.pause_next_format_read();
let pending_set = Arc::clone(&set);
let pending = tokio::spawn(async move {
if reader_first {
pending_set.get_disks_for_data_read().await;
} else {
pending_set.renew_disk(&pending_set.set_endpoints[0]).await;
}
});
time::timeout(Duration::from_secs(2), gate.entered.notified())
.await
.expect("the first reconnect must reach the format barrier");
if reader_first {
set.renew_disk(&set.set_endpoints[0]).await;
} else {
set.get_disks_for_data_read().await;
}
let published = set.disks.read().await[0]
.clone()
.expect("the competing reconnect must publish");
gate.release.cancel();
pending.await.expect("paused reconnect must complete");
assert!(
Arc::ptr_eq(&published, set.disks.read().await[0].as_ref().expect("published slot must remain")),
"neither reconnect may replace a handle published while its format I/O was pending"
);
published.close().await.expect("published disk should close");
peer.stop().await;
}
}
#[tokio::test]
#[serial(remote_disk_recovery_probe, internode_metrics)]
async fn missing_read_disks_expired_waiter_does_not_start_another_probe() {
// Disable cooldown so this independently proves deadline admission,
// rather than passing only because the leader has just finished.
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_RETURNING_PROBE_INTERVAL_SECS, Some("0"))], async {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
let peer = TestGrpcPeer::spawn(Bytes::from(reference.to_json().expect("format should serialize")), Bytes::new())
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference).await;
let gate = peer.peer.pause_next_format_read();
let mut leader = Box::pin(set.get_disks_for_data_read());
time::timeout(Duration::from_secs(2), async {
tokio::select! {
_ = gate.entered.notified() => {}
_ = &mut leader => panic!("leader must remain blocked in its format RPC"),
}
})
.await
.expect("leader must reach the format barrier");
time::pause();
let mut waiter = Box::pin(set.get_disks_for_data_read());
assert!(futures::poll!(&mut waiter).is_pending(), "waiter must queue behind the leader");
let requests_before =
crate::cluster::rpc::runtime_sources::internode_metrics_snapshot_for_test().outgoing_requests_total;
time::advance(crate::disk::disk_store::get_drive_active_check_timeout()).await;
assert!(leader.await[0].is_none(), "expired leader must not publish a disk");
assert!(waiter.await[0].is_none(), "expired waiter must not publish a disk");
time::resume();
// Count client dispatches, not server arrivals: a cancelled RPC can still
// be buffered in the transport when the expired waiter returns.
assert_eq!(
crate::cluster::rpc::runtime_sources::internode_metrics_snapshot_for_test().outgoing_requests_total,
requests_before,
"an expired waiter must not dispatch another format RPC after the leader releases the lock"
);
gate.release.cancel();
peer.stop().await;
})
.await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_timeout_releases_singleflight_and_allows_retry() {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
let peer = TestGrpcPeer::spawn(Bytes::from(reference.to_json().expect("format should serialize")), Bytes::new())
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference).await;
let gate = peer.peer.pause_next_format_read();
let deadline = crate::disk::disk_store::get_drive_active_check_timeout();
let snapshots = time::timeout(
deadline + Duration::from_secs(2),
futures::future::join_all((0..4).map(|_| set.get_disks_for_data_read())),
)
.await
.expect("all waiting requests must respect the bounded probe deadline");
assert!(snapshots.iter().all(|snapshot| snapshot[0].is_none()));
assert_eq!(peer.peer.read_all_calls(), 1, "only one format RPC may remain stalled");
gate.release.cancel();
time::pause();
time::advance(crate::disk::health_state::get_drive_returning_probe_interval()).await;
time::resume();
let disks = set.get_disks_for_data_read().await;
let disk = disks[0]
.as_ref()
.expect("a timed-out probe must not prevent a later read from reconnecting");
assert_eq!(peer.peer.read_all_calls(), 2, "retry must validate the format again");
disk.close().await.expect("reconnected disk should close");
peer.stop().await;
}
#[tokio::test]
#[serial(remote_disk_recovery_probe)]
async fn missing_read_disks_cancellation_leaves_no_published_handle() {
runtime_sources::ensure_test_rpc_secret();
let mut reference = crate::layout::format::FormatV3::new(1, 1);
reference.erasure.this = reference.erasure.sets[0][0];
let peer = TestGrpcPeer::spawn(Bytes::from(reference.to_json().expect("format should serialize")), Bytes::new())
.await
.expect("authenticated regression peer is required");
let set = missing_read_set(&peer, reference).await;
let gate = peer.peer.pause_next_format_read();
let pending_set = Arc::clone(&set);
let pending = tokio::spawn(async move { pending_set.get_disks_for_data_read().await });
time::timeout(Duration::from_secs(2), gate.entered.notified())
.await
.expect("probe must reach the format barrier");
// Cancellation can happen after the start-based cooldown has elapsed.
// It must still cool down before another caller probes the same peer.
time::pause();
time::advance(crate::disk::health_state::get_drive_returning_probe_interval()).await;
time::resume();
pending.abort();
assert!(pending.await.expect_err("probe must be cancelled").is_cancelled());
assert!(
set.disks.read().await[0].is_none(),
"cancelled format I/O must not publish an unvalidated handle"
);
let waiting = time::timeout(Duration::from_secs(1), set.get_disks_for_data_read())
.await
.expect("cancelled probe must release the singleflight lock");
assert!(waiting[0].is_none(), "retry cooldown must still apply after cancellation");
assert_eq!(peer.peer.read_all_calls(), 1);
gate.release.cancel();
peer.stop().await;
}
#[tokio::test]
async fn test_copy_stream_with_buffer_copies_full_payload() {
let payload = b"walk-dir-stream".repeat(1024);
+573 -51
View File
@@ -47,10 +47,37 @@ fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(req
/// Work to run if an RPC that already timed out for its caller completes later.
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
type LateFailure = LateCompletion<()>;
struct RpcCleanup<T> {
late: LateCompletion<T>,
late_failure: LateFailure,
admission: Option<LockRequestAdmission>,
}
impl<T> RpcCleanup<T> {
fn new(late: LateCompletion<T>, late_failure: LateFailure, admission: Option<LockRequestAdmission>) -> Self {
Self {
late,
late_failure,
admission,
}
}
}
/// The liveness window is this many RPC deadlines: a peer that completed a
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
const LOCK_RPC_REQUEST_BACKOFF_THRESHOLD: u32 = 3;
const LOCK_RPC_REQUEST_BACKOFF_MAX: Duration = Duration::from_secs(30);
const LATE_RELEASE_RETRY_DELAYS: [Duration; 3] = [Duration::from_millis(100), Duration::from_millis(500), Duration::from_secs(1)];
const LATE_RELEASE_IN_FLIGHT_LIMIT: usize = 32;
const TIMEOUT_LOG_STATE_MAX: usize = 1024;
const TIMEOUT_LOG_STATE_RETENTION: Duration = Duration::from_secs(600);
fn is_request_breaker_operation(op: &'static str) -> bool {
matches!(op, "lock" | "lock_batch")
}
/// Recent history of the shared lock channel to one peer (issue #7363).
///
@@ -64,15 +91,44 @@ struct LockPeerChannelHealth {
last_success: Option<Instant>,
last_eviction: Option<Instant>,
consecutive_timeouts: u32,
request_consecutive_timeouts: u32,
request_backoff_until: Option<Instant>,
request_backoff: Duration,
request_probe_generation: Option<u64>,
next_probe_generation: u64,
request_in_flight: usize,
late_release_in_flight: usize,
/// Timed-out RPCs still running in the background for this peer.
detached_rpcs: usize,
}
#[derive(Debug, Clone, Copy, Default)]
struct TimeoutLogHealth {
last_logged: Option<Instant>,
suppressed: u64,
}
#[derive(Debug, Clone, Copy)]
struct LockRequestAdmission {
probe_generation: Option<u64>,
}
impl LockRequestAdmission {
fn is_probe(self) -> bool {
self.probe_generation.is_some()
}
}
fn lock_peer_channel_health() -> &'static Mutex<HashMap<String, LockPeerChannelHealth>> {
static HEALTH: OnceLock<Mutex<HashMap<String, LockPeerChannelHealth>>> = OnceLock::new();
HEALTH.get_or_init(Mutex::default)
}
fn timeout_log_health() -> &'static Mutex<HashMap<(String, &'static str), TimeoutLogHealth>> {
static HEALTH: OnceLock<Mutex<HashMap<(String, &'static str), TimeoutLogHealth>>> = OnceLock::new();
HEALTH.get_or_init(Mutex::default)
}
fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannelHealth) -> R) -> R {
let mut peers = lock_peer_channel_health()
.lock()
@@ -343,17 +399,163 @@ impl RemoteClient {
)
}
fn request_limit() -> usize {
rustfs_utils::get_env_usize(
rustfs_config::ENV_OBJECT_LOCK_RPC_REQUEST_LIMIT,
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_REQUEST_LIMIT,
)
.max(1)
}
fn liveness_window(deadline: Duration) -> Duration {
deadline.saturating_mul(LOCK_RPC_LIVENESS_WINDOW_DEADLINES)
}
fn record_rpc_success(&self) {
fn record_rpc_success(&self, op: &'static str, admission: Option<LockRequestAdmission>) {
with_lock_peer_health(&self.addr, |health| {
health.last_success = Some(Instant::now());
health.consecutive_timeouts = 0;
if is_request_breaker_operation(op) {
let is_probe = admission.is_some_and(LockRequestAdmission::is_probe);
if health.request_backoff_until.is_none() || is_probe {
health.request_consecutive_timeouts = 0;
health.request_backoff_until = None;
health.request_backoff = Duration::ZERO;
}
if is_probe && admission.is_some_and(|admission| health.request_probe_generation == admission.probe_generation) {
health.request_probe_generation = None;
}
}
});
}
fn admit_late_release(&self) -> bool {
with_lock_peer_health(&self.addr, |health| {
if health.late_release_in_flight >= LATE_RELEASE_IN_FLIGHT_LIMIT {
false
} else {
health.late_release_in_flight += 1;
true
}
})
}
fn release_late_release(&self) {
with_lock_peer_health(&self.addr, |health| {
health.late_release_in_flight = health.late_release_in_flight.saturating_sub(1);
});
}
/// Admit a new lock acquisition unless this peer is in the request-level
/// backoff window. A single half-open probe is allowed after the window;
/// channel liveness and request admission are deliberately separate.
fn admit_lock_request(&self, op: &'static str) -> Option<LockRequestAdmission> {
if !is_request_breaker_operation(op) {
return Some(LockRequestAdmission { probe_generation: None });
}
let now = Instant::now();
let (admission, suppression_reason) = with_lock_peer_health(&self.addr, |health| {
let degraded = health.request_backoff_until.is_some() || health.request_consecutive_timeouts > 0;
if degraded && health.request_in_flight >= Self::request_limit() {
return (None, "in_flight_limit");
}
let Some(until) = health.request_backoff_until else {
health.request_in_flight += 1;
return (Some(LockRequestAdmission { probe_generation: None }), "none");
};
if now < until || health.request_probe_generation.is_some() {
return (None, "breaker_open");
}
health.next_probe_generation = health.next_probe_generation.saturating_add(1);
let generation = health.next_probe_generation;
health.request_probe_generation = Some(generation);
health.request_in_flight += 1;
(
Some(LockRequestAdmission {
probe_generation: Some(generation),
}),
"none",
)
});
if admission.is_none() {
rustfs_io_metrics::lock_metrics::record_remote_lock_request_suppressed(&self.addr, op, suppression_reason);
}
admission
}
fn release_lock_request(&self, op: &'static str, admission: Option<LockRequestAdmission>) {
if is_request_breaker_operation(op) && admission.is_some() {
with_lock_peer_health(&self.addr, |health| {
health.request_in_flight = health.request_in_flight.saturating_sub(1);
if admission.is_some_and(|admission| health.request_probe_generation == admission.probe_generation) {
health.request_probe_generation = None;
}
});
}
}
fn record_request_timeout(&self, op: &'static str, deadline: Duration) {
if !is_request_breaker_operation(op) {
return;
}
let now = Instant::now();
with_lock_peer_health(&self.addr, |health| {
health.request_consecutive_timeouts = health.request_consecutive_timeouts.saturating_add(1);
if health.request_consecutive_timeouts < LOCK_RPC_REQUEST_BACKOFF_THRESHOLD {
return;
}
let initial = deadline / 2;
let next = if health.request_backoff.is_zero() {
initial.max(Duration::from_millis(1))
} else {
health.request_backoff.saturating_mul(2)
};
health.request_backoff = next.min(LOCK_RPC_REQUEST_BACKOFF_MAX);
health.request_backoff_until = Some(now + health.request_backoff);
});
}
fn timeout_log_decision(&self, op: &'static str, interval: Duration) -> (bool, u64) {
let now = Instant::now();
let mut peers = timeout_log_health().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if peers.len() >= TIMEOUT_LOG_STATE_MAX {
peers.retain(|_, state| {
state
.last_logged
.is_some_and(|at| now.saturating_duration_since(at) < TIMEOUT_LOG_STATE_RETENTION)
});
}
let key = (self.addr.clone(), op);
if peers.len() >= TIMEOUT_LOG_STATE_MAX
&& !peers.contains_key(&key)
&& let Some(eviction_key) = peers.keys().next().cloned()
{
peers.remove(&eviction_key);
}
let state = peers.entry(key).or_default();
if state
.last_logged
.is_some_and(|at| now.saturating_duration_since(at) < interval)
{
state.suppressed = state.suppressed.saturating_add(1);
return (false, state.suppressed);
}
let suppressed = state.suppressed;
state.last_logged = Some(now);
state.suppressed = 0;
(true, suppressed)
}
fn bounded_acquire_request(request: &LockRequest, deadline: Duration) -> LockRequest {
let mut bounded = request.clone();
bounded.acquire_timeout = bounded.acquire_timeout.min(deadline);
bounded
}
/// Apply the per-peer eviction policy after a failed RPC.
async fn maybe_evict_connection(
&self,
@@ -406,6 +608,8 @@ impl RemoteClient {
resource_summary: &str,
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
late: LateCompletion<T>,
late_failure: LateFailure,
admission: Option<LockRequestAdmission>,
) {
let limit = Self::detached_rpc_limit();
let admitted = with_lock_peer_health(&self.addr, |health| {
@@ -418,6 +622,10 @@ impl RemoteClient {
});
if !admitted {
handle.abort();
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
debug!(
addr = %self.addr,
@@ -432,12 +640,19 @@ impl RemoteClient {
let addr = self.addr.clone();
tokio::spawn(async move {
let outcome = handle.await;
with_lock_peer_health(&addr, |health| health.detached_rpcs = health.detached_rpcs.saturating_sub(1));
with_lock_peer_health(&addr, |health| {
health.detached_rpcs = health.detached_rpcs.saturating_sub(1);
if is_request_breaker_operation(op) && admission.is_some() {
health.request_in_flight = health.request_in_flight.saturating_sub(1);
if admission.is_some_and(|admission| health.request_probe_generation == admission.probe_generation) {
health.request_probe_generation = None;
}
}
});
match outcome {
Ok(Ok(response)) => {
with_lock_peer_health(&addr, |health| {
health.last_success = Some(Instant::now());
health.consecutive_timeouts = 0;
});
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
if let Some(late) = late {
@@ -445,6 +660,9 @@ impl RemoteClient {
}
}
Ok(Err(status)) => {
if let Some(late_failure) = late_failure {
late_failure(()).await;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
debug!(
addr = %addr,
@@ -455,6 +673,9 @@ impl RemoteClient {
);
}
Err(join_error) => {
if let Some(late_failure) = late_failure {
late_failure(()).await;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
}
@@ -485,24 +706,79 @@ impl RemoteClient {
}))
}
fn late_release_failure_hook(&self, lock_ids: Vec<LockId>) -> LateFailure {
let client = self.clone();
Some(Box::new(move |_| {
Box::pin(async move {
client.release_late_acquisitions(lock_ids).await;
})
}))
}
/// A lock granted after its caller stopped waiting is an orphan until its
/// lease expires; hand it back right away, best effort.
async fn release_late_acquisitions(&self, lock_ids: Vec<LockId>) {
let outcome = match self.release_locks_batch(&lock_ids).await {
Ok(released) if released.iter().all(|released| *released) => "released",
Ok(_) => "partial",
Err(_) => "failed",
};
async fn release_late_acquisitions(&self, mut lock_ids: Vec<LockId>) {
if !self.admit_late_release() {
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release("failed");
let (log_warning, suppressed_timeouts) = self.timeout_log_decision("late_release", Self::rpc_timeout());
if log_warning {
warn!(
addr = %self.addr,
count = lock_ids.len(),
outcome = "budget_exhausted",
suppressed_timeouts,
"Skipped late remote lock release because the cleanup budget is exhausted"
);
}
return;
}
let original_count = lock_ids.len();
let mut outcome = "failed";
for (attempt, delay) in std::iter::once(Duration::ZERO).chain(LATE_RELEASE_RETRY_DELAYS).enumerate() {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
match self.release_locks_batch(&lock_ids).await {
Ok(released) => {
lock_ids = lock_ids
.into_iter()
.zip(released)
.filter_map(|(lock_id, released)| (!released).then_some(lock_id))
.collect();
if lock_ids.is_empty() {
outcome = "released";
break;
}
outcome = if attempt == LATE_RELEASE_RETRY_DELAYS.len() {
"partial"
} else {
"failed"
};
}
Err(_) => {
outcome = "failed";
}
}
}
self.release_late_release();
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
if outcome == "released" {
debug!(addr = %self.addr, count = lock_ids.len(), "Released remote locks granted after their caller timed out");
debug!(addr = %self.addr, count = original_count, "Released remote locks granted after their caller timed out");
} else {
warn!(
addr = %self.addr,
count = lock_ids.len(),
outcome,
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
);
let (log_warning, suppressed_timeouts) = self.timeout_log_decision("late_release", Self::rpc_timeout());
if log_warning {
warn!(
addr = %self.addr,
count = original_count,
outcome,
suppressed_timeouts,
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
);
} else {
debug!(addr = %self.addr, count = original_count, outcome, suppressed_timeouts, "Suppressed repeated late remote lock release warning");
}
}
}
@@ -512,26 +788,74 @@ impl RemoteClient {
resource_summary: &str,
deadline: Duration,
future: Fut,
late: LateCompletion<T>,
cleanup: RpcCleanup<T>,
) -> std::result::Result<T, LockError>
where
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
T: Send + 'static,
{
let RpcCleanup {
late,
late_failure,
admission,
} = cleanup;
let mut handle = tokio::spawn(future);
match timeout(deadline, &mut handle).await {
Ok(Ok(Ok(response))) => {
self.record_rpc_success();
self.record_rpc_success(op, admission);
self.release_lock_request(op, admission);
Ok(response)
}
Ok(Ok(Err(err))) => {
let reason = err.to_string();
if err.code() == tonic::Code::DeadlineExceeded {
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
self.record_request_timeout(op, deadline);
let (log_timeout, suppressed_timeouts) = self.timeout_log_decision(op, deadline);
if Self::is_scanner_leader_lock(resource_summary) {
debug!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
"Remote lock RPC returned deadline exceeded for scanner leader lock"
);
} else if log_timeout {
warn!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
suppressed_timeouts,
"Remote lock RPC returned deadline exceeded"
);
} else {
debug!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
suppressed_timeouts,
"Suppressed repeated remote lock RPC deadline"
);
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
return Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline));
}
// Only evict (and re-dial) the cached channel when the failure is a genuine
// transport problem. A server-produced application status (auth denied, peer
// lock service not ready, invalid args, ...) arrives on a perfectly healthy
// channel; evicting it just churns the connection and pushes the peer toward
// the offline threshold for no benefit. See issue #4567.
let transport_failure = Self::is_transport_failure(&err);
if transport_failure {
self.record_request_timeout(op, deadline);
}
if Self::is_scanner_leader_lock(resource_summary) {
debug!(
addr = %self.addr,
@@ -543,6 +867,31 @@ impl RemoteClient {
transport_failure,
"Remote lock RPC returned tonic error for scanner leader lock"
);
} else if transport_failure {
let (log_failure, suppressed_failures) = self.timeout_log_decision(op, deadline);
if log_failure {
warn!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
tonic_code = ?err.code(),
tonic_message = err.message(),
transport_failure,
suppressed_failures,
"Remote lock RPC returned transport error"
);
} else {
debug!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
tonic_code = ?err.code(),
suppressed_failures,
"Suppressed repeated remote lock transport error"
);
}
} else {
warn!(
addr = %self.addr,
@@ -559,6 +908,10 @@ impl RemoteClient {
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
.await;
}
self.release_lock_request(op, admission);
if transport_failure && let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
}
Ok(Err(join_error)) => {
@@ -569,11 +922,17 @@ impl RemoteClient {
error = %join_error,
"Remote lock RPC task ended abnormally"
);
self.release_lock_request(op, admission);
if let Some(late_failure) = late_failure {
drop(tokio::spawn(late_failure(())));
}
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
}
Err(_) => {
let reason = format!("RPC timed out after {deadline:?}");
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
self.record_request_timeout(op, deadline);
let (log_timeout, suppressed_timeouts) = self.timeout_log_decision(op, deadline);
if Self::is_scanner_leader_lock(resource_summary) {
debug!(
addr = %self.addr,
@@ -582,18 +941,28 @@ impl RemoteClient {
resource_summary,
"Remote lock RPC timed out for scanner leader lock"
);
} else {
} else if log_timeout {
warn!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
suppressed_timeouts,
"Remote lock RPC timed out"
);
} else {
debug!(
addr = %self.addr,
op,
timeout_ms = deadline.as_millis(),
resource_summary,
suppressed_timeouts,
"Suppressed repeated remote lock RPC timeout"
);
}
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
.await;
self.detach_timed_out_rpc(op, resource_summary, handle, late);
self.detach_timed_out_rpc(op, resource_summary, handle, late, late_failure, admission);
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
}
}
@@ -621,6 +990,14 @@ impl RemoteClient {
.collect()
}
fn rpc_backoff_failure_response(request: &LockRequest) -> LockResponse {
LockResponse::failure("Remote lock RPC timed out: peer request breaker is open", request.acquire_timeout)
}
fn rpc_backoff_failure_batch(requests: &[LockRequest]) -> Vec<LockResponse> {
requests.iter().map(Self::rpc_backoff_failure_response).collect()
}
fn build_lock_info(request: &LockRequest, lock_info_json: Option<String>) -> LockInfo {
if let Some(lock_info_json) = lock_info_json {
match serde_json::from_str::<LockInfo>(&lock_info_json) {
@@ -680,22 +1057,45 @@ impl RemoteClient {
impl LockClient for RemoteClient {
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
info!("remote acquire_exclusive for {}", request.resource);
let mut client = self.get_client().await?;
let admission = match self.admit_lock_request("lock") {
Some(admission) => admission,
None => return Ok(Self::rpc_backoff_failure_response(request)),
};
let rpc_timeout = Self::rpc_timeout();
let bounded_request = Self::bounded_acquire_request(request, rpc_timeout);
let mut client = match self.get_client().await {
Ok(client) => client,
Err(err) => {
self.record_request_timeout("lock", rpc_timeout);
self.release_lock_request("lock", Some(admission));
return Err(err);
}
};
let resource_summary = request.resource.to_string();
let mut req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
attach_lock_mutation_body_digest(&mut req)?;
let args = match serde_json::to_string(&bounded_request) {
Ok(args) => args,
Err(err) => {
self.release_lock_request("lock", Some(admission));
return Err(LockError::internal(format!("Failed to serialize request: {err}")));
}
};
let mut req = Request::new(GenerallyLockRequest { args });
req.set_timeout(rpc_timeout);
if let Err(err) = attach_lock_mutation_body_digest(&mut req) {
self.release_lock_request("lock", Some(admission));
return Err(err.into());
}
let late = self.late_release_hook(request.lock_id.clone());
let late_failure = self.late_release_failure_hook(vec![request.lock_id.clone()]);
let resp = match self
.execute_rpc(
"lock",
&resource_summary,
Self::rpc_timeout(),
rpc_timeout,
async move { client.lock(req).await },
late,
RpcCleanup::new(late, late_failure, Some(admission)),
)
.await
{
@@ -725,26 +1125,54 @@ impl LockClient for RemoteClient {
return Ok(Vec::new());
}
let mut client = self.get_client().await?;
let admission = match self.admit_lock_request("lock_batch") {
Some(admission) => admission,
None => return Ok(Self::rpc_backoff_failure_batch(requests)),
};
let rpc_timeout = Self::rpc_timeout();
let bounded_requests = requests
.iter()
.map(|request| Self::bounded_acquire_request(request, rpc_timeout))
.collect::<Vec<_>>();
let mut client = match self.get_client().await {
Ok(client) => client,
Err(err) => {
self.record_request_timeout("lock_batch", rpc_timeout);
self.release_lock_request("lock_batch", Some(admission));
return Err(err);
}
};
let resource_summary = Self::summarize_resources(requests);
let mut req = Request::new(BatchGenerallyLockRequest {
args: requests
.iter()
.map(|request| {
serde_json::to_string(request).map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))
})
.collect::<Result<Vec<_>>>()?,
});
attach_lock_mutation_body_digest(&mut req)?;
let args = match bounded_requests
.iter()
.map(|request| {
serde_json::to_string(request).map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))
})
.collect::<Result<Vec<_>>>()
{
Ok(args) => args,
Err(err) => {
self.release_lock_request("lock_batch", Some(admission));
return Err(err);
}
};
let mut req = Request::new(BatchGenerallyLockRequest { args });
req.set_timeout(rpc_timeout);
if let Err(err) = attach_lock_mutation_body_digest(&mut req) {
self.release_lock_request("lock_batch", Some(admission));
return Err(err.into());
}
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
let late_failure = self.late_release_failure_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
let resp = match self
.execute_rpc(
"lock_batch",
&resource_summary,
Self::rpc_timeout(),
rpc_timeout,
async move { client.lock_batch(req).await },
late,
RpcCleanup::new(late, late_failure, Some(admission)),
)
.await
{
@@ -791,7 +1219,7 @@ impl LockClient for RemoteClient {
&resource_summary,
Self::rpc_timeout(),
async move { client.un_lock(req).await },
None,
RpcCleanup::new(None, None, None),
)
.await?
.into_inner();
@@ -825,7 +1253,7 @@ impl LockClient for RemoteClient {
&resource_summary,
Self::rpc_timeout(),
async move { client.un_lock_batch(req).await },
None,
RpcCleanup::new(None, None, None),
)
.await?
.into_inner();
@@ -853,7 +1281,7 @@ impl LockClient for RemoteClient {
&resource_summary,
Self::rpc_timeout(),
async move { client.refresh(req).await },
None,
RpcCleanup::new(None, None, None),
)
.await?
.into_inner();
@@ -879,7 +1307,7 @@ impl LockClient for RemoteClient {
&resource_summary,
Self::rpc_timeout(),
async move { client.force_un_lock(req).await },
None,
RpcCleanup::new(None, None, None),
)
.await?
.into_inner();
@@ -894,26 +1322,31 @@ impl LockClient for RemoteClient {
// Since there's no direct status query in the gRPC service,
// we attempt a non-blocking lock acquisition to check if the resource is available
let status_request = Self::create_unlock_request(lock_id);
let probe_lock_id = LockId::new_unique(&lock_id.resource);
let rpc_timeout = Self::rpc_timeout();
let status_request = Self::create_unlock_request(&probe_lock_id).with_ttl(rpc_timeout);
let bounded_status_request = Self::bounded_acquire_request(&status_request, rpc_timeout);
let resource_summary = status_request.resource.to_string();
let mut client = self.get_client().await?;
let args = serde_json::to_string(&status_request)
let args = serde_json::to_string(&bounded_status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
// Try to acquire a very short-lived lock to test availability
let mut req = Request::new(GenerallyLockRequest { args: args.clone() });
req.set_timeout(rpc_timeout);
attach_lock_mutation_body_digest(&mut req)?;
// A probe lock granted after the deadline must not linger on the peer.
let late = self.late_release_hook(lock_id.clone());
let late = self.late_release_hook(probe_lock_id.clone());
let late_failure = self.late_release_failure_hook(vec![probe_lock_id]);
// Try exclusive lock first with very short timeout
let resp = match self
.execute_rpc(
"check_status",
&resource_summary,
Self::rpc_timeout(),
rpc_timeout,
async move { client.lock(req).await },
late,
RpcCleanup::new(late, late_failure, None),
)
.await
{
@@ -925,6 +1358,7 @@ impl LockClient for RemoteClient {
// If we successfully acquired the lock, the resource was free.
// Immediately release it on a best-effort basis.
let mut release_req = Request::new(GenerallyLockRequest { args });
release_req.set_timeout(rpc_timeout);
attach_lock_mutation_body_digest(&mut release_req)?;
if let Ok(mut client) = self.get_client().await {
let _ = self
@@ -933,7 +1367,7 @@ impl LockClient for RemoteClient {
&resource_summary,
Self::rpc_timeout(),
async move { client.un_lock(release_req).await },
None,
RpcCleanup::new(None, None, None),
)
.await;
}
@@ -998,7 +1432,7 @@ impl LockClient for RemoteClient {
Self::ONLINE_CHECK_RESOURCE,
online_timeout,
async move { client.ping(ping_req).await },
None,
RpcCleanup::new(None, None, None),
)
.await
{
@@ -1133,6 +1567,94 @@ mod tests {
);
}
#[test]
fn request_breaker_opens_after_three_timeouts_and_allows_one_probe() {
let addr = "http://breaker-test";
let client = RemoteClient::new(addr.to_string());
reset_lock_peer_health_for_test(addr);
for _ in 0..LOCK_RPC_REQUEST_BACKOFF_THRESHOLD {
client.record_request_timeout("lock", Duration::from_millis(100));
}
assert!(
client.admit_lock_request("lock").is_none(),
"open breaker must reject requests during backoff"
);
with_lock_peer_health(addr, |health| {
health.request_backoff_until = Some(Instant::now() - Duration::from_millis(1));
health.request_probe_generation = None;
health.request_in_flight = 0;
});
let probe = client
.admit_lock_request("lock")
.expect("half-open breaker must admit one probe");
assert!(probe.is_probe());
assert!(client.admit_lock_request("lock").is_none(), "only one half-open probe may run");
client.release_lock_request("lock", Some(probe));
assert!(lock_peer_health_for_test(addr).request_probe_generation.is_none());
reset_lock_peer_health_for_test(addr);
}
#[test]
fn old_request_completion_cannot_clear_a_new_probe() {
let addr = "http://breaker-token-test";
let client = RemoteClient::new(addr.to_string());
reset_lock_peer_health_for_test(addr);
with_lock_peer_health(addr, |health| {
health.request_backoff_until = Some(Instant::now() - Duration::from_millis(1));
health.request_probe_generation = None;
health.request_in_flight = 1;
});
let probe = client.admit_lock_request("lock").expect("probe should be admitted");
client.release_lock_request("lock", Some(LockRequestAdmission { probe_generation: None }));
assert!(lock_peer_health_for_test(addr).request_probe_generation.is_some());
client.record_rpc_success("lock", Some(LockRequestAdmission { probe_generation: None }));
assert!(lock_peer_health_for_test(addr).request_probe_generation.is_some());
client.release_lock_request("lock", Some(probe));
assert!(lock_peer_health_for_test(addr).request_probe_generation.is_none());
reset_lock_peer_health_for_test(addr);
}
#[test]
fn stale_probe_completion_cannot_clear_a_later_generation() {
let addr = "http://breaker-generation-test";
let client = RemoteClient::new(addr.to_string());
reset_lock_peer_health_for_test(addr);
with_lock_peer_health(addr, |health| {
health.request_backoff_until = Some(Instant::now() - Duration::from_millis(1));
});
let first = client.admit_lock_request("lock").expect("first probe should be admitted");
client.record_request_timeout("lock", Duration::from_millis(10));
with_lock_peer_health(addr, |health| {
health.request_backoff_until = Some(Instant::now() - Duration::from_millis(1));
health.request_in_flight = 0;
health.request_probe_generation = None;
health.request_consecutive_timeouts = 0;
});
let second = client.admit_lock_request("lock").expect("second probe should be admitted");
assert_ne!(first.probe_generation, second.probe_generation);
client.release_lock_request("lock", Some(first));
assert_eq!(lock_peer_health_for_test(addr).request_probe_generation, second.probe_generation);
client.release_lock_request("lock", Some(second));
reset_lock_peer_health_for_test(addr);
}
#[test]
fn bounded_acquire_request_matches_transport_deadline() {
let request = test_lock_request(Duration::from_secs(30));
let bounded = RemoteClient::bounded_acquire_request(&request, Duration::from_secs(3));
assert_eq!(bounded.acquire_timeout, Duration::from_secs(3));
let request = test_lock_request(Duration::from_secs(1));
let bounded = RemoteClient::bounded_acquire_request(&request, Duration::from_secs(3));
assert_eq!(bounded.acquire_timeout, Duration::from_secs(1));
}
#[test]
fn acquired_lock_ids_picks_only_granted_batch_entries() {
let lock_ids = vec![
File diff suppressed because it is too large Load Diff
+7
View File
@@ -4253,6 +4253,13 @@ mod decommission_lock_order_tests {
panic!("cancellation probe finished before observing target contention: {result:?}");
}
}
// The mutation absorbs the gate contention inline before the outer wait
// loop ever sees it, and that inline budget is bounded.
assert_eq!(
retry_observer.target_gate_inline_retries(),
crate::core::pools::DECOMMISSION_MUTATION_GATE_MAX_INLINE_ATTEMPTS - 1,
"inline target-gate retries must stop at their bounded attempt budget"
);
tokio::time::sleep(Duration::from_millis(800)).await;
assert_eq!(
retry_observer.target_gate_exact_reloads(),
+2 -2
View File
@@ -1493,12 +1493,12 @@ impl Sets {
object: &str,
version_id: &str,
opts: &HealOpts,
retirement: Option<&crate::bucket::retirement::MarkerRetirementContext<'_>>,
proof: crate::set_disk::AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>, Option<crate::set_disk::HealedObjectAbsence>)> {
let mut absence = None;
let (item, error) = self
.get_disks_for_heal_object(object, opts)?
.heal_object_with_retirement(bucket, object, version_id, opts, &mut absence, retirement)
.heal_object_with_retirement(bucket, object, version_id, opts, &mut absence, proof)
.await?;
// A caller-owned lock does not expose its lease to this boundary.
// Keep cleanup unverified when that lease cannot be checked here.
+15
View File
@@ -642,6 +642,21 @@ pub(crate) fn data_movement_stage_source(err: &Error) -> Option<&Error> {
.downcast_ref::<Error>()
}
/// Recover a concrete typed error wrapped by [`data_movement_stage_error`].
pub(crate) fn data_movement_stage_source_as<T>(err: &Error) -> Option<&T>
where
T: std::error::Error + 'static,
{
let Error::Io(io_err) = err else {
return None;
};
io_err
.get_ref()?
.downcast_ref::<DataMovementStageError>()?
.source
.downcast_ref::<T>()
}
fn schedule_data_movement_multipart_abort_cleanup(
store: Arc<ECStore>,
target_pool_idx: usize,
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::data_movement::data_movement_context_error;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result, is_err_object_not_found, is_err_version_not_found};
use crate::object_api::ObjectOptions;
@@ -27,6 +28,28 @@ use tokio::io::AsyncReadExt;
pub const MAX_SCANNER_PAUSE_BACKLOG_BYTES: u64 = 64 * 1024;
pub(crate) const SCANNER_PAUSE_BACKLOG_PATH: &str = "buckets/.scanner-pause-backlog.json";
/// Typed failure from the scanner-owned native retirement planner.
///
/// Decommission must preserve the source when the native authority cannot be
/// proven. The handoff variant is deliberately narrower than other authority
/// failures so the worker can retry without converting a transient membership
/// transition into a terminal pool failure.
#[derive(Debug, thiserror::Error)]
pub enum ScannerPauseBacklogRetirementError {
#[error("scanner pause backlog retirement has a retryable handoff conflict: {reason}")]
HandoffConflict { reason: String },
#[error("scanner pause backlog retirement has an unsafe authority conflict: {reason}")]
AuthorityConflict { reason: String },
#[error("scanner pause backlog retirement found an invalid native record: {reason}")]
InvalidRecord { reason: String },
}
impl ScannerPauseBacklogRetirementError {
pub fn is_retryable_handoff_conflict(&self) -> bool {
matches!(self, Self::HandoffConflict { .. })
}
}
/// A bounded, storage-fenced native replica. Only a confirmed missing object
/// has no payload; read failures never enter the Scanner verifier.
pub struct ScannerPauseBacklogRetirementReplica {
@@ -44,7 +67,10 @@ pub struct ScannerPauseBacklogRetirementPlan {
}
pub type ScannerPauseBacklogRetirementPlanner =
fn(usize, &[ScannerPauseBacklogRetirementReplica]) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, String>;
fn(
usize,
&[ScannerPauseBacklogRetirementReplica],
) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, ScannerPauseBacklogRetirementError>;
static RETIREMENT_PLANNER: OnceLock<ScannerPauseBacklogRetirementPlanner> = OnceLock::new();
@@ -161,7 +187,8 @@ pub(crate) fn plan_scanner_pause_backlog_retirement(
data: read.replica.data.clone(),
})
.collect::<Vec<_>>();
planner(source_pool_index, &snapshots).map_err(Error::other)
planner(source_pool_index, &snapshots)
.map_err(|err| data_movement_context_error(format!("scanner pause backlog retirement planner failed: {err}"), err))
}
/// The native writer and retirement handoff use the same conditional, full-tail
+24
View File
@@ -30,6 +30,8 @@ use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_madmin::{info_commands::DiskMetrics, metrics::TimedAction};
#[cfg(not(test))]
use std::sync::OnceLock;
#[cfg(test)]
use std::sync::atomic::AtomicBool;
use std::{
collections::HashMap,
path::PathBuf,
@@ -628,6 +630,8 @@ pub struct DiskHealthTracker {
/// Authoritative atomically published runtime/status pair.
state_snapshot: AtomicU64,
transition_lock: std::sync::Mutex<()>,
#[cfg(test)]
test_forced_offline: AtomicBool,
}
fn pack_health_state(runtime_state: RuntimeDriveHealthState, status: u32) -> u64 {
@@ -975,6 +979,8 @@ impl DiskHealthTracker {
last_capacity_probe_unix_secs: AtomicI64::new(0),
state_snapshot: AtomicU64::new(pack_health_state(RuntimeDriveHealthState::Online, DISK_HEALTH_OK)),
transition_lock: std::sync::Mutex::new(()),
#[cfg(test)]
test_forced_offline: AtomicBool::new(false),
}
}
@@ -1045,6 +1051,13 @@ impl DiskHealthTracker {
self.publish_state(state, status);
}
#[cfg(test)]
pub fn force_offline_for_test(&self) {
self.test_forced_offline.store(true, Ordering::Release);
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
self.publish_state(RuntimeDriveHealthState::Offline, DISK_HEALTH_FAULTY);
}
pub fn swap_ok_to_faulty(&self) -> bool {
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let (_, status) = unpack_health_state(self.state_snapshot.load(Ordering::Acquire));
@@ -1123,6 +1136,8 @@ impl DiskHealthTracker {
/// Remote disks are marked faulty on timeout/network errors; the init loop retries with the
/// same [`DiskStore`] handles, which would otherwise fail immediately at `is_faulty()`.
pub fn reset_for_store_init_retry(&self, endpoint: &Endpoint) {
#[cfg(test)]
self.test_forced_offline.store(false, Ordering::Release);
self.reset_for_store_init_retry_at(endpoint, current_unix_time());
}
@@ -1142,6 +1157,10 @@ impl DiskHealthTracker {
}
pub fn mark_recovery_success(&self, endpoint: &Endpoint, reason: &'static str) -> bool {
#[cfg(test)]
if self.test_forced_offline.load(Ordering::Acquire) {
return false;
}
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let current = self.runtime_state();
let next = match current {
@@ -1440,6 +1459,11 @@ impl LocalDiskWrapper {
self.health.force_runtime_state_for_test(state);
}
#[cfg(test)]
pub fn force_offline_for_test(&self) {
self.health.force_offline_for_test();
}
/// Same as [`DiskHealthTracker::reset_for_store_init_retry`]: undo a transient faulty mark before another format load attempt.
pub fn reset_health_for_store_init_retry(&self) {
self.health.reset_for_store_init_retry(&self.disk.endpoint());
+17 -18
View File
@@ -244,13 +244,16 @@ impl StdError for ConditionalFileNotCommittedError {
}
}
fn classify_internode_missing_error(error: &InternodeHttpError) -> Option<DiskError> {
fn classify_internode_disk_error(error: &InternodeHttpError) -> Option<DiskError> {
if error.is_remote_file_not_found() {
return Some(DiskError::FileNotFound);
}
if error.is_remote_volume_not_found() {
return Some(DiskError::VolumeNotFound);
}
if error.is_remote_file_corrupt() {
return Some(DiskError::FileCorrupt);
}
None
}
@@ -526,13 +529,10 @@ fn io_error_chain_contains_kind(io_error: &std::io::Error, kind: std::io::ErrorK
impl From<std::io::Error> for DiskError {
fn from(e: std::io::Error) -> Self {
if let Some(error) = e.get_ref().and_then(|source| source.downcast_ref::<InternodeHttpError>()) {
if error.is_remote_file_not_found() {
return DiskError::FileNotFound;
}
if error.is_remote_volume_not_found() {
return DiskError::VolumeNotFound;
}
if let Some(error) = e.get_ref().and_then(|source| source.downcast_ref::<InternodeHttpError>())
&& let Some(classified) = classify_internode_disk_error(error)
{
return classified;
}
let e = match e.downcast::<TerminalReadError>() {
Ok(terminal_error) => {
@@ -541,7 +541,7 @@ impl From<std::io::Error> for DiskError {
&& let Some(internode_error) = io_error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
&& let Some(classified) = classify_internode_missing_error(internode_error)
&& let Some(classified) = classify_internode_disk_error(internode_error)
{
return classified;
}
@@ -963,6 +963,7 @@ mod tests {
for (remote_error, expected) in [
(rustfs_rio::new_test_remote_file_not_found_http_io_error(), DiskError::FileNotFound),
(rustfs_rio::new_test_remote_volume_not_found_http_io_error(), DiskError::VolumeNotFound),
(rustfs_rio::new_test_remote_file_corrupt_http_io_error(), DiskError::FileCorrupt),
] {
let wrapped = terminal_read_error_to_io(DiskError::Io(remote_error));
assert_eq!(DiskError::from(wrapped), expected);
@@ -1533,25 +1534,23 @@ mod tests {
}
#[test]
fn test_internode_missing_errors_preserve_disk_error_types() {
fn test_internode_disk_errors_preserve_disk_error_types() {
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
let volume_missing = DiskError::from(rustfs_rio::new_test_remote_volume_not_found_http_io_error());
let file_corrupt = DiskError::from(rustfs_rio::new_test_remote_file_corrupt_http_io_error());
let unmarked_server_error = DiskError::from(rustfs_rio::new_test_internode_http_io_error(
rustfs_rio::InternodeHttpErrorKind::HttpStatus(http::StatusCode::INTERNAL_SERVER_ERROR),
));
assert_eq!(file_missing, DiskError::FileNotFound);
assert_eq!(volume_missing, DiskError::VolumeNotFound);
assert_eq!(file_corrupt, DiskError::FileCorrupt);
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
for missing in [file_missing, volume_missing] {
assert_eq!(missing.clone(), missing);
for error in [file_missing, volume_missing, file_corrupt] {
assert_eq!(error.clone(), error);
assert_eq!(
crate::disk::error_reduce::reduce_write_quorum_errs(
&[Some(missing.clone()), Some(missing.clone()), None],
&[],
2
),
Some(missing)
crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(error.clone()), Some(error.clone()), None], &[], 2),
Some(error)
);
}
}
+133 -60
View File
@@ -1899,6 +1899,61 @@ impl AsyncWrite for DirectWriter {
}
}
fn poll_write_vectored(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> std::task::Poll<std::io::Result<usize>> {
// Bitrot writes arrive as [hash, data]. Coalesce both slices into the
// aligned bounce buffer so the O_DIRECT path has the same byte-stream
// contract as the buffered writer instead of relying on the trait's
// first-slice fallback.
let this = self.get_mut();
loop {
match &mut this.state {
DirectWriteState::Busy(_) => {
std::task::ready!(this.poll_drive_busy(cx))?;
}
DirectWriteState::Idle(inner_opt) => {
let inner = inner_opt.as_mut().expect("idle direct writer must hold inner state");
let capacity = inner.buf.len;
let space = capacity - inner.filled;
let mut remaining = space;
let mut written = 0;
for src in bufs {
if remaining == 0 {
break;
}
let take = src.len().min(remaining);
let start = inner.filled + written;
inner.buf.as_mut_slice()[start..start + take].copy_from_slice(&src[..take]);
written += take;
remaining -= take;
}
if written == 0 {
return std::task::Poll::Ready(Ok(0));
}
inner.filled += written;
if inner.filled == capacity {
let mut inner = inner_opt.take().expect("idle direct writer must hold inner state");
let handle = tokio::task::spawn_blocking(move || {
let res = inner.flush_batch();
(inner, res)
});
this.state = DirectWriteState::Busy(handle);
}
return std::task::Poll::Ready(Ok(written));
}
}
}
}
fn is_write_vectored(&self) -> bool {
true
}
fn poll_flush(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<std::io::Result<()>> {
// Only drive an in-flight batch to completion. Sub-alignment staged
// bytes cannot be flushed mid-stream (they would misalign the next
@@ -7556,7 +7611,7 @@ impl LocalDisk {
objs_returned: &mut i32,
skip_current_dir_object: bool,
multipart_dir_to_skip: Option<HashSet<String>>,
) -> Result<()>
) -> Result<bool>
where
W: AsyncWrite + Unpin + Send,
{
@@ -7574,7 +7629,7 @@ impl LocalDisk {
};
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
// TODO(backlog): add directory listing lock to prevent concurrent enumeration
@@ -7635,12 +7690,12 @@ impl LocalDisk {
return Err(DiskError::FileNotFound);
}
return Ok(());
return Ok(false);
}
};
if entries.is_empty() {
return Ok(());
return Ok(false);
}
current = current.trim_matches('/').to_owned();
@@ -7654,7 +7709,7 @@ impl LocalDisk {
let entry = item.clone();
// check limit
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
// check multipart dir
if skip_current_dir_object
@@ -7759,14 +7814,14 @@ impl LocalDisk {
prefix = "".to_owned();
for entry in entries.iter() {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
}
if entry.is_empty() {
continue;
}
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(true);
}
let name = path_join_buf(&[current.as_str(), entry.as_str()]);
while let Some((last_name, _, _, _)) = dir_stack.last()
@@ -7780,7 +7835,7 @@ impl LocalDisk {
// moment the limit is reached, same as the check below this
// loop guards against for the current entry itself.
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
let (pop, skip_object, dir_to_skip, scan_required) = dir_stack.pop().expect("operation should succeed");
@@ -7794,23 +7849,25 @@ impl LocalDisk {
.await?;
let scan_path = pop.clone();
if opts.recursive
&& scan_required
&& let Err(er) =
Box::pin(self.scan_dir(pop, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await
{
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local scan failed"
);
if opts.recursive && scan_required {
match Box::pin(self.scan_dir(pop, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(er) => {
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local scan failed"
);
}
return Err(er);
}
}
return Err(er);
}
}
@@ -7821,7 +7878,7 @@ impl LocalDisk {
// tail, permanently skipping those keys on the next page instead
// of just deferring them to it.
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
let mut meta = MetaCacheEntry {
@@ -7964,7 +8021,7 @@ impl LocalDisk {
while let Some((dir, skip_object, dir_to_skip, scan_required)) = dir_stack.pop() {
if opts.limit > 0 && *objs_returned >= opts.limit {
return Ok(());
return Ok(true);
}
write_metacache_obj(
@@ -7977,27 +8034,29 @@ impl LocalDisk {
.await?;
let scan_path = dir.clone();
if opts.recursive
&& scan_required
&& let Err(er) =
Box::pin(self.scan_dir(dir, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await
{
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local recursive scan failed"
);
if opts.recursive && scan_required {
match Box::pin(self.scan_dir(dir, prefix.clone(), opts, out, objs_returned, skip_object, dir_to_skip)).await {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(er) => {
if !er.is_metacache_output_stream_closed() {
error!(
event = EVENT_DISK_LOCAL_SCAN_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
path = %scan_path,
operation = "scan_dir",
error = ?er,
"Disk local recursive scan failed"
);
}
return Err(er);
}
}
return Err(er);
}
}
Ok(())
Ok(false)
}
/// Whether the backing directory of plain object `object_name` also holds
@@ -10121,21 +10180,25 @@ impl DiskAPI for LocalDisk {
}
}
self.scan_dir(
opts.base_dir.clone(),
opts.filter_prefix.clone().unwrap_or_default(),
&opts,
&mut out,
&mut objs_returned,
skip_current_dir_object,
if multipart_dir_to_skip.is_empty() {
None
} else {
Some(multipart_dir_to_skip)
},
)
.await?;
let limit_reached = self
.scan_dir(
opts.base_dir.clone(),
opts.filter_prefix.clone().unwrap_or_default(),
&opts,
&mut out,
&mut objs_returned,
skip_current_dir_object,
if multipart_dir_to_skip.is_empty() {
None
} else {
Some(multipart_dir_to_skip)
},
)
.await?;
if let Some(flag) = opts.producer_limit_reached.as_ref() {
flag.store(limit_reached, std::sync::atomic::Ordering::Release);
}
out.close().await?;
Ok(())
}
@@ -22134,7 +22197,7 @@ mod test {
#[cfg(target_os = "linux")]
#[tokio::test]
async fn direct_writer_state_machine_round_trips_over_plain_file() {
use std::io::Read;
use std::io::{IoSlice, Read};
use tempfile::tempdir;
let dir = tempdir().expect("tempdir");
@@ -22153,6 +22216,16 @@ mod test {
let mut writer = DirectWriter::from_std_file_for_test(file, align, capacity);
let mut off = 0;
if content.len() >= 2 {
let split = content.len().min(300);
let first = split / 2;
let written = writer
.write_vectored(&[IoSlice::new(&content[..first]), IoSlice::new(&content[first..split])])
.await
.expect("vectored write");
assert_eq!(written, split, "vectored write must consume both hash/data-like slices");
off = split;
}
while off < content.len() {
let end = (off + 300).min(content.len());
writer.write_all(&content[off..end]).await.expect("write_all");
+22 -2
View File
@@ -70,7 +70,12 @@ use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
use rustfs_madmin::info_commands::DiskMetrics;
use rustfs_rio::ChunkReaderBox;
use serde::{Deserialize, Serialize};
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
use std::{
fmt::Debug,
path::PathBuf,
sync::{Arc, atomic::AtomicBool},
time::Duration,
};
use time::OffsetDateTime;
use tokio::io::{AsyncRead, AsyncWrite};
use uuid::Uuid;
@@ -820,7 +825,7 @@ impl Disk {
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
let Some(scope) = crate::store::bucket_heal_scope(dst_volume) else {
let Some(scope) = crate::store::bucket_heal_scope_for_object(dst_volume, dst_path) else {
return self
.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
.await;
@@ -979,6 +984,14 @@ impl Disk {
Disk::Remote(remote_disk) => remote_disk.force_runtime_state_for_test(state),
}
}
#[cfg(test)]
pub fn force_offline_for_test(&self) {
match self {
Disk::Local(local_disk) => local_disk.force_offline_for_test(),
Disk::Remote(remote_disk) => remote_disk.force_offline_for_test(),
}
}
}
#[derive(Debug)]
@@ -1471,6 +1484,12 @@ pub struct WalkDirOptions {
// Override the remote stream stall timeout for long background walks.
#[serde(default)]
pub stall_timeout_ms: Option<u64>,
/// In-process completion state for bounded local walks. This is skipped
/// from RPC serialization; remote peers retain the legacy natural-EOF
/// behavior until they support an explicit capability.
#[serde(skip)]
pub producer_limit_reached: Option<Arc<AtomicBool>>,
}
impl WalkDirOptions {
@@ -1794,6 +1813,7 @@ mod tests {
skip_total_timeout: false,
timeout_ms: Some(10_000),
stall_timeout_ms: Some(20_000),
producer_limit_reached: None,
};
assert_eq!(opts.bucket, "test-bucket");
+24 -3
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::disk::error::Error as DiskError;
use crate::erasure::codec::buffer_pool::{get_ec_buffer, return_ec_buffer};
use pin_project_lite::pin_project;
use rustfs_utils::HashAlgorithm;
@@ -239,7 +240,7 @@ fn short_shard_read(got: usize, want: usize) -> std::io::Error {
want,
"short shard read: got {got} of {want} bytes"
);
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, format!("short shard read: got {got} of {want} bytes"))
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, DiskError::FileCorrupt)
}
/// Split a `[hash][data]` block, verify the hash (unless `skip_verify`), and
@@ -265,7 +266,7 @@ fn split_and_verify<'a>(hash_algo: &HashAlgorithm, skip_verify: bool, block: &'a
data_len = data.len(),
"bitrot hash mismatch"
);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bitrot hash mismatch"));
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, DiskError::FileCorrupt));
}
Ok((data, verify))
}
@@ -434,7 +435,7 @@ where
data_len = want,
"bitrot hash mismatch"
);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bitrot hash mismatch"));
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, DiskError::FileCorrupt));
}
hash_offset += take;
remaining -= take;
@@ -1141,6 +1142,26 @@ mod tests {
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
#[test]
fn bitrot_damage_preserves_typed_repair_evidence() {
use crate::disk::error::Error as DiskError;
let algo = HashAlgorithm::HighwayHash256;
let data = b"repair evidence";
let mut frame = algo.hash_encode(data).as_ref().to_vec();
frame.extend_from_slice(data);
*frame.last_mut().expect("test frame has payload") ^= 1;
let error = super::split_and_verify(&algo, false, &frame).expect_err("a hash mismatch must reject the shard");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert_eq!(DiskError::from(error), DiskError::FileCorrupt);
let error = super::short_shard_read(3, 4);
assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
assert_eq!(DiskError::from(error), DiskError::FileCorrupt);
assert!(matches!(
DiskError::from(io::Error::new(io::ErrorKind::InvalidData, "unrelated invalid data")),
DiskError::Io(_)
));
}
struct FragmentedSource {
chunks: VecDeque<Bytes>,
}
+273 -78
View File
@@ -26,7 +26,7 @@ use crate::erasure::codec::workspace::ShardBufferPool;
use crate::erasure::coding::{BitrotReader, Erasure};
use crate::io_support::bitrot::DeferredReaderStripeHandle;
use crate::set_disk::shard_source::{
INLINE_SHARD_SLOTS, ShardBuffers, ShardErrors, ShardReadCost, ShardStripeSource, StripeReadState,
INLINE_SHARD_SLOTS, ShardBuffers, ShardErrors, ShardReadCost, ShardReadRepair, ShardStripeSource, StripeReadState,
};
use futures::FutureExt;
use futures::stream::{FuturesUnordered, StreamExt};
@@ -46,7 +46,13 @@ type ShardReadFuture<'a> = Pin<Box<dyn Future<Output = (usize, ShardReadCost, Re
type OwnedShardReadFuture<'a, R> =
Pin<Box<dyn Future<Output = (usize, ShardReadCost, Result<Vec<u8>, Error>, Option<BitrotReader<R>>, bool)> + Send + 'a>>;
pub(crate) type DeferredReaderReopener<R> = Arc<dyn Fn(usize) -> Option<BitrotReader<R>> + Send + Sync>;
pub(crate) type DecodeOutcome = (usize, Option<std::io::Error>, bool);
#[derive(Debug, Default)]
pub(crate) struct DecodeOutcome {
pub(crate) written: usize,
pub(crate) error: Option<std::io::Error>,
pub(crate) exact_quorum: bool,
pub(crate) repair: ShardReadRepair,
}
type ShardIndexes = SmallVec<[usize; INLINE_SHARD_SLOTS]>;
type ActiveReaders = SmallVec<[bool; INLINE_SHARD_SLOTS]>;
@@ -596,6 +602,7 @@ pub(crate) struct ParallelReader<R> {
hedged_readers: ActiveReaders,
stripe_index: usize,
integrity: Option<std::sync::Arc<crate::io_support::shard_integrity::PartProofReader>>,
repair_evidence: Option<Arc<ShardReadRepair>>,
}
}
@@ -808,9 +815,15 @@ where
deferred_reopeners: Vec::new(),
hedged_readers,
stripe_index: 0,
repair_evidence: None,
}
}
pub(crate) fn with_repair_evidence(mut self, evidence: Arc<ShardReadRepair>) -> Self {
self.repair_evidence = Some(evidence);
self
}
/// Attach the per-slot deferred stripe handles produced during bitrot
/// reader setup. Only parity slots are consulted: a handle lets the
/// lockstep path open a parity shard aligned to the stripe where a data
@@ -2027,6 +2040,9 @@ where
.take()
.unwrap_or_else(|| Box::new(StripeReadState::with_slot_count(self.readers.len(), self.data_shards)));
self.read_into_state(&mut state).await;
if let Some(evidence) = &self.repair_evidence {
evidence.observe(state.parts_mut().1);
}
if state.can_decode()
&& let Some(proof) = &self.integrity
{
@@ -2231,10 +2247,10 @@ impl Erasure {
W: AsyncWrite + Send + Sync + Unpin,
R: crate::erasure::coding::ShardSource,
{
let (written, error, _) = self
let outcome = self
.decode_inner(writer, readers, offset, length, total_length, None, Vec::new(), Vec::new())
.await;
(written, error)
(outcome.written, outcome.error)
}
#[allow(dead_code, reason = "read-cost decode path asserted by this file's tests (backlog#1823)")]
@@ -2251,10 +2267,10 @@ impl Erasure {
W: AsyncWrite + Send + Sync + Unpin,
R: crate::erasure::coding::ShardSource,
{
let (written, error, _) = self
let outcome = self
.decode_inner(writer, readers, offset, length, total_length, Some(read_costs), Vec::new(), Vec::new())
.await;
(written, error)
(outcome.written, outcome.error)
}
/// GET decode entry point that also carries the deferred-parity stripe
@@ -2311,7 +2327,7 @@ impl Erasure {
W: AsyncWrite + Send + Sync + Unpin,
R: crate::erasure::coding::ShardSource,
{
let (written, error, _) = self
let outcome = self
.decode_inner(
writer,
readers,
@@ -2323,7 +2339,7 @@ impl Erasure {
deferred_reopeners,
)
.await;
(written, error)
(outcome.written, outcome.error)
}
#[allow(clippy::too_many_arguments)]
@@ -2374,6 +2390,7 @@ impl Erasure {
block_length: usize,
written: &mut usize,
ret_err: &mut Option<std::io::Error>,
repair: &ShardReadRepair,
stage_metrics_enabled: bool,
require_surplus_source: bool,
integrity: Option<&std::sync::Arc<crate::io_support::shard_integrity::PartProofReader>>,
@@ -2382,6 +2399,7 @@ impl Erasure {
where
W: AsyncWrite + Send + Sync + Unpin,
{
repair.observe(errs);
if ret_err.is_none()
&& let (_, Some(err)) = reduce_errs(errs, &[])
&& (err == Error::FileNotFound || err == Error::FileCorrupt)
@@ -2516,41 +2534,42 @@ impl Erasure {
{
if readers.len() != self.data_shards + self.parity_shards {
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
return (0, Some(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers")), false);
return DecodeOutcome {
error: Some(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers")),
..Default::default()
};
}
// block_size/data_shards come from on-disk metadata; a corrupt FileInfo with a
// zero here must surface as an error, not a divide-by-zero panic on every GET.
if self.block_size == 0 || self.data_shards == 0 {
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
return (
0,
Some(io::Error::new(ErrorKind::InvalidInput, "Invalid erasure coding parameters")),
false,
);
return DecodeOutcome {
error: Some(io::Error::new(ErrorKind::InvalidInput, "Invalid erasure coding parameters")),
..Default::default()
};
}
let Some(end_offset) = offset.checked_add(length) else {
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
return (
0,
Some(io::Error::new(ErrorKind::InvalidInput, "offset + length exceeds total length")),
false,
);
return DecodeOutcome {
error: Some(io::Error::new(ErrorKind::InvalidInput, "offset + length exceeds total length")),
..Default::default()
};
};
if end_offset > total_length {
record_get_object_pipeline_failure(GET_STAGE_RANGE, GetObjectFailureReason::RangeOrLengthInvalid);
return (
0,
Some(io::Error::new(ErrorKind::InvalidInput, "offset + length exceeds total length")),
false,
);
return DecodeOutcome {
error: Some(io::Error::new(ErrorKind::InvalidInput, "offset + length exceeds total length")),
..Default::default()
};
}
let mut ret_err = None;
let repair = ShardReadRepair::default();
if length == 0 {
return (0, ret_err, false);
return DecodeOutcome::default();
}
let mut written = 0;
@@ -2573,6 +2592,11 @@ impl Erasure {
.with_deferred_parity_reopeners(deferred_reopeners);
let integrity = reader.integrity.clone();
// Copy sources use demand-bound scheduling for backpressure, but retain
// the ordinary GET read quorum. Only the opt-in GET rollout requires a
// surplus source; available surplus shards and integrity proofs are
// still verified before any reconstructed copy bytes are emitted.
let require_surplus_source = reader.demand_bound_lockstep && matches!(decode_read_policy(), DecodeReadPolicy::Default);
let start = offset / self.block_size;
let end = end_offset.saturating_sub(1) / self.block_size;
@@ -2667,7 +2691,6 @@ impl Erasure {
// `shards` are borrowed again below. In the `Stop` case that
// drop is what cancels the still-in-flight prefetch read.
let (flow, next): (Option<StripeFlow>, Option<StripeReadOutput>) = {
let require_surplus_source = reader.demand_bound_lockstep;
let read_fut = read_stripe_timed(&mut reader, stage_metrics_enabled);
let emit_fut = self.emit_decoded_stripe(
writer,
@@ -2677,6 +2700,7 @@ impl Erasure {
block_length,
&mut written,
&mut ret_err,
&repair,
stage_metrics_enabled,
require_surplus_source,
integrity.as_ref(),
@@ -2727,8 +2751,9 @@ impl Erasure {
block_length,
&mut written,
&mut ret_err,
&repair,
stage_metrics_enabled,
reader.demand_bound_lockstep,
require_surplus_source,
integrity.as_ref(),
offset,
)
@@ -2770,8 +2795,9 @@ impl Erasure {
block_length,
&mut written,
&mut ret_err,
&repair,
stage_metrics_enabled,
reader.demand_bound_lockstep,
require_surplus_source,
integrity.as_ref(),
offset,
)
@@ -2785,15 +2811,16 @@ impl Erasure {
}
}
if ret_err.is_some() {
return (written, ret_err, exact_quorum);
}
if written < length {
if ret_err.is_none() && written < length {
ret_err = Some(Error::LessData.into());
}
(written, ret_err, exact_quorum)
DecodeOutcome {
written,
error: ret_err,
exact_quorum,
repair,
}
}
}
@@ -3449,6 +3476,150 @@ mod tests {
}
}
async fn copy_source_bitrot_shards(erasure: &Erasure, payload: &[u8], corrupt_parity: bool) -> Vec<Vec<u8>> {
let mut writers: Vec<_> = (0..erasure.data_shards + erasure.parity_shards)
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), erasure.shard_size(), HashAlgorithm::HighwayHash256))
.collect();
for block in payload.chunks(erasure.block_size) {
let shards = erasure.encode_data(block).expect("copy source should encode");
for (index, shard) in shards.iter().enumerate() {
let mut bytes = shard.to_vec();
if corrupt_parity && index == erasure.data_shards {
// A stale source can have a valid per-shard bitrot checksum.
// Reframe the changed parity to exercise reconstruction verification.
bytes[0] ^= 0x80;
}
writers[index].write(&bytes).await.expect("copy source should frame");
}
}
writers.into_iter().map(|writer| writer.into_inner().into_inner()).collect()
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_reads_exact_quorum_across_stripes_and_ranges() {
const BLOCK_SIZE: usize = 768;
let payload: Vec<_> = (0..BLOCK_SIZE * 3 + 37)
.map(|index| (index.wrapping_mul(31) % 251) as u8)
.collect();
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, false).await;
for offline in [[0, 1, 2, 3], [0, 5, 12, 15]] {
for (offset, length) in [(0, payload.len()), (BLOCK_SIZE + 17, payload.len() - BLOCK_SIZE - 17)] {
let frame_offset = offset / BLOCK_SIZE * (erasure.shard_size() + HashAlgorithm::HighwayHash256.size());
let positioned: Vec<_> = buffers.iter().map(|bytes| bytes[frame_offset..].to_vec()).collect();
let (mut readers, handles) =
readers_with_deferred_parity(&positioned, 12, erasure.shard_size(), &HashAlgorithm::HighwayHash256, &[]);
for index in offline {
readers[index] = None;
}
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, offset, length, payload.len(), None, handles),
)
.await;
assert!(error.is_none(), "legacy={uses_legacy}, offline={offline:?}, offset={offset}: {error:?}");
assert_eq!(written, length);
assert_eq!(output, payload[offset..offset + length]);
}
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_recovers_exact_quorum_after_first_stripe() {
const BLOCK_SIZE: usize = 768;
let payload: Vec<_> = (0..BLOCK_SIZE * 3 + 37)
.map(|index| (index.wrapping_mul(31) % 251) as u8)
.collect();
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, false).await;
let first_frame = erasure.shard_size() + HashAlgorithm::HighwayHash256.size();
let (readers, handles) = readers_with_deferred_parity(
&buffers,
12,
erasure.shard_size(),
&HashAlgorithm::HighwayHash256,
&[(0, first_frame), (1, first_frame), (2, first_frame), (3, first_frame)],
);
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, 0, payload.len(), payload.len(), None, handles),
)
.await;
assert!(error.is_none(), "legacy={uses_legacy}: late shard loss should recover: {error:?}");
assert_eq!(written, payload.len());
assert_eq!(output, payload);
}
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_below_read_quorum_fails_before_output() {
const BLOCK_SIZE: usize = 768;
let payload = vec![0x5a; BLOCK_SIZE * 2 + 37];
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, false).await;
let (mut readers, handles) =
readers_with_deferred_parity(&buffers, 12, erasure.shard_size(), &HashAlgorithm::HighwayHash256, &[]);
for reader in readers.iter_mut().take(5) {
*reader = None;
}
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, 0, payload.len(), payload.len(), None, handles),
)
.await;
assert_eq!(written, 0);
assert!(output.is_empty());
assert_eq!(
error
.expect("eleven shards cannot reconstruct twelve data shards")
.to_string(),
Error::ErasureReadQuorum.to_string()
);
}
}
#[tokio::test]
#[serial_test::serial]
async fn copy_source_rejects_corrupt_surplus_parity_before_output() {
const BLOCK_SIZE: usize = 768;
let payload = vec![0x5a; BLOCK_SIZE * 2 + 37];
for uses_legacy in [false, true] {
let erasure = Erasure::new_with_options(12, 4, BLOCK_SIZE, uses_legacy);
let buffers = copy_source_bitrot_shards(&erasure, &payload, true).await;
let (mut readers, handles) =
readers_with_deferred_parity(&buffers, 12, erasure.shard_size(), &HashAlgorithm::HighwayHash256, &[]);
for reader in readers.iter_mut().take(3) {
*reader = None;
}
let mut output = Vec::new();
let (written, error) = crate::set_disk::with_get_object_read_policy(
crate::set_disk::GetObjectReadPolicy::CopySource,
erasure.decode_with_stripe_handles(&mut output, readers, 0, payload.len(), payload.len(), None, handles),
)
.await;
assert_eq!(written, 0);
assert!(output.is_empty());
let error = error.expect("copy must reject inconsistent surplus parity despite valid bitrot framing");
assert_eq!(error.kind(), ErrorKind::InvalidData);
assert!(error.to_string().contains("inconsistent read source shards"));
}
}
/// Guards the shard-buffer reuse in `ParallelReader`: a multi-stripe
/// `Erasure::decode` that reconstructs missing data shards on every stripe
/// must still return byte-exact output. Reconstructed and parity buffers are
@@ -3834,10 +4005,45 @@ mod tests {
temp_env::async_with_vars(vars, async {
let readers = build_prefetch_readers(&shard_bufs, shard_size, &hash_algo, BLOCK_SIZE, 0, &[], &[1]);
let mut output = Vec::new();
let (written, err) = erasure.decode(&mut output, readers, 0, total_len, total_len).await;
assert!(err.is_none(), "{label}: corrupt-shard read errored {err:?}");
assert_eq!(written, total_len, "{label}: corrupt-shard short write");
let outcome = erasure
.decode_with_stripe_handles_and_reopeners_with_diagnostics(
&mut output,
readers,
0,
total_len,
total_len,
None,
Vec::new(),
Vec::new(),
)
.await;
assert!(outcome.error.is_none(), "{label}: corrupt-shard read errored {:?}", outcome.error);
assert_eq!(outcome.written, total_len, "{label}: corrupt-shard short write");
assert_eq!(output, total_data, "{label}: corrupt bytes leaked into output");
assert!(
outcome.repair.needed(false),
"{label}: minority corruption must survive successful reconstruction"
);
let readers = build_prefetch_readers(&shard_bufs, shard_size, &hash_algo, BLOCK_SIZE, 0, &[], &[]);
let mut healthy_output = Vec::new();
let healthy = erasure
.decode_with_stripe_handles_and_reopeners_with_diagnostics(
&mut healthy_output,
readers,
0,
total_len,
total_len,
None,
Vec::new(),
Vec::new(),
)
.await;
assert!(healthy.error.is_none());
assert_eq!(healthy_output, total_data);
assert!(
!healthy.repair.needed(true),
"{label}: healthy/deferred shards cannot create repair evidence"
);
})
.await;
}
@@ -4635,40 +4841,38 @@ mod tests {
(ENV_RUSTFS_GET_SHARD_LOCALITY_PREFERENCE_ENABLE, None::<&str>),
],
async {
const NUM_SHARDS: usize = 1;
const BLOCK_SIZE: usize = 64;
const DATA_SHARDS: usize = 4;
const PARITY_SHARDS: usize = 2;
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
const NUM_SHARDS: usize = 1;
const BLOCK_SIZE: usize = 64;
const DATA_SHARDS: usize = 4;
const PARITY_SHARDS: usize = 2;
const SHARD_SIZE: usize = BLOCK_SIZE / DATA_SHARDS;
let hash_algo = HashAlgorithm::HighwayHash256;
let readers = make_test_readers(DATA_SHARDS + PARITY_SHARDS, SHARD_SIZE, NUM_SHARDS, &hash_algo, &[], &[1]).await;
let read_costs = vec![
ShardReadCost::Local,
ShardReadCost::Local,
ShardReadCost::Local,
ShardReadCost::Local,
ShardReadCost::Remote,
ShardReadCost::Remote,
];
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
let mut parallel_reader = ParallelReader::new_with_metrics_path_and_read_costs(
readers,
erasure,
0,
NUM_SHARDS * BLOCK_SIZE,
None,
read_costs,
);
let hash_algo = HashAlgorithm::HighwayHash256;
let readers = make_test_readers(DATA_SHARDS + PARITY_SHARDS, SHARD_SIZE, NUM_SHARDS, &hash_algo, &[], &[1]).await;
let read_costs = vec![
ShardReadCost::Local,
ShardReadCost::Local,
ShardReadCost::Local,
ShardReadCost::Local,
ShardReadCost::Remote,
ShardReadCost::Remote,
];
let erasure = Erasure::new(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE);
let mut parallel_reader = ParallelReader::new_with_metrics_path_and_read_costs(
readers,
erasure,
0,
NUM_SHARDS * BLOCK_SIZE,
None,
read_costs,
);
let (bufs, errs) = parallel_reader.read().await;
let (bufs, errs) = parallel_reader.read().await;
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
assert!(
matches!(&errs[1], Some(DiskError::Io(err)) if err.kind() == ErrorKind::InvalidData && err.to_string().contains("bitrot"))
);
assert_eq!(bufs[4].as_deref(), Some(&[4u8; SHARD_SIZE][..]));
assert!(bufs[5].is_none());
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
assert_eq!(errs[1], Some(DiskError::FileCorrupt));
assert_eq!(bufs[4].as_deref(), Some(&[4u8; SHARD_SIZE][..]));
assert!(bufs[5].is_none());
},
)
.await;
@@ -4814,16 +5018,7 @@ mod tests {
assert_eq!(DATA_SHARDS, bufs.iter().filter(|buf| buf.is_some()).count());
assert_eq!(
BITROT_DISKS,
errs.iter()
.filter(|err| {
match err {
Some(DiskError::Io(err)) => {
err.kind() == std::io::ErrorKind::InvalidData && err.to_string().contains("bitrot")
}
_ => false,
}
})
.count()
errs.iter().filter(|err| matches!(err, Some(DiskError::FileCorrupt))).count()
);
}
}
@@ -587,7 +587,9 @@ pub(crate) async fn verify_deep_parts(
return Err(DiskError::FileCorrupt);
}
let part_status = statuses.get_mut(&part_index).ok_or(DiskError::FileCorrupt)?;
let length = erasure.shard_file_offset(0, part.size, part.size);
// Deep verification must consume every encoded stripe, including a
// partial final stripe, before certifying the shard's integrity.
let length = usize::try_from(erasure.shard_file_size(part.size as i64)).map_err(|_| DiskError::FileCorrupt)?;
let mut readers = Vec::with_capacity(disks.len());
for (index, disk) in disks.iter().enumerate() {
if part_status[index] != CHECK_PART_UNKNOWN {
@@ -631,6 +633,9 @@ pub(crate) async fn verify_deep_parts(
part_status[index] = CHECK_PART_FILE_CORRUPT;
}
Err(error) => {
if !matches!(error, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::FileCorrupt) {
return Err(error);
}
readers.push(None);
part_status[index] = conv_part_err_to_int(&Some(error));
}
+2
View File
@@ -935,6 +935,8 @@ pub struct ObjectOptions {
/// Persisted bucket incarnation observed before authorization.
pub expected_bucket_incarnation_id: Option<Uuid>,
pub no_lock: bool,
/// Internal read-only inspection must not enqueue metadata or payload repairs.
pub suppress_read_repair: bool,
/// Control-plane writers that immediately read or CAS the same namespace
/// key use TailDrained without changing namespace lock ownership.
#[doc(hidden)]
@@ -0,0 +1,290 @@
// Copyright 2024 RustFS Team
// Licensed under the Apache License, Version 2.0.
//! Explicit, bounded integrity operations. This service does not participate in ordinary GET or Heal.
//! Lock order: cluster worker lock, bucket incarnation fence, object commit lock.
//! Control requests use checkpoint CAS without taking the worker lock; a worker that loses CAS stops.
mod model;
mod runner;
#[cfg(test)]
mod tests;
pub use model::{
InventoryItem, InventoryPage, ItemRequest, ItemResult, ItemState, Job, JobMode, JobRequest, JobState, Protection, Readiness,
readiness,
};
use crate::config::com::{read_config_limited_preserve_empty_with_metadata, save_config_with_opts_quiet};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::Error as StorageError;
use crate::object_api::{ObjectOptions, WriteCompletion};
use crate::storage_api_contracts::{
list::ListOperations,
namespace::NamespaceLocking,
object::{HTTPPreconditions, ObjectOperations},
};
use crate::store::ECStore;
use model::InventoryItem as Item;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
type Result<T> = std::result::Result<T, IntegrityError>;
#[derive(Debug, thiserror::Error)]
pub enum IntegrityError {
#[error("invalid integrity request: {0}")]
Invalid(&'static str),
#[error("integrity job or target changed concurrently")]
Conflict,
#[error("an integrity worker is already active")]
Busy,
#[error("integrity job not found")]
NotFound,
#[error("integrity operation was stopped")]
Stopped,
#[error("protected writes are not enabled on this coordinator")]
NotActivated,
#[error("unsupported migration bucket configuration")]
UnsupportedBucket,
#[error("integrity storage operation failed: {0}")]
Storage(#[from] StorageError),
#[error("integrity stream operation failed: {0}")]
Io(#[from] std::io::Error),
#[error("integrity checkpoint is malformed: {0}")]
Json(#[from] serde_json::Error),
}
fn check_bucket(bucket: &str) -> Result<()> {
crate::bucket::utils::check_valid_bucket_name_strict(bucket).map_err(|_| IntegrityError::Invalid("invalid source bucket"))
}
fn path(bucket: &str, id: Uuid) -> String {
format!("buckets/{bucket}/integrity-jobs/{id}.json")
}
pub async fn inventory(
api: Arc<ECStore>,
bucket: &str,
prefix: &str,
key_marker: Option<String>,
version_marker: Option<String>,
limit: i32,
) -> Result<InventoryPage> {
check_bucket(bucket)?;
if !(1..=100).contains(&limit) || prefix.len() > 1024 || (version_marker.is_some() && key_marker.is_none()) {
return Err(IntegrityError::Invalid("invalid inventory page"));
}
let incarnation = api.bucket_incarnation_id_from_disk(bucket).await?;
let page = api
.clone()
.list_object_versions(bucket, prefix, key_marker.clone(), version_marker.clone(), None, limit)
.await?;
if page.is_truncated && (page.next_marker.clone(), page.next_version_idmarker.clone()) == (key_marker, version_marker) {
return Err(IntegrityError::Invalid("inventory cursor did not advance"));
}
let mut items = Vec::with_capacity(page.objects.len());
for listed in page.objects {
if listed.delete_marker {
items.push(Item::from_info(&listed));
continue;
}
let opts = ObjectOptions {
version_id: Some(listed.version_id.unwrap_or_else(Uuid::nil).to_string()),
include_part_checksums: true,
suppress_read_repair: true,
..Default::default()
};
match api.get_object_info(bucket, &listed.name, &opts).await {
Ok(info) => items.push(Item::from_info(&info)),
Err(_) => {
let mut item = Item::from_info(&listed);
item.observation_error = Some("metadata_unavailable_or_changed".into());
item.protection = Protection::Unknown;
item.audit_unsupported = Some("metadata_unavailable_or_changed".into());
items.push(item);
}
}
}
if api.bucket_incarnation_id_from_disk(bucket).await? != incarnation {
return Err(IntegrityError::Conflict);
}
Ok(InventoryPage {
observed_at: time::OffsetDateTime::now_utc().to_string(),
bucket_incarnation: incarnation,
items,
is_truncated: page.is_truncated,
next_key_marker: page.next_marker,
next_version_marker: page.next_version_idmarker,
})
}
pub async fn create_job(api: Arc<ECStore>, bucket: &str, request: JobRequest) -> Result<Job> {
check_bucket(bucket)?;
request.validate()?;
if request.mode == JobMode::Migrate && !readiness().new_writes_enabled_here {
return Err(IntegrityError::NotActivated);
}
let incarnation = api.bucket_incarnation_id_from_disk(bucket).await?;
let job = Job {
format_version: 1,
id: Uuid::new_v4(),
bucket: bucket.into(),
bucket_incarnation: incarnation,
results: vec![ItemResult::default(); request.items.len()],
request,
state: JobState::Paused,
revision: 0,
last_error: None,
};
save(&api, &job, None, None).await?;
Ok(job)
}
pub async fn get_job(api: Arc<ECStore>, bucket: &str, id: Uuid) -> Result<Job> {
Ok(load(&api, bucket, id).await?.0)
}
async fn load(api: &Arc<ECStore>, bucket: &str, id: Uuid) -> Result<(Job, String)> {
check_bucket(bucket)?;
let (bytes, info) = match read_config_limited_preserve_empty_with_metadata(api.clone(), &path(bucket, id), 1024 * 1024).await
{
Ok(value) => value,
Err(StorageError::ConfigNotFound | StorageError::FileNotFound) => return Err(IntegrityError::NotFound),
Err(error) => return Err(error.into()),
};
let job: Job = serde_json::from_slice(&bytes)?;
job.request.validate()?;
if job.format_version != 1
|| job.bucket != bucket
|| job.id != id
|| job.results.len() != job.request.items.len()
|| api.bucket_incarnation_id_from_disk(bucket).await? != job.bucket_incarnation
{
return Err(IntegrityError::Conflict);
}
Ok((job, info.etag.ok_or(IntegrityError::Conflict)?))
}
async fn save(
api: &Arc<ECStore>,
job: &Job,
etag: Option<&str>,
worker: Option<&rustfs_lock::NamespaceLockGuard>,
) -> Result<String> {
let fence = api
.acquire_bucket_incarnation_fence(&job.bucket, job.bucket_incarnation)
.await?;
let mut opts = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
http_preconditions: Some(match etag {
Some(value) => HTTPPreconditions {
if_match: Some(value.into()),
..Default::default()
},
None => HTTPPreconditions {
if_none_match: Some("*".into()),
..Default::default()
},
}),
..Default::default()
};
fence.attach_to_object_options(&mut opts);
if let Some(guard) = worker {
opts.add_namespace_lock_guard(guard);
}
let api = api.clone();
let job = job.clone();
// Keep the lifecycle guard through the drained commit even if the HTTP waiter is cancelled.
tokio::spawn(async move {
save_config_with_opts_quiet(api.clone(), &path(&job.bucket, job.id), serde_json::to_vec(&job)?, &opts).await?;
let (stored, next) = load(&api, &job.bucket, job.id).await?;
if stored.revision != job.revision {
return Err(IntegrityError::Conflict);
}
drop(fence);
Ok(next)
})
.await
.map_err(|_| IntegrityError::Invalid("checkpoint commit task failed"))?
}
/// Pause/cancel acknowledge intent. An in-flight conditional publication is allowed to drain.
pub async fn control_job(api: Arc<ECStore>, bucket: &str, id: Uuid, cancel: bool) -> Result<Job> {
let (mut job, etag) = load(&api, bucket, id).await?;
match job.state {
JobState::Complete | JobState::Cancelled | JobState::CancelRequested => return Ok(job),
JobState::Paused | JobState::Failed => job.state = if cancel { JobState::Cancelled } else { JobState::Paused },
_ => {
job.state = if cancel {
JobState::CancelRequested
} else {
JobState::PauseRequested
}
}
}
job.revision = job.revision.checked_add(1).ok_or(IntegrityError::Conflict)?;
save(&api, &job, Some(&etag), None).await?;
Ok(job)
}
/// Resume is explicit after restart. The distributed lock excludes another worker, including another node.
pub async fn resume_job(api: Arc<ECStore>, bucket: &str, id: Uuid) -> Result<Job> {
check_bucket(bucket)?;
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, "integrity-jobs/worker.lock").await?;
let guard = lock
.get_write_lock_quiet(Duration::from_secs(1))
.await
.map_err(|_| IntegrityError::Busy)?;
let (mut job, etag) = load(&api, bucket, id).await?;
if matches!(job.state, JobState::Complete | JobState::Cancelled) {
return Ok(job);
}
if job.state == JobState::CancelRequested {
job.state = JobState::Cancelled;
job.revision = job.revision.checked_add(1).ok_or(IntegrityError::Conflict)?;
save(&api, &job, Some(&etag), Some(&guard)).await?;
return Ok(job);
}
if job.request.mode == JobMode::Migrate && !readiness().new_writes_enabled_here {
return Err(IntegrityError::NotActivated);
}
job.state = JobState::Running;
job.last_error = None;
job.revision = job.revision.checked_add(1).ok_or(IntegrityError::Conflict)?;
let etag = save(&api, &job, Some(&etag), Some(&guard)).await?;
let response = job.clone();
tokio::spawn(async move {
let result = runner::run(&api, &mut job, etag, &guard).await;
if let Err(error) = result {
// A failed write/CAS stops publication. Never overwrite a concurrent control request.
if let Ok((mut latest, etag)) = load(&api, &job.bucket, job.id).await {
match latest.state {
JobState::PauseRequested => latest.state = JobState::Paused,
JobState::CancelRequested => latest.state = JobState::Cancelled,
JobState::Running if latest.revision <= job.revision => {
latest.state = JobState::Failed;
latest.last_error = Some(
match error {
IntegrityError::Conflict => "concurrent_change",
IntegrityError::Stopped => "worker_stopped",
IntegrityError::UnsupportedBucket => "unsupported_destination_bucket_configuration",
IntegrityError::NotActivated => "protected_writes_not_activated",
_ => "operation_failed_retry_requires_resume",
}
.into(),
);
}
_ => return,
}
if let Some(revision) = latest.revision.checked_add(1) {
latest.revision = revision;
let _ = save(&api, &latest, Some(&etag), Some(&guard)).await;
}
}
}
});
Ok(response)
}
@@ -0,0 +1,343 @@
// Copyright 2024 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::{IntegrityError, Result};
use crate::object_api::ObjectInfo;
use rustfs_rio::{Checksum, read_checksums};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use uuid::Uuid;
pub const MAX_ITEMS: usize = 64;
pub const MAX_OBJECT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ItemRequest {
pub key: String,
pub version_id: Option<String>,
/// Base64 SHA-256 supplied by the administrator from an independent source.
pub expected_sha256: Option<String>,
pub target_key: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobMode {
Audit,
Migrate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobRequest {
pub mode: JobMode,
pub items: Vec<ItemRequest>,
/// Bounds each source read, destination PUT, and verification stream; one worker runs per cluster.
pub bytes_per_second: u64,
pub max_object_bytes: u64,
}
impl JobRequest {
pub(crate) fn validate(&self) -> Result<()> {
if self.items.is_empty() || self.items.len() > MAX_ITEMS {
return Err(IntegrityError::Invalid("a job requires 1..=64 explicit source versions"));
}
if !(64 * 1024..=1024 * 1024 * 1024).contains(&self.bytes_per_second)
|| self.max_object_bytes == 0
|| self.max_object_bytes > MAX_OBJECT_BYTES
{
return Err(IntegrityError::Invalid("invalid byte rate or staging limit"));
}
let mut targets = HashSet::new();
let sources: HashSet<_> = self.items.iter().map(|i| i.key.as_str()).collect();
for item in &self.items {
validate_key(&item.key)?;
if let Some(version) = &item.version_id
&& version != "null"
&& Uuid::parse_str(version).ok().is_none_or(|id| id.is_nil())
{
return Err(IntegrityError::Invalid("invalid source version"));
}
if let Some(value) = &item.expected_sha256 {
sha256_bytes(value)?;
}
match (self.mode, &item.target_key) {
(JobMode::Audit, None) => {}
(JobMode::Migrate, Some(target)) => {
validate_key(target)?;
if sources.contains(target.as_str()) || !targets.insert(target) {
return Err(IntegrityError::Invalid("targets must be distinct from every source and target"));
}
}
_ => return Err(IntegrityError::Invalid("only migrations require an explicit target key")),
}
}
Ok(())
}
}
fn validate_key(key: &str) -> Result<()> {
if key.is_empty() || key.len() > 1024 || key.ends_with('/') || key.contains('\0') {
return Err(IntegrityError::Invalid("invalid object key"));
}
Ok(())
}
pub(crate) fn sha256_bytes(value: &str) -> Result<Vec<u8>> {
let raw = base64_simd::STANDARD
.decode_to_vec(value)
.map_err(|_| IntegrityError::Invalid("SHA256 must be canonical Base64"))?;
if raw.len() != 32 || base64_simd::STANDARD.encode_to_string(&raw) != value {
return Err(IntegrityError::Invalid("SHA256 must be canonical Base64 of 32 bytes"));
}
Ok(raw)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Protection {
Unknown,
Legacy,
IndependentCommitment,
InvalidDeclaration,
NoPayload,
}
pub(crate) fn protection(info: &ObjectInfo) -> Protection {
if info.delete_marker {
return Protection::NoPayload;
}
match rustfs_filemeta::shard_integrity::descriptor_from_metadata(&info.user_defined) {
Ok(None) if info.parts.iter().all(|p| p.integrity.is_none()) => Protection::Legacy,
Ok(Some(parts))
if parts.len() == info.parts.len()
&& !parts.is_empty()
&& parts
.iter()
.zip(info.parts.iter())
.all(|(declared, part)| part.integrity.as_ref() == Some(declared)) =>
{
Protection::IndependentCommitment
}
_ => Protection::InvalidDeclaration,
}
}
/// Checksums shown in inventory are not automatically accepted as migration evidence.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryItem {
pub key: String,
/// S3 selector, including the literal "null" for the null version slot.
pub version_id: String,
pub data_dir: Option<Uuid>,
pub size: i64,
pub protection: Protection,
pub checksums: BTreeMap<String, String>,
pub checksum_is_multipart: bool,
pub audit_unsupported: Option<String>,
pub source_fingerprint: String,
pub observation_error: Option<String>,
}
impl InventoryItem {
pub(crate) fn from_info(info: &ObjectInfo) -> Self {
let (checksums, checksum_is_multipart) = info.checksum.as_ref().map_or_else(Default::default, |b| read_checksums(b, 0));
Self {
key: info.name.clone(),
version_id: info
.version_id
.filter(|id| !id.is_nil())
.map_or_else(|| "null".into(), |id| id.to_string()),
data_dir: info.data_dir,
size: info.size,
protection: protection(info),
checksums: checksums.into_iter().collect(),
checksum_is_multipart,
audit_unsupported: unsupported(info).map(str::to_owned),
source_fingerprint: fingerprint(info),
observation_error: None,
}
}
}
pub(crate) fn unsupported(info: &ObjectInfo) -> Option<&'static str> {
if info.delete_marker {
Some("delete_marker")
} else if !info.transitioned_object.status.is_empty() {
Some("transitioned_object")
} else if info.is_encrypted() || info.is_compressed() {
Some("transformed_object")
} else if info.is_multipart() {
Some("multipart_object")
} else if protection(info) == Protection::InvalidDeclaration {
Some("invalid_protection_declaration")
} else {
None
}
}
pub(crate) fn stored_sha256(info: &ObjectInfo) -> Option<String> {
let bytes = info.checksum.as_ref()?;
let (sums, multipart) = read_checksums(bytes, 0);
let value = sums.get("SHA256")?;
let canonical = Checksum::new_from_string("SHA256", value)?.to_bytes(&[]);
// The general display decoder tolerates truncated suffixes. Eligibility does not.
(!multipart && canonical.as_ref() == bytes.as_ref()).then(|| value.clone())
}
pub(crate) fn fingerprint(info: &ObjectInfo) -> String {
let mut hash = Sha256::new();
// Ordered metadata includes transformation and protection declarations as well as user metadata.
for value in [
info.bucket.clone(),
info.name.clone(),
format!("{:?}", info.version_id),
format!("{:?}", info.data_dir),
format!("{:?}", info.mod_time),
info.size.to_string(),
info.actual_size.to_string(),
format!("{:?}", info.etag),
format!("{:?}", info.checksum),
format!("{:?}", info.transitioned_object),
info.user_tags.to_string(),
format!("{:?}", info.expires),
format!("{:?}", info.content_type),
format!("{:?}", info.content_encoding),
format!("{:?}", info.storage_class),
] {
hash.update(value.len().to_le_bytes());
hash.update(value.as_bytes());
}
for part in info.parts.iter() {
let values = format!(
"{}:{}:{}:{:?}:{:?}:{:?}:{:?}",
part.number, part.size, part.actual_size, part.etag, part.mod_time, part.index, part.integrity
);
hash.update(values.len().to_le_bytes());
hash.update(values.as_bytes());
if let Some(checksums) = &part.checksums {
let mut checksums: Vec<_> = checksums.iter().collect();
checksums.sort_unstable_by_key(|(key, _)| *key);
for (key, value) in checksums {
hash.update(key.len().to_le_bytes());
hash.update(key.as_bytes());
hash.update(value.len().to_le_bytes());
hash.update(value.as_bytes());
}
}
}
let mut metadata: Vec<_> = info.user_defined.iter().collect();
metadata.sort_unstable_by_key(|(key, _)| *key);
for (key, value) in metadata {
hash.update(key.len().to_le_bytes());
hash.update(key.as_bytes());
hash.update(value.len().to_le_bytes());
hash.update(value.as_bytes());
}
hex_simd::encode_to_string(hash.finalize(), hex_simd::AsciiCase::Lower)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobState {
Paused,
Running,
PauseRequested,
CancelRequested,
Cancelled,
Complete,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemState {
Pending,
Prepared,
Verified,
Migrated,
Unsupported,
Unavailable,
Mismatch,
Stale,
Conflict,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ItemResult {
pub state: ItemState,
pub source_fingerprint: Option<String>,
pub expected_sha256: Option<String>,
pub evidence_source: Option<String>,
pub source_size: Option<u64>,
pub target_version_id: Option<Uuid>,
pub detail: Option<String>,
}
impl Default for ItemResult {
fn default() -> Self {
Self {
state: ItemState::Pending,
source_fingerprint: None,
expected_sha256: None,
evidence_source: None,
source_size: None,
target_version_id: None,
detail: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Job {
pub format_version: u32,
pub id: Uuid,
pub bucket: String,
pub bucket_incarnation: Uuid,
pub request: JobRequest,
pub state: JobState,
pub revision: u64,
pub results: Vec<ItemResult>,
pub last_error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct InventoryPage {
pub observed_at: String,
pub bucket_incarnation: Uuid,
pub items: Vec<InventoryItem>,
pub is_truncated: bool,
pub next_key_marker: Option<String>,
pub next_version_marker: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Readiness {
pub local_reader_supported: bool,
pub write_requested: bool,
pub fleet_operator_attested: bool,
pub new_writes_enabled_here: bool,
pub fleet_capability_verified: bool,
pub unverified_requirements: [&'static str; 3],
}
pub fn readiness() -> Readiness {
let write = rustfs_utils::get_env_bool(rustfs_config::ENV_SHARD_INTEGRITY_WRITE, false);
let fleet = rustfs_utils::get_env_bool(rustfs_config::ENV_SHARD_INTEGRITY_FLEET_CONFIRMED, false);
Readiness {
local_reader_supported: true,
write_requested: write,
fleet_operator_attested: fleet,
new_writes_enabled_here: write && fleet,
fleet_capability_verified: false,
unverified_requirements: [
"peer_reader_writer_and_repair_capabilities",
"old_process_storage_admission",
"deployment_failure_and_performance_qualification",
],
}
}
@@ -0,0 +1,468 @@
// Copyright 2024 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::model::{Protection, fingerprint, protection, sha256_bytes, stored_sha256, unsupported};
use super::{IntegrityError, ItemState, Job, JobMode, JobState, Result, load, readiness, save};
use crate::object_api::{
ObjectInfo, ObjectOptions, PutObjReader, ShardIntegrityWriteMode, WriteCompletion, without_get_object_body_cache_hook,
};
use crate::storage_api_contracts::object::{HTTPPreconditions, ObjectIO, ObjectOperations};
use crate::store::ECStore;
use rustfs_rio::{Checksum, HashReader};
use sha2::{Digest, Sha256};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
const MARKER: &str = "integrity-migration-job-v1";
pub(super) async fn run(
api: &Arc<ECStore>,
job: &mut Job,
mut etag: String,
guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
without_get_object_body_cache_hook(async {
for index in 0..job.results.len() {
ensure_running(api, job, guard).await?;
if !matches!(
job.results[index].state,
ItemState::Pending | ItemState::Prepared | ItemState::Unavailable
) {
continue;
}
if job.request.mode == JobMode::Migrate {
validate_destination(api, job).await?;
}
if job.results[index].state == ItemState::Prepared
&& job.request.mode == JobMode::Migrate
&& reconcile_target(api, job, index, guard).await?
{
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
let request = job.request.items[index].clone();
let opts = ObjectOptions {
version_id: request.version_id.as_ref().map(|version| {
if version == "null" {
uuid::Uuid::nil().to_string()
} else {
version.clone()
}
}),
include_part_checksums: true,
suppress_read_repair: true,
..Default::default()
};
let info = match api.get_object_info(&job.bucket, &request.key, &opts).await {
Ok(info) => info,
Err(_) => {
job.results[index].state = ItemState::Unavailable;
job.results[index].detail = Some("source_metadata_unavailable".into());
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
};
if let Some(reason) = unsupported(&info) {
job.results[index].state = ItemState::Unsupported;
job.results[index].detail = Some(reason.into());
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
let identity = fingerprint(&info);
if job.results[index]
.source_fingerprint
.as_ref()
.is_some_and(|old| old != &identity)
{
job.results[index].state = ItemState::Stale;
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
let expected = request.expected_sha256.clone().or_else(|| stored_sha256(&info));
let Some(expected) = expected else {
job.results[index].state = ItemState::Unsupported;
job.results[index].detail = Some("no_canonical_single_part_sha256".into());
etag = checkpoint(api, job, &etag, guard).await?;
continue;
};
let size = u64::try_from(info.size).map_err(|_| IntegrityError::Invalid("negative source size"))?;
if size > job.request.max_object_bytes {
job.results[index].state = ItemState::Unsupported;
job.results[index].detail = Some("object_exceeds_staging_budget".into());
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
let result = &mut job.results[index];
result.source_fingerprint = Some(identity);
result.expected_sha256 = Some(expected.clone());
result.evidence_source = Some(
if request.expected_sha256.is_some() {
"administrator_supplied_sha256"
} else {
"stored_sha256"
}
.into(),
);
result.source_size = Some(size);
result.state = ItemState::Prepared;
result.detail = None;
etag = checkpoint(api, job, &etag, guard).await?;
let mut file = match stage(api, job, index, &info, &opts, guard).await {
Ok(file) => file,
Err(IntegrityError::Conflict) => {
job.results[index].state = ItemState::Stale;
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
Err(IntegrityError::Invalid("content_sha256_mismatch")) => {
job.results[index].state = ItemState::Mismatch;
etag = checkpoint(api, job, &etag, guard).await?;
continue;
}
Err(error) => return Err(error),
};
ensure_running(api, job, guard).await?;
if job.request.mode == JobMode::Audit {
job.results[index].state = ItemState::Verified;
} else {
let snapshot = validate_destination(api, job).await?;
file.rewind().await?;
let raw = sha256_bytes(&expected)?;
let expected_hex = hex_simd::encode_to_string(raw, hex_simd::AsciiCase::Lower);
let mut reader = HashReader::from_stream(
ThrottledFile::new(file, job.request.bytes_per_second),
info.size,
info.size,
None,
Some(expected_hex),
false,
)?;
reader.add_non_trailing_checksum(Checksum::new_from_string("SHA256", &expected), false)?;
let mut body = PutObjReader::new(reader);
let mut metadata: HashMap<String, String> = info
.user_defined
.iter()
.filter(|(key, _)| {
key.starts_with("x-amz-meta-")
|| matches!(key.as_str(), "cache-control" | "content-disposition" | "content-language" | "expires")
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
if let Some(content_type) = &info.content_type {
metadata.insert("content-type".into(), content_type.clone());
}
if let Some(content_encoding) = &info.content_encoding {
metadata.insert("content-encoding".into(), content_encoding.clone());
}
if let Some(expires) = info.expires {
metadata.insert(
"expires".into(),
expires
.format(&time::format_description::well_known::Rfc3339)
.map_err(|_| IntegrityError::Invalid("invalid source expiration"))?,
);
}
if let Some(storage_class) = &info.storage_class {
metadata.insert("x-amz-storage-class".into(), storage_class.clone());
}
if !info.user_tags.is_empty() {
metadata.insert(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING.into(), info.user_tags.to_string());
}
rustfs_utils::http::insert_str(&mut metadata, MARKER, marker(job, index)?);
let mut write = ObjectOptions {
user_defined: metadata,
preserve_delete_marker: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
write_completion: WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".into()),
..Default::default()
}),
..Default::default()
};
write.add_namespace_lock_guard(guard);
write.expected_bucket_incarnation_id = Some(job.bucket_incarnation);
snapshot.add_lock_fences(&mut write);
write.object_lock_config_snapshot = Some(snapshot.clone());
let target = request
.target_key
.as_deref()
.ok_or(IntegrityError::Invalid("missing target"))?;
// Even an error may follow a committed PUT. Reconcile before retrying; never overwrite.
let put = api.put_object(&job.bucket, target, &mut body, &write).await;
drop(write);
drop(snapshot);
if !reconcile_target(api, job, index, guard).await? {
put?;
return Err(IntegrityError::Conflict);
}
}
etag = checkpoint(api, job, &etag, guard).await?;
}
job.state = if job.results.iter().any(|item| item.state == ItemState::Unavailable) {
job.last_error = Some("source_unavailable_retry_requires_resume".into());
JobState::Failed
} else {
JobState::Complete
};
checkpoint(api, job, &etag, guard).await?;
Ok(())
})
.await
}
async fn checkpoint(api: &Arc<ECStore>, job: &mut Job, etag: &str, guard: &rustfs_lock::NamespaceLockGuard) -> Result<String> {
ensure_running(api, job, guard).await?;
job.revision = job.revision.checked_add(1).ok_or(IntegrityError::Conflict)?;
save(api, job, Some(etag), Some(guard)).await
}
async fn ensure_running(api: &Arc<ECStore>, job: &Job, guard: &rustfs_lock::NamespaceLockGuard) -> Result<()> {
if guard.lock_lost_signal().is_some_and(|signal| signal.is_lost()) {
return Err(IntegrityError::Stopped);
}
let (current, _) = load(api, &job.bucket, job.id).await?;
if current.state != JobState::Running {
return Err(IntegrityError::Stopped);
}
if current.revision != job.revision {
return Err(IntegrityError::Conflict);
}
Ok(())
}
async fn stage(
api: &Arc<ECStore>,
job: &Job,
index: usize,
info: &ObjectInfo,
opts: &ObjectOptions,
guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<tokio::fs::File> {
let mut reader = api
.get_object_reader(&job.bucket, &job.request.items[index].key, None, Default::default(), opts)
.await?;
if fingerprint(&reader.object_info) != fingerprint(info) {
return Err(IntegrityError::Conflict);
}
let std_file = tokio::task::spawn_blocking(tempfile::tempfile)
.await
.map_err(|_| IntegrityError::Invalid("staging task failed"))??;
let mut file = tokio::fs::File::from_std(std_file);
let expected = job.results[index]
.expected_sha256
.as_deref()
.ok_or(IntegrityError::Invalid("missing expected digest"))?;
copy_verified(api, job, &mut reader.stream, Some(&mut file), info.size, expected, guard).await?;
file.flush().await?;
let after = api.get_object_info(&job.bucket, &job.request.items[index].key, opts).await?;
if fingerprint(&after) != fingerprint(info) {
return Err(IntegrityError::Conflict);
}
Ok(file)
}
async fn copy_verified(
api: &Arc<ECStore>,
job: &Job,
input: &mut (dyn tokio::io::AsyncRead + Unpin + Send + Sync),
mut output: Option<&mut tokio::fs::File>,
size: i64,
expected: &str,
guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
let expected = sha256_bytes(expected)?;
let expected_size = u64::try_from(size).map_err(|_| IntegrityError::Invalid("negative object size"))?;
let mut buffer = vec![0u8; 64 * 1024];
let mut count = 0u64;
let mut hash = Sha256::new();
let started = tokio::time::Instant::now();
let mut checked = started;
loop {
if checked.elapsed() >= Duration::from_secs(1) {
ensure_running(api, job, guard).await?;
checked = tokio::time::Instant::now();
}
let n = tokio::time::timeout(Duration::from_secs(30), input.read(&mut buffer))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "integrity read timed out"))??;
if n == 0 {
break;
}
count = count
.checked_add(u64::try_from(n).map_err(|_| IntegrityError::Invalid("read size overflow"))?)
.ok_or(IntegrityError::Invalid("read size overflow"))?;
if count > expected_size || count > job.request.max_object_bytes {
return Err(IntegrityError::Invalid("content_sha256_mismatch"));
}
hash.update(&buffer[..n]);
if let Some(file) = output.as_deref_mut() {
file.write_all(&buffer[..n]).await?;
}
let delay = Duration::from_secs_f64(count as f64 / job.request.bytes_per_second as f64);
tokio::time::sleep_until(started + delay).await;
}
if count != expected_size || hash.finalize().as_slice() != expected {
return Err(IntegrityError::Invalid("content_sha256_mismatch"));
}
Ok(())
}
fn marker(job: &Job, index: usize) -> Result<String> {
let result = &job.results[index];
Ok(format!(
"{}:{}:{}:{}",
job.id,
index,
result
.source_fingerprint
.as_deref()
.ok_or(IntegrityError::Invalid("missing source identity"))?,
result
.expected_sha256
.as_deref()
.ok_or(IntegrityError::Invalid("missing source digest"))?
))
}
async fn reconcile_target(
api: &Arc<ECStore>,
job: &mut Job,
index: usize,
guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<bool> {
let key = job.request.items[index]
.target_key
.as_deref()
.ok_or(IntegrityError::Invalid("missing target"))?;
let opts = ObjectOptions {
include_part_checksums: true,
suppress_read_repair: true,
..Default::default()
};
let target = match api.get_object_info(&job.bucket, key, &opts).await {
Ok(info) => info,
Err(crate::error::Error::FileNotFound | crate::error::Error::ObjectNotFound(_, _)) => return Ok(false),
Err(error) => return Err(error.into()),
};
let expected_marker = marker(job, index)?;
if rustfs_utils::http::get_consistent_str(&target.user_defined, MARKER) != Some(expected_marker.as_str()) {
job.results[index].state = ItemState::Conflict;
job.results[index].detail = Some("target_owned_by_another_operation".into());
return Ok(true);
}
if protection(&target) != Protection::IndependentCommitment {
return Err(IntegrityError::Invalid("target_not_protected"));
}
let mut reader = api
.get_object_reader(&job.bucket, key, None, Default::default(), &opts)
.await?;
if fingerprint(&target) != fingerprint(&reader.object_info) {
return Err(IntegrityError::Conflict);
}
let size = job.results[index]
.source_size
.ok_or(IntegrityError::Invalid("missing source size"))?;
let expected = job.results[index]
.expected_sha256
.clone()
.ok_or(IntegrityError::Invalid("missing source digest"))?;
copy_verified(
api,
job,
&mut reader.stream,
None,
i64::try_from(size).map_err(|_| IntegrityError::Invalid("source size overflow"))?,
&expected,
guard,
)
.await?;
let after = api.get_object_info(&job.bucket, key, &opts).await?;
if fingerprint(&after) != fingerprint(&target) {
return Err(IntegrityError::Conflict);
}
job.results[index].state = ItemState::Migrated;
job.results[index].target_version_id = target.version_id;
Ok(true)
}
async fn validate_destination(api: &Arc<ECStore>, job: &Job) -> Result<Arc<crate::object_api::ObjectLockConfigSnapshot>> {
if !readiness().new_writes_enabled_here {
return Err(IntegrityError::NotActivated);
}
let snapshot = api.object_lock_config_snapshot(&job.bucket).await?;
if snapshot
.metadata_transaction_guard_for(api.id, &job.bucket, Some(job.bucket_incarnation))
.is_none()
{
return Err(IntegrityError::Conflict);
}
let metadata = crate::bucket::metadata_sys::integrity_migration_metadata_in(&api.ctx, &job.bucket).await?;
// The direct storage writer is not the S3 orchestration layer. Reject configurations it cannot preserve.
if metadata.bucket_incarnation_id != job.bucket_incarnation {
return Err(IntegrityError::Conflict);
}
if !metadata.versioning_config_xml.is_empty()
|| !metadata.encryption_config_xml.is_empty()
|| !metadata.object_lock_config_xml.is_empty()
|| !metadata.replication_config_xml.is_empty()
|| !metadata.notification_config_xml.is_empty()
|| !metadata.quota_config_json.is_empty()
|| !metadata.on_demand_migration_config_json.is_empty()
|| !metadata.lifecycle_config_xml.is_empty()
|| !metadata.table_bucket_config_json.is_empty()
|| !metadata.bucket_acl_config_json.is_empty()
|| !metadata.logging_config_xml.is_empty()
{
return Err(IntegrityError::UnsupportedBucket);
}
Ok(snapshot)
}
/// Bound publication throughput as well as the source and destination verification reads.
struct ThrottledFile {
file: tokio::fs::File,
started: tokio::time::Instant,
count: u64,
rate: u64,
sleep: std::pin::Pin<Box<tokio::time::Sleep>>,
}
impl ThrottledFile {
fn new(file: tokio::fs::File, rate: u64) -> Self {
let started = tokio::time::Instant::now();
Self {
file,
started,
count: 0,
rate,
sleep: Box::pin(tokio::time::sleep_until(started)),
}
}
}
impl tokio::io::AsyncRead for ThrottledFile {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
output: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
use std::future::Future;
if self.sleep.as_mut().poll(cx).is_pending() {
return std::task::Poll::Pending;
}
let capacity = output.remaining().min(64 * 1024);
let mut buffer = tokio::io::ReadBuf::new(output.initialize_unfilled_to(capacity));
match std::pin::Pin::new(&mut self.file).poll_read(cx, &mut buffer) {
std::task::Poll::Ready(Ok(())) => {
let count = buffer.filled().len();
output.advance(count);
self.count += count as u64;
let deadline = self.started + Duration::from_secs_f64(self.count as f64 / self.rate as f64);
self.sleep.as_mut().reset(deadline);
std::task::Poll::Ready(Ok(()))
}
result => result,
}
}
}
@@ -0,0 +1,387 @@
// Copyright 2024 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::*;
use crate::object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode};
use crate::storage_api_contracts::{
bucket::{BucketOperations, MakeBucketOptions},
object::{ObjectIO, ObjectOperations},
};
use tokio::io::AsyncReadExt;
async fn fixture() -> (Vec<tempfile::TempDir>, Arc<crate::store::ECStore>, String) {
let (dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(Default::default()).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("integrity-{}", Uuid::new_v4());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("fixture bucket");
(dirs, store, bucket)
}
fn request(mode: JobMode, digest: String) -> JobRequest {
JobRequest {
mode,
items: vec![ItemRequest {
key: "source".into(),
version_id: Some("null".into()),
expected_sha256: Some(digest),
target_key: (mode == JobMode::Migrate).then(|| "target".into()),
}],
bytes_per_second: 1024 * 1024,
max_object_bytes: 16 * 1024 * 1024,
}
}
async fn finish(store: Arc<crate::store::ECStore>, bucket: &str, id: Uuid) -> Job {
tokio::time::timeout(std::time::Duration::from_secs(60), async {
loop {
let job = get_job(store.clone(), bucket, id).await.expect("durable job");
if job.state != JobState::Running {
return job;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("job must finish")
}
#[tokio::test]
#[serial_test::serial(shard_integrity_rollout)]
async fn audit_migration_and_ack_recovery_preserve_source_and_reject_conflicts() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_SHARD_INTEGRITY_WRITE, Some("true")),
(rustfs_config::ENV_SHARD_INTEGRITY_FLEET_CONFIRMED, Some("true")),
],
async {
let (_dirs, store, bucket) = fixture().await;
let payload = vec![42; 256 * 1024 + 17];
let expected = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, &payload)
.expect("digest")
.encoded;
store
.put_object(
&bucket,
"source",
&mut PutObjReader::from_vec(payload.clone()),
&ObjectOptions {
user_defined: [
("cache-control".into(), "max-age=300".into()),
("content-disposition".into(), "attachment; filename=sample.bin".into()),
("expires".into(), "2030-01-01T00:00:00Z".into()),
(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING.into(), "kind=archive".into()),
]
.into(),
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("source");
let source = store
.get_object_info(&bucket, "source", &ObjectOptions::default())
.await
.expect("source metadata");
assert_eq!(source.user_tags.as_str(), "kind=archive", "source fixture tags");
let before = model::fingerprint(&source);
let page = inventory(store.clone(), &bucket, "", None, None, 1).await.expect("inventory");
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].protection, Protection::Legacy);
assert_eq!(page.items[0].version_id, "null", "inventory must round-trip an explicit null selector");
let audit = create_job(store.clone(), &bucket, request(JobMode::Audit, expected.clone()))
.await
.expect("create audit");
resume_job(store.clone(), &bucket, audit.id).await.expect("resume audit");
let audited = finish(store.clone(), &bucket, audit.id).await;
assert_eq!(audited.state, JobState::Complete, "{audited:?}");
assert_eq!(audited.results[0].state, ItemState::Verified);
let migration = create_job(store.clone(), &bucket, request(JobMode::Migrate, expected.clone()))
.await
.expect("create migration");
resume_job(store.clone(), &bucket, migration.id)
.await
.expect("resume migration");
let migrated = finish(store.clone(), &bucket, migration.id).await;
assert_eq!(migrated.state, JobState::Complete, "{migrated:?}");
assert_eq!(migrated.results[0].state, ItemState::Migrated);
let mut reader = store
.get_object_reader(&bucket, "target", None, Default::default(), &ObjectOptions::default())
.await
.expect("target");
assert_eq!(model::protection(&reader.object_info), Protection::IndependentCommitment);
assert_eq!(reader.object_info.user_tags.as_str(), "kind=archive");
assert_eq!(reader.object_info.expires, source.expires);
assert_eq!(
reader.object_info.user_defined.get("cache-control"),
source.user_defined.get("cache-control")
);
assert_eq!(
reader.object_info.user_defined.get("content-disposition"),
source.user_defined.get("content-disposition")
);
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("target content");
assert_eq!(actual, payload);
let after = store
.get_object_info(&bucket, "source", &ObjectOptions::default())
.await
.expect("source after");
assert_eq!(model::fingerprint(&after), before);
// Simulate a lost completion checkpoint after the create-only PUT committed.
let (mut replay, etag) = load(&store, &bucket, migration.id).await.expect("load receipt");
replay.state = JobState::Paused;
replay.results[0].state = ItemState::Prepared;
replay.revision += 1;
save(&store, &replay, Some(&etag), None)
.await
.expect("lost acknowledgement fixture");
resume_job(store.clone(), &bucket, replay.id)
.await
.expect("reconcile after restart");
let reconciled = finish(store.clone(), &bucket, replay.id).await;
assert_eq!(reconciled.results[0].state, ItemState::Migrated, "{reconciled:?}");
let conflict = create_job(store.clone(), &bucket, request(JobMode::Migrate, expected))
.await
.expect("conflicting job");
resume_job(store.clone(), &bucket, conflict.id)
.await
.expect("resume conflict");
let conflict = finish(store.clone(), &bucket, conflict.id).await;
assert_eq!(conflict.results[0].state, ItemState::Conflict, "{conflict:?}");
let target = store
.get_object_info(&bucket, "target", &ObjectOptions::default())
.await
.expect("unchanged target");
assert_eq!(target.data_dir, reader.object_info.data_dir);
},
)
.await;
}
#[tokio::test]
async fn mismatch_never_publishes_and_paused_job_survives_reload() {
let (_dirs, store, bucket) = fixture().await;
store
.put_object(
&bucket,
"source",
&mut PutObjReader::from_vec(b"actual".to_vec()),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
..Default::default()
},
)
.await
.expect("source");
let wrong = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"different")
.expect("digest")
.encoded;
let job = create_job(store.clone(), &bucket, request(JobMode::Audit, wrong))
.await
.expect("job");
assert_eq!(get_job(store.clone(), &bucket, job.id).await.expect("reload").state, JobState::Paused);
resume_job(store.clone(), &bucket, job.id).await.expect("resume");
let job = finish(store.clone(), &bucket, job.id).await;
assert_eq!(job.results[0].state, ItemState::Mismatch, "{job:?}");
assert!(
store
.get_object_info(&bucket, "target", &ObjectOptions::default())
.await
.is_err()
);
}
#[test]
fn fingerprint_is_independent_of_metadata_insertion_order() {
let mut a = crate::object_api::ObjectInfo::default();
let mut first = std::collections::HashMap::new();
first.insert("a".into(), "one".into());
first.insert("b".into(), "two".into());
a.user_defined = Arc::new(first);
let mut b = a.clone();
let mut second = std::collections::HashMap::new();
second.insert("b".into(), "two".into());
second.insert("a".into(), "one".into());
b.user_defined = Arc::new(second);
assert_eq!(model::fingerprint(&a), model::fingerprint(&b));
b.data_dir = Some(Uuid::new_v4());
assert_ne!(model::fingerprint(&a), model::fingerprint(&b));
}
#[test]
fn fingerprint_is_independent_of_part_checksum_insertion_order() {
let mut checksums = [
("SHA256", "sha256"),
("SHA1", "sha1"),
("CRC32", "crc32"),
("CRC32C", "crc32c"),
];
let a = crate::object_api::ObjectInfo {
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
checksums: Some(checksums.map(|(key, value)| (key.to_string(), value.to_string())).into()),
..Default::default()
}]),
..Default::default()
};
let mut b = a.clone();
let expected = model::fingerprint(&a);
for _ in 0..checksums.len() {
checksums.rotate_left(1);
Arc::make_mut(&mut b.parts)[0].checksums =
Some(checksums.map(|(key, value)| (key.to_string(), value.to_string())).into());
assert_eq!(expected, model::fingerprint(&b));
}
Arc::make_mut(&mut b.parts)[0]
.checksums
.as_mut()
.expect("part checksums")
.insert("SHA256".into(), "changed".into());
assert_ne!(expected, model::fingerprint(&b));
}
#[test]
fn rejects_migration_into_a_source_key() {
let request = JobRequest {
mode: JobMode::Migrate,
items: vec![ItemRequest {
key: "source".into(),
version_id: None,
expected_sha256: None,
target_key: Some("source".into()),
}],
bytes_per_second: 1024 * 1024,
max_object_bytes: 1024,
};
assert!(request.validate().is_err());
}
#[test]
fn rejects_truncated_checksum_suffix_as_migration_evidence() {
let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, b"payload").expect("checksum");
let mut info = crate::object_api::ObjectInfo {
checksum: Some(checksum.to_bytes(&[])),
..Default::default()
};
assert_eq!(model::stored_sha256(&info), Some(checksum.encoded));
let mut bytes = info.checksum.take().expect("bytes").to_vec();
bytes.push(0x80);
info.checksum = Some(bytes.into());
assert!(model::stored_sha256(&info).is_none());
}
#[tokio::test]
async fn unavailable_source_can_resume_and_cancel_intent_cannot_restart() {
let (_dirs, store, bucket) = fixture().await;
let payload = b"recovered".to_vec();
let digest = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, &payload)
.expect("digest")
.encoded;
let job = create_job(store.clone(), &bucket, request(JobMode::Audit, digest))
.await
.expect("create");
resume_job(store.clone(), &bucket, job.id).await.expect("start");
let failed = finish(store.clone(), &bucket, job.id).await;
assert_eq!(failed.state, JobState::Failed);
assert_eq!(failed.results[0].state, ItemState::Unavailable);
store
.put_object(
&bucket,
"source",
&mut PutObjReader::from_vec(payload),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("recovered source");
resume_job(store.clone(), &bucket, job.id).await.expect("retry");
let completed = finish(store.clone(), &bucket, job.id).await;
assert_eq!(completed.state, JobState::Complete, "{completed:?}");
assert_eq!(completed.results[0].state, ItemState::Verified);
// A process restart may leave the durable cancel intent without a live worker.
let (mut interrupted, etag) = load(&store, &bucket, job.id).await.expect("load");
interrupted.state = JobState::CancelRequested;
interrupted.revision += 1;
save(&store, &interrupted, Some(&etag), None).await.expect("persist intent");
assert_eq!(
control_job(store.clone(), &bucket, job.id, false).await.expect("pause").state,
JobState::CancelRequested
);
assert_eq!(
resume_job(store.clone(), &bucket, job.id)
.await
.expect("recover cancel")
.state,
JobState::Cancelled
);
}
#[tokio::test]
async fn pause_and_cancel_are_durable_during_content_read() {
let (_dirs, store, bucket) = fixture().await;
let payload = vec![7; 512 * 1024];
let digest = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::SHA256, &payload)
.expect("digest")
.encoded;
store
.put_object(
&bucket,
"source",
&mut PutObjReader::from_vec(payload),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("source");
let mut req = request(JobMode::Audit, digest);
req.bytes_per_second = 64 * 1024;
let job = create_job(store.clone(), &bucket, req).await.expect("create");
resume_job(store.clone(), &bucket, job.id).await.expect("start");
tokio::time::timeout(std::time::Duration::from_secs(20), async {
loop {
if get_job(store.clone(), &bucket, job.id).await.expect("status").results[0].state == ItemState::Prepared {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("prepared");
let intent = control_job(store.clone(), &bucket, job.id, false).await.expect("pause");
assert_eq!(intent.state, JobState::PauseRequested);
tokio::time::timeout(std::time::Duration::from_secs(20), async {
loop {
let paused = get_job(store.clone(), &bucket, job.id).await.expect("status");
if paused.state == JobState::Paused {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("pause acknowledged");
assert_eq!(
control_job(store.clone(), &bucket, job.id, true).await.expect("cancel").state,
JobState::Cancelled
);
assert_eq!(
resume_job(store.clone(), &bucket, job.id)
.await
.expect("terminal resume")
.state,
JobState::Cancelled
);
}
+1
View File
@@ -16,6 +16,7 @@
pub(crate) mod batch_processor;
pub(crate) mod event_notification;
pub(crate) mod integrity;
pub(crate) mod metrics_realtime;
pub(crate) mod notification_sys;
pub(crate) mod rebalance;
+139 -58
View File
@@ -42,6 +42,7 @@ use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::{Duration, Instant, SystemTime};
use tokio::sync::Notify;
use tokio::time::{sleep, timeout};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
@@ -61,6 +62,14 @@ const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
/// Fallback poll cadence while startup has not yet published the notification
/// system. IAM finalization starts the probe before `init_notification_runtime`
/// runs, so the first pass always fails closed; waiting a full probe interval
/// there left a single node without its durable quota capability for ten
/// seconds after `/health` reported ok (rustfs/rustfs#8014). Publication wakes
/// the probe immediately through `NOTIFICATION_SYS_PUBLISHED`; this bound only
/// covers a wakeup that races the availability check.
const REMOTE_VERSION_STATE_PROBE_BOOTSTRAP_INTERVAL: Duration = Duration::from_millis(100);
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
const TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION: u32 = 3;
const DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
@@ -338,6 +347,9 @@ static LEGACY_TRANSITION_STATE_RECONCILE_FLEET_PROOF: OnceLock<std::sync::RwLock
static ILM_RECOVERY_EXPORT_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static TRANSITION_TRANSACTION_COMPACTION_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
/// Signalled once `GLOBAL_NOTIFICATION_SYS` is published so the fleet probe can
/// run its first real pass without waiting for the bootstrap poll.
static NOTIFICATION_SYS_PUBLISHED: Notify = Notify::const_new();
static ILM_RECOVERY_EXPORT_LOCAL_PROCESS_EPOCH: LazyLock<Uuid> = LazyLock::new(Uuid::new_v4);
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
@@ -368,6 +380,24 @@ fn transition_transaction_compaction_fleet_proof_slot() -> &'static std::sync::R
TRANSITION_TRANSACTION_COMPACTION_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
}
fn all_fleet_capability_proof_slots() -> [&'static std::sync::RwLock<FleetCapabilityProofState>; 7] {
[
remote_version_state_fleet_proof_slot(),
cross_pool_fence_fleet_proof_slot(),
tier_delete_journal_fleet_proof_slot(),
decommission_target_fence_fleet_proof_slot(),
legacy_transition_state_reconcile_fleet_proof_slot(),
ilm_recovery_export_fleet_proof_slot(),
transition_transaction_compaction_fleet_proof_slot(),
]
}
fn revoke_all_fleet_capability_proofs() {
for slot in all_fleet_capability_proof_slots() {
revoke_fleet_capability_proof(slot);
}
}
fn revoke_fleet_capability_proof_state(state: &mut FleetCapabilityProofState) {
if let Some(proof) = state.proof.take() {
proof.generation.revoke();
@@ -453,6 +483,20 @@ pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option<RemoteVersion
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(RemoteVersionStateFleetProofToken)
}
pub(crate) fn acquire_remote_version_state_writer_fleet_proof() -> Option<RemoteVersionStateFleetProofToken> {
let requested = rustfs_utils::get_env_bool(
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE,
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE,
);
let fleet_confirmed = rustfs_utils::get_env_bool(
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
);
(requested && fleet_confirmed)
.then(acquire_remote_version_state_fleet_proof)
.flatten()
}
fn acquire_fleet_capability_proof_from(
state: &FleetCapabilityProofState,
expected_topology: &str,
@@ -1212,15 +1256,7 @@ fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, pe
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
for slot in [
remote_version_state_fleet_proof_slot(),
cross_pool_fence_fleet_proof_slot(),
tier_delete_journal_fleet_proof_slot(),
decommission_target_fence_fleet_proof_slot(),
legacy_transition_state_reconcile_fleet_proof_slot(),
ilm_recovery_export_fleet_proof_slot(),
transition_transaction_compaction_fleet_proof_slot(),
] {
for slot in all_fleet_capability_proof_slots() {
mark_fleet_capability_topology_conflict(slot);
}
}
@@ -1228,53 +1264,59 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
}
tokio::spawn(async move {
let mut notification_sys_unavailable_logged = false;
loop {
let notification_sys = get_global_notification_sys();
let remote_version_state_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_remote_version_state_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("remote version state fleet capability probe timed out"))),
None => Err(Error::other("remote version state fleet capability notification system is unavailable")),
let Some(notification_sys) = get_global_notification_sys() else {
// Startup publishes the notification system after this probe
// is started. Stay failed closed, but retry on the bootstrap
// cadence so a single node gains its capabilities as soon as
// the system appears instead of one full probe interval later.
revoke_all_fleet_capability_proofs();
if !notification_sys_unavailable_logged {
notification_sys_unavailable_logged = true;
debug!(
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
state = "waiting_for_notification_system",
"notification capability probe"
);
}
let _ = timeout(REMOTE_VERSION_STATE_PROBE_BOOTSTRAP_INTERVAL, NOTIFICATION_SYS_PUBLISHED.notified()).await;
continue;
};
notification_sys_unavailable_logged = false;
let remote_version_state_probe = async {
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_remote_version_state_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("remote version state fleet capability probe timed out")))
};
let cross_pool_fence_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
}
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out")))
};
let recovery_export_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_ilm_recovery_export_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("ILM recovery export fleet capability probe timed out"))),
None => Err(Error::other("ILM recovery export fleet capability notification system is unavailable")),
}
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_ilm_recovery_export_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("ILM recovery export fleet capability probe timed out")))
};
let transition_transaction_compaction_probe = async {
match notification_sys.as_ref() {
Some(notification_sys) => timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_transition_transaction_compaction_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("transition transaction compaction fleet capability probe timed out"))),
None => Err(Error::other(
"transition transaction compaction fleet capability notification system is unavailable",
)),
}
timeout(
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
notification_sys.probe_transition_transaction_compaction_fleet(&topology_fingerprint),
)
.await
.unwrap_or_else(|_| Err(Error::other("transition transaction compaction fleet capability probe timed out")))
};
let (result, fence_probe, recovery_export_result, transition_transaction_compaction_result) = tokio::join!(
remote_version_state_probe,
@@ -1299,13 +1341,7 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.topology_conflict;
if topology_conflict {
revoke_fleet_capability_proof(remote_version_state_fleet_proof_slot());
revoke_fleet_capability_proof(cross_pool_fence_fleet_proof_slot());
revoke_fleet_capability_proof(tier_delete_journal_fleet_proof_slot());
revoke_fleet_capability_proof(decommission_target_fence_fleet_proof_slot());
revoke_fleet_capability_proof(legacy_transition_state_reconcile_fleet_proof_slot());
revoke_fleet_capability_proof(ilm_recovery_export_fleet_proof_slot());
revoke_fleet_capability_proof(transition_transaction_compaction_fleet_proof_slot());
revoke_all_fleet_capability_proofs();
} else if let Some(err) = publish_fleet_capability_probe_result(
remote_version_state_fleet_proof_slot(),
&topology_fingerprint,
@@ -1428,9 +1464,11 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
}
pub async fn new_global_notification_sys(eps: EndpointServerPools) -> Result<()> {
let _ = GLOBAL_NOTIFICATION_SYS
.set(Arc::new(NotificationSys::new(eps).await))
.map_err(|_| Error::other("init notification_sys fail"));
if GLOBAL_NOTIFICATION_SYS.set(Arc::new(NotificationSys::new(eps).await)).is_ok() {
// `notify_one` stores a permit, so a probe that checks availability
// just before this publication still wakes without losing the signal.
NOTIFICATION_SYS_PUBLISHED.notify_one();
}
Ok(())
}
@@ -3905,6 +3943,49 @@ mod tests {
stats
}
/// rustfs/rustfs#8014: IAM finalization starts the fleet probe before the
/// notification system is published. The first probe therefore fails
/// closed, and a single node used to wait a full probe interval before the
/// durable quota capability appeared, even though `/health` was already
/// reporting ok. The probe must retry promptly while it waits for startup
/// to publish the notification system.
#[tokio::test]
async fn fleet_probe_publishes_promptly_once_the_notification_system_appears() {
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get().is_some() || GLOBAL_NOTIFICATION_SYS.get().is_some() {
// Process-wide state was bound by another test in this binary. The
// startup ordering under test needs a fresh process; nextest
// (the authoritative runner) always provides one.
eprintln!("skipping: fleet probe globals already bound in this process");
return;
}
// Share the fingerprint every other proof-installing test in this crate
// binds, so the plain `cargo test` fallback cannot see two topologies.
start_remote_version_state_fleet_probe("object-transaction-fencing-test".to_string());
sleep(Duration::from_millis(300)).await;
assert!(
acquire_cross_pool_fence_fleet_proof().is_none(),
"no capability proof may exist before the notification system is published"
);
let published_at = Instant::now();
new_global_notification_sys(EndpointServerPools::default())
.await
.expect("single-node notification system initializes");
let observed = timeout(Duration::from_secs(2), async {
while acquire_cross_pool_fence_fleet_proof().is_none() {
sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
observed.is_ok(),
"single-node cross-pool fence proof should publish within 2s of the notification system, waited {:?}",
published_at.elapsed()
);
}
#[test]
fn merging_peer_rings_sums_each_tier_without_double_counting() {
let mut cluster = DailyAllTierStats::from([("WARM".to_string(), ring(10))]);
@@ -9,11 +9,12 @@ use super::meta::{
};
use super::worker::{
rebalance_max_attempts, rebalance_meta_lock_error, resolve_load_rebalance_stats_update_result,
resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result, retry_rebalance_metadata_access,
resolve_rebalance_deferred_last_error, resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result,
retry_rebalance_metadata_access,
};
use super::{
DiskStat, EVENT_REBALANCE_BUCKET, EVENT_REBALANCE_STATE, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REBALANCE, REBAL_META_NAME,
RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats, RebalanceStopPropagationRecord,
RebalStatus, RebalanceDeferKind, RebalanceInfo, RebalanceMeta, RebalanceStats, RebalanceStopPropagationRecord,
encode_rebalance_stop_propagation_record,
};
use crate::core::pools::{
@@ -1103,6 +1104,7 @@ impl ECStore {
bucket: String,
last_error: String,
expected_id: &str,
kind: RebalanceDeferKind,
) -> Result<()> {
let mut rebalance_meta = self.rebalance_meta.write().await;
ensure_rebalance_worker_active(rebalance_meta.as_ref(), expected_id, "defer rebalance bucket")?;
@@ -1116,7 +1118,8 @@ impl ECStore {
};
defer_bucket_in_rebalance_queue(pool_stat, &bucket)?;
pool_stat.info.last_error = Some(last_error);
let pending_entry_defer = pool_stat.info.last_error.clone();
pool_stat.info.last_error = resolve_rebalance_deferred_last_error(kind, pending_entry_defer.as_deref(), &last_error);
meta.last_refreshed_at = Some(OffsetDateTime::now_utc());
Ok(())
}
+30 -1
View File
@@ -1209,7 +1209,7 @@ mod tests {
cancel: &warning_cancel,
};
let warning_result = store
.finish_rebalance_entry_after_cleanup(&warning_cleanup_context, async { Err(Error::SlowDown.into()) })
.finish_rebalance_entry_after_cleanup(&warning_cleanup_context, async { Err(Error::FileAccessDenied.into()) })
.await
.expect("cleanup warnings should not fail the completed migration");
assert!(matches!(warning_result, RebalanceEntryCleanupResult::Completed { warning: Some(_) }));
@@ -1250,6 +1250,35 @@ mod tests {
let pool_stats = &meta.as_ref().expect("rebalance metadata should exist").pool_stats[0];
assert_eq!(pool_stats.bytes, 0, "deferred cleanup must not commit completion stats");
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
drop(meta);
let transient_guard = store
.rebalance_run_guard(rebalance_id, "rebalance transient cleanup deferral test")
.await
.expect("rebalance transient cleanup deferral test guard should be acquired");
let transient_cancel = CancellationToken::new();
let transient_stats_updates = [&warning_version];
let transient_cleanup_context = RebalanceEntryCleanupContext {
run_guard: &transient_guard,
pool_index: 0,
bucket: "bucket",
object: "object.bin",
stats_updates: &transient_stats_updates,
expected_id: rebalance_id,
cancel: &transient_cancel,
};
let transient = store
.finish_rebalance_entry_after_cleanup(&transient_cleanup_context, async { Err(Error::SlowDown.into()) })
.await
.expect("retryable cleanup failures should defer without failing the worker");
assert!(matches!(transient, RebalanceEntryCleanupResult::Deferred { .. }));
let meta = store.rebalance_meta.read().await;
let pool_stats = &meta.as_ref().expect("rebalance metadata should exist").pool_stats[0];
assert_eq!(pool_stats.bytes, 0, "retryable cleanup failures must not commit completion stats");
assert_eq!(
pool_stats.cleanup_warnings.count, 1,
"retryable cleanup failures must not add a permanent warning"
);
}
#[tokio::test]
+2 -2
View File
@@ -29,7 +29,7 @@ const REBAL_META_FMT: u16 = 1; // Replace with actual format value
const REBAL_META_VER: u16 = 1; // Replace with actual version value
pub(crate) const REBAL_META_NAME: &str = "rebalance.bin";
const DEFAULT_REBALANCE_MAX_ATTEMPTS: usize = 3;
pub(crate) const REBALANCE_SOURCE_CLEANUP_MAX_DEFERS: usize = 3;
pub(crate) const REBALANCE_SOURCE_CLEANUP_MAX_DEFERS: usize = 8;
const REBALANCE_MAX_ATTEMPTS_ENV: &str = "RUSTFS_REBALANCE_MAX_ATTEMPTS";
const REBALANCE_STOP_PROPAGATION_ERROR_PREFIX: &str = "rebalance stop propagation incomplete: ";
const REBALANCE_LISTING_RETRY_BASE_DELAY: Duration = Duration::from_millis(250);
@@ -55,7 +55,7 @@ pub use types::{
DiskStat, RebalSaveOpt, RebalStatus, RebalanceCleanupWarningEntry, RebalanceCleanupWarnings, RebalanceInfo, RebalanceMeta,
RebalanceStats, RebalanceStopPropagationRecord,
};
use types::{RebalanceBucketConfigs, RebalanceBucketOutcome, RebalanceEntryOutcome};
use types::{RebalanceBucketConfigs, RebalanceBucketOutcome, RebalanceDeferKind, RebalanceEntryOutcome};
#[cfg(any(test, feature = "test-util"))]
pub async fn test_store_with_persisted_rebalance_meta(
@@ -34,27 +34,29 @@ use super::migration::{
};
use super::runtime::{
RebalanceLocalActivationOutcome, commit_local_rebalance_worker_activation,
commit_local_rebalance_worker_activation_candidate, should_fail_repeated_rebalance_bucket_defer,
source_cleanup_defer_attempt, stage_local_rebalance_worker_activation,
commit_local_rebalance_worker_activation_candidate, reached_rebalance_source_cleanup_defer_limit,
should_fail_repeated_rebalance_bucket_defer, source_cleanup_defer_attempt, stage_local_rebalance_worker_activation,
};
use super::worker::{
RebalanceEntryCleanupResult, ensure_rebalance_listing_disks_available, is_transient_rebalance_error,
parse_rebalance_max_attempts, rebalance_listing_retry_delay, rebalance_migration_retry_delay,
resolve_load_rebalance_stats_update_result, resolve_rebalance_bucket_error, resolve_rebalance_bucket_result,
resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result, resolve_rebalance_migrate_result_error,
resolve_rebalance_optional_bucket_config_result, resolve_rebalance_save_task_result, resolve_rebalance_stats_update_result,
resolve_rebalance_terminal_error, resolve_rebalance_worker_result, run_rebalance_listing_with_retry,
send_rebalance_done_signal, should_cleanup_rebalance_source_entry, should_count_rebalance_version_complete,
should_defer_rebalance_entry_failure, should_retry_rebalance_listing, should_skip_rebalance_delete_marker,
wait_rebalance_entry_tasks, wait_rebalance_listing_retry, with_rebalance_entry_context,
resolve_rebalance_deferred_last_error, resolve_rebalance_entry_cleanup_delete_result,
resolve_rebalance_file_info_versions_result, resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result,
resolve_rebalance_migrate_result_error, resolve_rebalance_optional_bucket_config_result, resolve_rebalance_save_task_result,
resolve_rebalance_stats_update_result, resolve_rebalance_terminal_error, resolve_rebalance_worker_result,
run_rebalance_listing_with_retry, send_rebalance_done_signal, should_cleanup_rebalance_source_entry,
should_count_rebalance_version_complete, should_defer_rebalance_entry_failure, should_retry_rebalance_listing,
should_skip_rebalance_delete_marker, wait_rebalance_entry_tasks, wait_rebalance_listing_retry, with_rebalance_entry_context,
};
use super::{
DiskStat, GetObjectReader, ObjectInfo, ObjectOptions, RebalSaveOpt, RebalStatus, RebalanceBucketConfigs,
RebalanceBucketOutcome, RebalanceCleanupWarnings, RebalanceEntryOutcome, RebalanceInfo, RebalanceMeta, RebalanceStats,
RebalanceStopPropagationRecord,
RebalanceBucketOutcome, RebalanceCleanupWarnings, RebalanceDeferKind, RebalanceEntryOutcome, RebalanceInfo, RebalanceMeta,
RebalanceStats, RebalanceStopPropagationRecord,
};
use super::{
REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX, REBALANCE_SOURCE_CLEANUP_MAX_DEFERS,
};
use super::{REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX};
use crate::bucket::replication::{ReplicationState, ReplicationStatusType, replication_state_to_filemeta};
use crate::data_movement;
use crate::data_movement::SourceCleanupError;
@@ -1775,7 +1777,7 @@ fn test_resolve_rebalance_entry_cleanup_delete_result_ignores_not_found() {
#[test]
fn test_resolve_rebalance_entry_cleanup_delete_result_returns_warning_for_failures() {
let result = resolve_rebalance_entry_cleanup_delete_result(Err(Error::SlowDown.into()), "bucket-a", "obj.txt");
let result = resolve_rebalance_entry_cleanup_delete_result(Err(Error::FileAccessDenied.into()), "bucket-a", "obj.txt");
assert!(matches!(
result,
RebalanceEntryCleanupResult::Completed { warning: Some(ref message) }
@@ -1783,6 +1785,122 @@ fn test_resolve_rebalance_entry_cleanup_delete_result_returns_warning_for_failur
));
}
#[test]
fn test_resolve_rebalance_entry_cleanup_delete_result_defers_transient_failures() {
let cases = [
(Error::SlowDown, "slow down"),
(
Error::Lock(rustfs_lock::LockError::timeout("bucket-a/obj.txt@latest", Duration::from_secs(5))),
"object lock timeout",
),
(
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
"object lock network failure",
),
(Error::ErasureWriteQuorum, "write quorum"),
(Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)), "io timeout"),
(
Error::other("Lock error: Lock acquisition timeout for resource 'bucket-a/obj.txt@latest' after 5s"),
"rendered lock timeout text",
),
];
for (err, label) in cases {
match resolve_rebalance_entry_cleanup_delete_result(Err(err.into()), "bucket-a", "obj.txt") {
RebalanceEntryCleanupResult::Deferred { last_error } => {
assert!(
last_error.starts_with(REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX),
"{label}: {last_error}"
);
assert!(last_error.contains("bucket-a/obj.txt"), "{label}: {last_error}");
}
RebalanceEntryCleanupResult::Completed { warning } => {
panic!("{label} must defer source cleanup instead of completing the entry with warning {warning:?}")
}
}
}
}
#[test]
fn test_resolve_rebalance_entry_cleanup_delete_result_defers_stage_wrapped_lock_timeout() {
let err = data_movement::data_movement_stage_error_for_test(
"rebalance",
"delete_object",
"bucket-a",
"obj.txt",
Error::Lock(rustfs_lock::LockError::timeout("bucket-a/obj.txt@latest", Duration::from_secs(5))),
);
assert!(matches!(
resolve_rebalance_entry_cleanup_delete_result(Err(err.into()), "bucket-a", "obj.txt"),
RebalanceEntryCleanupResult::Deferred { .. }
));
}
#[test]
fn test_resolve_rebalance_entry_cleanup_delete_result_ignores_stage_wrapped_not_found() {
let err = data_movement::data_movement_stage_error_for_test(
"rebalance",
"delete_object",
"bucket-a",
"obj.txt",
Error::ObjectNotFound("bucket-a".to_string(), "obj.txt".to_string()),
);
assert_eq!(
resolve_rebalance_entry_cleanup_delete_result(Err(err.into()), "bucket-a", "obj.txt"),
RebalanceEntryCleanupResult::Completed { warning: None }
);
}
#[test]
fn test_resolve_rebalance_deferred_last_error_hides_retryable_cleanup_conflicts() {
let entry_error = format!("{REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX} timeout");
assert_eq!(
resolve_rebalance_deferred_last_error(RebalanceDeferKind::Entry, None, entry_error.as_str()),
Some(entry_error.clone()),
"transient migration deferrals must stay visible to the completion guards"
);
let cleanup_error = format!("{REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX} lock acquisition timeout");
assert_eq!(
resolve_rebalance_deferred_last_error(RebalanceDeferKind::SourceCleanup, None, cleanup_error.as_str()),
None,
"a retryable source cleanup conflict is progress, not a pool failure"
);
assert_eq!(
resolve_rebalance_deferred_last_error(
RebalanceDeferKind::SourceCleanup,
Some(entry_error.as_str()),
cleanup_error.as_str()
),
Some(entry_error.clone()),
"a cleanup deferral for one bucket must not erase an unresolved migration deferral of the same pool"
);
assert_eq!(
resolve_rebalance_deferred_last_error(
RebalanceDeferKind::SourceCleanup,
Some(cleanup_error.as_str()),
cleanup_error.as_str()
),
None,
"a stale retryable cleanup message must not survive as a pool failure"
);
assert_eq!(
resolve_rebalance_deferred_last_error(
RebalanceDeferKind::SourceCleanup,
Some(entry_error.as_str()),
entry_error.as_str()
),
Some(entry_error),
"repeated cleanup deferrals must keep the pending migration deferral visible"
);
}
#[test]
fn test_resolve_rebalance_entry_cleanup_delete_result_defers_source_change() {
let result = resolve_rebalance_entry_cleanup_delete_result(Err(SourceCleanupError::SourceChanged), "bucket-a", "obj.txt");
@@ -1817,6 +1935,89 @@ fn test_source_cleanup_defer_does_not_fail_repeated_bucket_retry() {
assert_eq!(source_cleanup_defer_attempt(&mut source_attempts, "bucket-c"), 1);
assert_eq!(source_cleanup_defer_attempt(&mut source_attempts, "bucket-c"), 2);
assert_eq!(source_cleanup_defer_attempt(&mut source_attempts, "bucket-c"), 3);
let mut bounded_attempts = std::collections::HashMap::new();
for expected in 1..REBALANCE_SOURCE_CLEANUP_MAX_DEFERS {
assert_eq!(source_cleanup_defer_attempt(&mut bounded_attempts, "bucket-d"), expected);
assert!(
!reached_rebalance_source_cleanup_defer_limit(expected),
"a retryable cleanup conflict must stay retryable at deferral {expected}"
);
}
assert_eq!(
source_cleanup_defer_attempt(&mut bounded_attempts, "bucket-d"),
REBALANCE_SOURCE_CLEANUP_MAX_DEFERS
);
assert!(
reached_rebalance_source_cleanup_defer_limit(REBALANCE_SOURCE_CLEANUP_MAX_DEFERS),
"an unreclaimable source replica must fail the bucket after the bounded deferral budget"
);
}
#[tokio::test]
async fn test_defer_rebalance_bucket_keeps_pending_entry_defer_without_surfacing_cleanup_conflicts() {
let id = "rebalance-defer-last-error";
let meta = RebalanceMeta {
id: id.to_string(),
pool_stats: vec![RebalanceStats {
participating: true,
buckets: vec!["bucket-a".to_string(), "bucket-b".to_string()],
info: RebalanceInfo {
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
}],
..Default::default()
};
let store = test_store_with_rebalance_meta(meta);
let cleanup_error = format!("{REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX} lock acquisition timeout");
store
.defer_rebalance_bucket(0, "bucket-a".to_string(), cleanup_error.clone(), id, RebalanceDeferKind::SourceCleanup)
.await
.expect("a retryable cleanup conflict must be deferrable");
{
let meta = store.rebalance_meta.read().await;
let pool_stat = &meta.as_ref().expect("rebalance metadata should exist").pool_stats[0];
assert_eq!(
pool_stat.info.last_error, None,
"a retryable cleanup conflict is progress and must not surface as a pool failure"
);
assert_eq!(pool_stat.buckets, vec!["bucket-b".to_string(), "bucket-a".to_string()]);
}
let entry_error = format!("{REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX} slow down");
{
let mut meta = store.rebalance_meta.write().await;
meta.as_mut().expect("rebalance metadata should exist").pool_stats[0]
.info
.last_error = Some(entry_error.clone());
}
store
.defer_rebalance_bucket(0, "bucket-b".to_string(), cleanup_error, id, RebalanceDeferKind::SourceCleanup)
.await
.expect("a cleanup deferral must be accepted while another bucket still defers an entry");
{
let meta = store.rebalance_meta.read().await;
let pool_stat = &meta.as_ref().expect("rebalance metadata should exist").pool_stats[0];
assert_eq!(
pool_stat.info.last_error,
Some(entry_error),
"a cleanup deferral must not erase the pending migration deferral that blocks goal completion"
);
assert!(has_deferred_rebalance_error(pool_stat));
assert_eq!(pool_stat.buckets, vec!["bucket-a".to_string(), "bucket-b".to_string()]);
}
let migration_error = format!("{REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX} i/o timeout");
store
.defer_rebalance_bucket(0, "bucket-a".to_string(), migration_error.clone(), id, RebalanceDeferKind::Entry)
.await
.expect("migration deferrals must keep their last error");
let meta = store.rebalance_meta.read().await;
let pool_stat = &meta.as_ref().expect("rebalance metadata should exist").pool_stats[0];
assert_eq!(pool_stat.info.last_error, Some(migration_error));
}
#[test]
@@ -7,13 +7,14 @@ use super::meta::{
validate_start_rebalance_state,
};
use super::worker::{
resolve_rebalance_bucket_result, resolve_rebalance_meta_save_result, resolve_rebalance_save_task_result,
resolve_rebalance_terminal_error, send_rebalance_done_signal,
rebalance_max_attempts, resolve_rebalance_bucket_result, resolve_rebalance_meta_save_result,
resolve_rebalance_save_task_result, resolve_rebalance_terminal_error, retry_rebalance_metadata_access,
send_rebalance_done_signal,
};
use super::{
EVENT_REBALANCE_BUCKET, EVENT_REBALANCE_STATE, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REBALANCE,
REBALANCE_LISTING_RETRY_BASE_DELAY, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX, RebalSaveOpt, RebalStatus,
RebalanceBucketOutcome,
REBALANCE_LISTING_RETRY_BASE_DELAY, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX, REBALANCE_SOURCE_CLEANUP_MAX_DEFERS,
RebalSaveOpt, RebalStatus, RebalanceBucketOutcome, RebalanceDeferKind,
};
use crate::error::{Error, Result};
use crate::runtime::sources as runtime_sources;
@@ -40,6 +41,12 @@ pub(super) fn source_cleanup_defer_attempt(deferred_attempts: &mut HashMap<Strin
*attempts
}
/// Retryable source cleanup conflicts are bounded per run, so a source replica that stays
/// unreclaimable fails the bucket explicitly instead of deferring forever.
pub(super) fn reached_rebalance_source_cleanup_defer_limit(attempt: usize) -> bool {
attempt >= REBALANCE_SOURCE_CLEANUP_MAX_DEFERS
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum RebalanceLocalActivationOutcome {
Started,
@@ -443,13 +450,14 @@ impl ECStore {
};
if terminal_state_present {
if let Err(err) = store
.save_rebalance_stats_inner(
if let Err(err) = retry_rebalance_metadata_access(None, rebalance_max_attempts(), || {
store.save_rebalance_stats_inner(
pool_index,
RebalSaveOpt::Stats,
Some(save_rebalance_id.as_ref()),
)
.await
})
.await
{
let mut rebalance_meta = store.rebalance_meta.write().await;
*rebalance_meta = previous_meta;
@@ -469,9 +477,10 @@ impl ECStore {
}
if !terminal_state_saved
&& let Err(err) = store
.save_rebalance_stats_for_id(pool_index, RebalSaveOpt::Stats, save_rebalance_id.as_ref())
.await
&& let Err(err) = retry_rebalance_metadata_access(None, rebalance_max_attempts(), || {
store.save_rebalance_stats_for_id(pool_index, RebalSaveOpt::Stats, save_rebalance_id.as_ref())
})
.await
{
let wrapped = Error::other(format!("rebalance save_task stats save failed for pool {pool_index}: {err}"));
error!("{} err: {:?}", msg, wrapped);
@@ -623,6 +632,11 @@ impl ECStore {
} else {
0
};
let defer_kind = if source_cleanup_deferred {
RebalanceDeferKind::SourceCleanup
} else {
RebalanceDeferKind::Entry
};
warn!(
event = EVENT_REBALANCE_BUCKET,
component = LOG_COMPONENT_ECSTORE,
@@ -634,7 +648,7 @@ impl ECStore {
"Deferred rebalance bucket after transient object failures"
);
if let Err(err) = self
.defer_rebalance_bucket(pool_index, bucket.clone(), last_error.clone(), rebalance_id.as_ref())
.defer_rebalance_bucket(pool_index, bucket.clone(), last_error.clone(), rebalance_id.as_ref(), defer_kind)
.await
{
error!(
@@ -654,10 +668,10 @@ impl ECStore {
break;
}
if source_cleanup_deferred {
if source_cleanup_attempt >= super::REBALANCE_SOURCE_CLEANUP_MAX_DEFERS {
if reached_rebalance_source_cleanup_defer_limit(source_cleanup_attempt) {
let err = Error::other(format!(
"rebalance bucket {bucket} source cleanup remained unstable after {} deferrals: {last_error}",
super::REBALANCE_SOURCE_CLEANUP_MAX_DEFERS
REBALANCE_SOURCE_CLEANUP_MAX_DEFERS
));
warn!(
event = EVENT_REBALANCE_BUCKET,
@@ -51,6 +51,18 @@ pub(super) enum RebalanceEntryOutcome {
Deferred { last_error: String },
}
/// Why a rebalance bucket was put back at the end of the queue.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RebalanceDeferKind {
/// A transient object-migration failure. Persisting it as `lastError` keeps the pool
/// from being completed at the free-space goal while the entry is still retried.
Entry,
/// A retryable source-cleanup conflict. The bucket stays queued and is retried, so a
/// transient lock conflict must not be recorded as a permanent warning that would block
/// pool completion for the rest of the run.
SourceCleanup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum RebalStatus {
#[default]
+110 -7
View File
@@ -1,9 +1,9 @@
use super::migration::MigrationVersionResult;
use super::{
DEFAULT_REBALANCE_MAX_ATTEMPTS, EVENT_REBALANCE_LISTING, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REBALANCE, REBAL_META_NAME,
REBALANCE_LISTING_RETRY_BASE_DELAY, REBALANCE_MAX_ATTEMPTS_ENV, REBALANCE_MIGRATION_LOCK_RETRY_CAP,
REBALANCE_MIGRATION_RETRY_BASE_DELAY, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX, RebalanceBucketConfigs,
RebalanceBucketOutcome, RebalanceEntryOutcome, Result,
REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX, REBALANCE_LISTING_RETRY_BASE_DELAY, REBALANCE_MAX_ATTEMPTS_ENV,
REBALANCE_MIGRATION_LOCK_RETRY_CAP, REBALANCE_MIGRATION_RETRY_BASE_DELAY, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX,
RebalanceBucketConfigs, RebalanceBucketOutcome, RebalanceDeferKind, RebalanceEntryOutcome, Result,
};
use crate::cache_value::metacache_set::{ListPathRawOptions, list_path_raw};
use crate::core::pools::ListCallback;
@@ -109,7 +109,13 @@ pub(super) fn resolve_rebalance_save_task_result(
}
pub(super) fn resolve_rebalance_meta_save_result(result: Result<()>, stage: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("rebalance meta save failed during {stage}: {err}")))
// Keep the source error reachable: the metadata retry policy classifies
// transient lock timeouts by inspecting the source chain, so collapsing the
// failure into a plain string here would make that retry a no-op.
result.map_err(|err| {
let rendered = format!("rebalance meta save failed during {stage}: {err}");
crate::data_movement::data_movement_context_error(rendered, err)
})
}
pub(super) fn rebalance_meta_lock_error(err: rustfs_lock::LockError, mode: &'static str) -> Error {
@@ -177,7 +183,7 @@ pub(super) fn resolve_rebalance_entry_cleanup_delete_result(
) -> RebalanceEntryCleanupResult {
match result {
Ok(_) => RebalanceEntryCleanupResult::Completed { warning: None },
Err(SourceCleanupError::Storage(err)) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
Err(SourceCleanupError::Storage(err)) if is_source_cleanup_not_found(&err) => {
RebalanceEntryCleanupResult::Completed { warning: None }
}
Err(SourceCleanupError::SourceChanged) => RebalanceEntryCleanupResult::Deferred {
@@ -185,12 +191,25 @@ pub(super) fn resolve_rebalance_entry_cleanup_delete_result(
"{REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX} source changed during cleanup preflight for {bucket}/{object_name}"
),
},
// A transient cleanup failure is not evidence that the source replica is gone, so the
// entry stays incomplete and the bucket is retried instead of recording a permanent
// cleanup warning that would block pool completion.
Err(SourceCleanupError::Storage(err)) if is_transient_rebalance_error(&err) => RebalanceEntryCleanupResult::Deferred {
last_error: format!(
"{REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX} transient source cleanup failure for {bucket}/{object_name} will be retried: {err}"
),
},
Err(SourceCleanupError::Storage(err)) => RebalanceEntryCleanupResult::Completed {
warning: Some(format!("rebalance cleanup delete failed for {bucket}/{object_name}: {err}")),
},
}
}
fn is_source_cleanup_not_found(err: &Error) -> bool {
let err = rebalance_error_source(err);
is_err_object_not_found(err) || is_err_version_not_found(err)
}
pub(super) fn resolve_rebalance_migrate_result_error(
err: Option<Error>,
pool_idx: usize,
@@ -210,6 +229,23 @@ pub(super) fn should_defer_rebalance_entry_failure(err: &Error) -> bool {
is_transient_rebalance_error(err)
}
pub(super) fn resolve_rebalance_deferred_last_error(
kind: RebalanceDeferKind,
pending_entry_defer: Option<&str>,
last_error: &str,
) -> Option<String> {
match kind {
RebalanceDeferKind::Entry => Some(last_error.to_string()),
// A retryable cleanup conflict is progress, not a pool failure, so it must not surface as
// `lastError`. It also must not erase an unresolved migration deferral: that marker is the
// only signal keeping the pool from completing at the free-space goal while an entry is
// still retried, and the two deferrals can be reported by different buckets of one pool.
RebalanceDeferKind::SourceCleanup => pending_entry_defer
.filter(|pending| pending.starts_with(REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX))
.map(str::to_string),
}
}
pub(super) fn resolve_load_rebalance_stats_update_result(result: Result<()>) -> Result<()> {
result.map_err(|err| Error::other(format!("rebalance metadata stats refresh failed after load: {err}")))
}
@@ -299,7 +335,10 @@ fn is_rebalance_transient_io_error(err: &std::io::Error) -> bool {
fn is_rebalance_transient_message(message: &str) -> bool {
let message = message.to_ascii_lowercase();
message.contains("lock acquisition timed out")
// `LockError::Timeout` renders "Lock acquisition timeout for resource ...", while the
// namespace-lock layer renders "lock acquisition timed out on ..."; both are retryable.
message.contains("lock acquisition timeout")
|| message.contains("lock acquisition timed out")
|| message.contains("remote lock rpc timed out")
|| message.contains("keepalivetimedout")
|| message.contains("i/o timeout")
@@ -380,7 +419,8 @@ fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
fn is_rebalance_lock_or_rpc_timeout_message(message: &str) -> bool {
let message = message.to_ascii_lowercase();
message.contains("lock acquisition timed out")
message.contains("lock acquisition timeout")
|| message.contains("lock acquisition timed out")
|| message.contains("remote lock rpc timed out")
|| message.contains("keepalivetimedout")
}
@@ -705,6 +745,29 @@ mod error_source_tests {
}
}
#[tokio::test]
async fn rebalance_metadata_retry_engages_for_wrapped_meta_save_lock_timeout() {
let mut attempts = 0;
let result = retry_rebalance_metadata_access(None, 3, || {
attempts += 1;
std::future::ready(resolve_rebalance_meta_save_result(
Err(rebalance_meta_lock_error(
rustfs_lock::LockError::timeout(".rustfs.sys/rebalance.bin@latest", Duration::from_secs(5)),
"write",
)),
"save_rebalance_stats for pool 0 opt Stats",
))
})
.await;
assert_eq!(attempts, 3, "a wrapped meta save lock timeout must stay retryable");
let err = result.expect_err("persistent lock contention must not become success");
assert!(matches!(
rebalance_error_source(&err),
Error::Lock(rustfs_lock::LockError::Timeout { .. })
));
}
#[tokio::test]
async fn rebalance_metadata_retry_cancels_a_pending_attempt() {
struct DropProbe(Arc<std::sync::atomic::AtomicUsize>);
@@ -807,4 +870,44 @@ mod error_source_tests {
}
}
}
#[test]
fn rendered_lock_timeout_text_selects_the_lock_backoff() {
// The lock backend renders a timeout as "Lock acquisition timeout for resource ...",
// so the message matcher must recognize that text when the error arrives re-rendered
// instead of as a typed `Error::Lock`.
let rendered = rustfs_lock::LockError::timeout("bucket/object@latest", Duration::from_secs(5)).to_string();
assert!(
rendered.contains("Lock acquisition timeout for resource"),
"unexpected lock timeout text: {rendered}"
);
// The lock policy jitters the delay inside its own cap, so a far-out attempt identifies
// the selected policy: the linear fallback would return `base * (attempt + 1)`.
let far_attempt = 100;
assert!(REBALANCE_MIGRATION_RETRY_BASE_DELAY * 101 > REBALANCE_MIGRATION_LOCK_RETRY_CAP);
let mut error = Error::other(format!("Lock error: {rendered}"));
for depth in 0..=3 {
assert!(
is_transient_rebalance_error(&error),
"rendered lock timeout lost retryability at depth {depth}: {error:?}"
);
assert!(
is_rebalance_lock_or_rpc_timeout(&error),
"rendered lock timeout lost the lock backoff at depth {depth}: {error:?}"
);
let delay = rebalance_migration_retry_delay(far_attempt, &error);
assert!(
delay <= REBALANCE_MIGRATION_LOCK_RETRY_CAP && delay >= Duration::from_millis(1),
"rendered lock timeout must stay inside the lock backoff cap at depth {depth}: {delay:?}"
);
error = crate::data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
error,
);
}
}
}
@@ -37,7 +37,7 @@ use rustfs_s3_client::{
api_s3_datatypes::ListVersionsResult,
credentials::{Credentials, SignatureType, Static, Value},
provider_versions::validate_remote_version_id,
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
transition_api::{BucketLookupType, ObjectInfo, Options, TransitionClient, TransitionCore},
transition_api::{ReadCloser, ReaderImpl},
};
use rustfs_utils::egress::validate_outbound_url;
@@ -278,19 +278,7 @@ impl WarmBackendS3 {
let mut stat_opts = GetObjectOptions::default();
stat_opts.version_id.clone_from(&version.version_id);
let info = self.client.stat_object(&self.bucket, &remote_object, &stat_opts).await?;
let mut metadata = info.user_metadata;
for (name, value) in &info.metadata {
if (name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX)
|| name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX))
&& let Ok(value) = value.to_str()
{
metadata.insert(name.as_str().to_string(), value.to_string());
}
}
let metadata = transition_candidate_metadata(info);
if transition_candidate_metadata_matches(&metadata, identity)? {
if matched_version.is_some() {
return Ok(TransitionCandidateProbe::Ambiguous);
@@ -313,6 +301,53 @@ impl WarmBackendS3 {
advance_version_markers(&mut key_marker, &mut version_id_marker, &versions)?;
}
}
async fn probe_unversioned_transition_candidate_identity(
&self,
object: &str,
identity: TransitionCandidateIdentity,
) -> Result<TransitionCandidateProbe, std::io::Error> {
let remote_object = self.get_dest(object);
match self
.client
.stat_object(&self.bucket, &remote_object, &GetObjectOptions::default())
.await
{
Ok(info) => {
let metadata = transition_candidate_metadata(info);
if transition_candidate_metadata_matches(&metadata, identity)? {
Ok(TransitionCandidateProbe::UnversionedPresent)
} else {
Ok(TransitionCandidateProbe::Unsupported)
}
}
Err(err) => {
let response = to_error_response(&err);
if response.code == S3ErrorCode::NoSuchKey {
Ok(TransitionCandidateProbe::Missing)
} else {
Err(err)
}
}
}
}
}
fn transition_candidate_metadata(info: ObjectInfo) -> HashMap<String, String> {
let mut metadata = info.user_metadata;
for (name, value) in &info.metadata {
if (name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX)
|| name
.as_str()
.starts_with(rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX))
&& let Ok(value) = value.to_str()
{
metadata.insert(name.as_str().to_string(), value.to_string());
}
}
metadata
}
fn transition_candidate_metadata_matches(
@@ -525,6 +560,13 @@ mod tests {
}
async fn scripted_probe_fixture(responses: Vec<String>) -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
scripted_probe_fixture_for_tier(responses, "s3").await
}
async fn scripted_probe_fixture_for_tier(
responses: Vec<String>,
tier_type: &str,
) -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
@@ -571,7 +613,7 @@ mod tests {
max_retries: 1,
..Default::default()
},
"s3",
tier_type,
)
.await
.expect("fixture client should build"),
@@ -675,6 +717,37 @@ mod tests {
assert!(requests[8].to_ascii_lowercase().contains("?versionid=historical-version"));
}
#[tokio::test]
async fn unversioned_candidate_reconciler_uses_head_without_version_apis() {
let identity = candidate_identity();
let transaction_id = identity.transaction_id;
let destination_id = rustfs_utils::crypto::hex(identity.destination_id);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nx-amz-meta-x-rustfs-internal-transition-transaction-id: {transaction_id}\r\nx-amz-meta-x-rustfs-internal-transition-tier-destination-id: {destination_id}\r\nConnection: close\r\n\r\n"
);
let Some((backend, fixture)) = scripted_probe_fixture_for_tier(vec![response], "r2").await else {
return;
};
assert_eq!(
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_transition_candidate_for(
&backend,
"archive/object",
identity,
)
.await
.expect("R2 candidate probe should use the current unversioned object"),
TransitionCandidateProbe::UnversionedPresent
);
let requests = fixture.await.expect("R2 candidate fixture should finish");
assert_eq!(requests.len(), 1);
let request = requests[0].to_ascii_lowercase();
assert!(request.starts_with("head /bucket/archive/object"));
assert!(!request.contains("versioning"));
assert!(!request.contains("versions"));
assert!(!request.contains("versionid="));
}
fn legacy_probe_xml_response(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
@@ -1089,6 +1162,10 @@ impl TransitionCandidateReconciler for WarmBackendS3 {
object: &str,
identity: TransitionCandidateIdentity,
) -> Result<TransitionCandidateProbe, std::io::Error> {
let capabilities = self.client.provider_version_capabilities();
if !capabilities.bucket_versioning_state && !capabilities.list_object_versions {
return self.probe_unversioned_transition_candidate_identity(object, identity).await;
}
let bucket_versioning = self.remote_bucket_versioning().await?;
self.probe_transition_candidate_identity(object, identity, bucket_versioning)
.await
@@ -1163,13 +1163,10 @@ pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> {
pub(in crate::set_disk) set_index: usize,
pub(in crate::set_disk) part_number: Option<usize>,
pub(in crate::set_disk) reason: &'static str,
/// Durable MRF journal intent to file alongside the read-repair request
/// (backlog#1894 axis A): the intent kind plus its native `Uuid`
/// version id (the submission's string form stays display-only). Bound
/// to the reservation — the intent is only delivered when this sighting
/// wins the dedup TTL, so a burst of reads failing on the same object
/// books exactly one journal record instead of one per retry. `None`
/// keeps the historical no-intent behavior.
/// Best-effort ingress for the durable MRF consumer. Its identity coalescer
/// is independent of the heal reservation: an earlier metadata repair must
/// not suppress newly observed payload damage, and rejected ingress must
/// remain retryable. Channel admission does not prove checkpoint commit.
pub(in crate::set_disk) mrf_intent: Option<(rustfs_common::mrf_channel::MrfKind, Option<uuid::Uuid>)>,
}
@@ -1224,6 +1221,27 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
mrf_intent,
} = submission;
if let Some((kind, version_uuid)) = mrf_intent
&& let (Ok(pool_index), Ok(set_index)) = (u32::try_from(pool_index), u32::try_from(set_index))
{
let scope = rustfs_common::mrf_channel::MrfScope { pool_index, set_index };
let ingress = rustfs_common::mrf_channel::try_send_mrf_intent_typed(kind, bucket, object, version_uuid, Some(scope));
debug!(
event = EVENT_SET_DISK_READ,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
state = "mrf_ingress",
mrf_kind = ?kind,
bucket,
object,
version_id,
pool_index,
set_index,
result = ?ingress,
"Read-repair MRF ingress result"
);
}
let Some(dedup_key) = reserve_read_repair_heal(bucket, object, version_id, pool_index, set_index).await else {
record_read_repair_dedup("duplicate");
debug!(
@@ -1233,15 +1251,6 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
return;
};
// Reservation won: this sighting owns the repair records for the object,
// including the durable journal intent when the caller asked for one.
if let Some((kind, version_uuid)) = mrf_intent
&& let (Ok(pool_index), Ok(set_index)) = (u32::try_from(pool_index), u32::try_from(set_index))
{
let scope = rustfs_common::mrf_channel::MrfScope { pool_index, set_index };
let _ = rustfs_common::mrf_channel::try_send_mrf_intent_typed(kind, bucket, object, version_uuid, Some(scope));
}
let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
@@ -12961,7 +12970,7 @@ mod tests {
#[tokio::test]
#[serial_test::serial]
async fn mrf_intent_is_filed_once_per_read_repair_reservation() {
async fn mrf_ingress_is_coalesced_independently_of_read_repair_reservations() {
// Serial: owns the process-global MRF channel for this test binary
// (same key as the other channel-owning tests above).
let bucket = format!("mrf-intent-bucket-{}", Uuid::new_v4());
@@ -12982,8 +12991,13 @@ mod tests {
}
}
// First sighting wins the reservation: the journal intent is filed
// synchronously before the admission task is spawned.
// An earlier metadata/missing-shard admission has no payload MRF intent.
let mut metadata_submission = intent_submission(&bucket, &object);
metadata_submission.mrf_intent = None;
submit_read_repair_heal_with_submitter(metadata_submission, accepted_read_repair_submitter).await;
assert!(receiver.try_recv().is_err());
// Newly observed corruption must reach MRF despite that reservation.
submit_read_repair_heal_with_submitter(intent_submission(&bucket, &object), accepted_read_repair_submitter).await;
let first = receiver.try_recv().expect("first sighting must file exactly one MRF intent");
assert_eq!(*first.bucket, bucket);
@@ -12993,6 +13007,20 @@ mod tests {
// second journal record.
submit_read_repair_heal_with_submitter(intent_submission(&bucket, &object), accepted_read_repair_submitter).await;
assert!(receiver.try_recv().is_err(), "duplicate sighting must not file another MRF intent");
let retry_object = format!("retry-object-{}", Uuid::new_v4());
rustfs_common::mrf_channel::set_mrf_delivery_enabled(false);
submit_read_repair_heal_with_submitter(intent_submission(&bucket, &retry_object), accepted_read_repair_submitter).await;
assert!(receiver.try_recv().is_err());
rustfs_common::mrf_channel::set_mrf_delivery_enabled(true);
submit_read_repair_heal_with_submitter(intent_submission(&bucket, &retry_object), accepted_read_repair_submitter).await;
let retried = receiver
.try_recv()
.expect("a heal reservation cannot suppress a previously rejected MRF intent");
assert_eq!(*retried.object, retry_object);
rustfs_common::mrf_channel::release_mrf_intent(&first);
rustfs_common::mrf_channel::release_mrf_intent(&retried);
rustfs_common::mrf_channel::set_mrf_delivery_enabled(false);
}
#[tokio::test]
+143
View File
@@ -874,6 +874,7 @@ mod ctx;
mod metadata;
mod ops;
pub(crate) use ops::bucket::BucketInfoQuorum;
pub(crate) use ops::heal::AbsenceProofRequest;
#[cfg(test)]
pub(crate) use ops::heal::DanglingDeleteFailure;
pub(crate) use ops::heal::HealedObjectAbsence;
@@ -3876,6 +3877,9 @@ pub struct SetDisks {
pub format: FormatV3,
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
disk_health_cache: Arc<RwLock<Vec<Option<DiskHealthEntry>>>>,
/// Coalesce bounded GET probes of missing remote slots. Shared by set clones;
/// the timestamp also limits repeated probes while a peer remains absent.
read_reconnect: Arc<tokio::sync::Mutex<Option<tokio::time::Instant>>>,
get_object_metadata_cache: moka::future::Cache<GetObjectMetadataCacheKey, Arc<GetObjectMetadataCacheEntry>>,
get_object_metadata_cache_hash_builder: std::collections::hash_map::RandomState,
get_object_metadata_cache_generations: Arc<[AtomicU64]>,
@@ -3914,6 +3918,72 @@ pub struct SetDisks {
>,
}
fn marker_purge_receipt_path(
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) -> String {
let object_hash = rustfs_utils::crypto::hex(Sha256::digest(object.as_bytes()));
let marker_hash = rustfs_utils::crypto::hex(purge.marker_identity);
format!(
"marker-purge-receipts/{}/{}/{}-{}-{}.json",
bucket, purge.bucket_incarnation_id, version, object_hash, marker_hash
)
}
impl SetDisks {
/// Persist a quorum DELETE receipt outside the object marker itself. The
/// receipt is written to the metadata bucket so it survives a node restart
/// while an inaccessible member still holds the stale marker.
pub(crate) async fn persist_marker_purge_receipt(
&self,
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) -> bool {
let path = marker_purge_receipt_path(bucket, object, version, purge);
let api = Arc::new(self.clone());
crate::config::com::save_config_with_opts(
api,
&path,
b"rustfs-marker-purge-receipt-v1".to_vec(),
&ObjectOptions {
max_parity: true,
no_lock: true,
..Default::default()
},
)
.await
.is_ok()
}
pub(crate) async fn has_marker_purge_receipt(
&self,
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) -> bool {
let path = marker_purge_receipt_path(bucket, object, version, purge);
crate::config::com::read_config_limited_preserve_empty(Arc::new(self.clone()), &path, 128)
.await
.is_ok()
}
pub(crate) async fn consume_marker_purge_receipt(
&self,
bucket: &str,
object: &str,
version: Uuid,
purge: &rustfs_common::mrf_channel::MrfDeleteMarkerPurge,
) {
let path = marker_purge_receipt_path(bucket, object, version, purge);
let _ = crate::config::com::delete_config_no_lock(Arc::new(self.clone()), &path).await;
}
}
/// Read every physical copy before selecting a version quorum. A minority
/// legacy record is still evidence and must not disappear behind a majority
/// not-found result. Only an explicit file/volume absence produces `None`;
@@ -4676,6 +4746,7 @@ impl SetDisks {
format,
set_endpoints,
disk_health_cache: Arc::new(RwLock::new(Vec::new())),
read_reconnect: Arc::new(tokio::sync::Mutex::new(None)),
get_object_metadata_cache: moka::future::Cache::builder()
.max_capacity(get_object_metadata_cache_max_entries() as u64)
.time_to_live(GET_OBJECT_METADATA_CACHE_TTL)
@@ -6406,6 +6477,28 @@ async fn verify_inline_part_bitrot(meta: &FileInfo) -> disk::error::Result<()> {
.map_err(|_| DiskError::FileCorrupt)
}
fn validate_deep_scan_results(results: &[usize], expected_parts: usize) -> disk::error::Result<()> {
if results.len() != expected_parts {
return Err(DiskError::other(format!(
"incomplete deep scan result: expected {expected_parts} parts, received {}",
results.len()
)));
}
for (part, status) in results.iter().enumerate() {
match *status {
CHECK_PART_SUCCESS | CHECK_PART_FILE_NOT_FOUND | CHECK_PART_FILE_CORRUPT => {}
CHECK_PART_DISK_NOT_FOUND => return Err(DiskError::DiskNotFound),
crate::disk::CHECK_PART_VOLUME_NOT_FOUND => return Err(DiskError::VolumeNotFound),
_ => {
return Err(DiskError::other(format!(
"incomplete deep scan result: part {part} has unverified status {status}"
)));
}
}
}
Ok(())
}
/// disks_with_all_partsv2 is a corrected version based on Go implementation.
/// It sets partsMetadata and onlineDisks when xl.meta is inexistant/corrupted or outdated.
/// It also checks if the status of each part (corrupted, missing, ok) in each drive.
@@ -6626,9 +6719,13 @@ async fn disks_with_all_parts(
// it needs healing too.
match disk.verify_file(bucket, object, meta).await {
Ok(v) => {
validate_deep_scan_results(&v.results, latest_meta.parts.len())?;
verify_resp = v;
}
Err(err) => {
if !matches!(err, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::FileCorrupt) {
return Err(err);
}
debug!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
@@ -11042,6 +11139,50 @@ mod tests {
assert_eq!(conv_part_err_to_int(&Some(other_err)), CHECK_PART_UNKNOWN); // Other errors should return UNKNOWN, not SUCCESS
}
#[test]
fn deep_scan_results_accept_only_complete_verified_or_repairable_parts() {
for (results, expected_parts) in [
(vec![], 0),
(vec![CHECK_PART_SUCCESS], 1),
(vec![CHECK_PART_FILE_NOT_FOUND], 1),
(vec![CHECK_PART_FILE_CORRUPT], 1),
(vec![CHECK_PART_SUCCESS, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_FILE_CORRUPT], 3),
] {
validate_deep_scan_results(&results, expected_parts)
.expect("complete results must allow healthy or repairable parts");
}
}
#[test]
fn deep_scan_results_reject_unknown_and_incomplete_observations() {
for (results, expected_parts) in [
(vec![CHECK_PART_UNKNOWN], 1),
(vec![usize::MAX], 1),
(vec![CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN], 2),
(vec![], 1),
(vec![CHECK_PART_SUCCESS], 2),
(vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS], 1),
(vec![CHECK_PART_SUCCESS], 0),
] {
let error =
validate_deep_scan_results(&results, expected_parts).expect_err("unverified parts must not become healthy");
assert!(matches!(error, DiskError::Io(_)), "an incomplete observation is not proven corruption");
assert!(error.to_string().contains("incomplete deep scan result"));
}
}
#[test]
fn deep_scan_results_preserve_disk_and_volume_failures() {
for (status, expected_error) in [
(CHECK_PART_DISK_NOT_FOUND, DiskError::DiskNotFound),
(CHECK_PART_VOLUME_NOT_FOUND, DiskError::VolumeNotFound),
] {
let error = validate_deep_scan_results(&[CHECK_PART_SUCCESS, status], 2)
.expect_err("an unavailable part cannot certify a healthy disk");
assert_eq!(error, expected_error);
}
}
#[test]
fn test_has_part_err() {
// Test checking for part errors
@@ -13561,6 +13702,7 @@ mod tests {
true,
false,
false,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
metrics_size_bucket,
@@ -13674,6 +13816,7 @@ mod tests {
true,
false,
false,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
metrics_size_bucket,
@@ -141,6 +141,7 @@ pub(in crate::set_disk::ops) async fn verify_written_bitrot_shards(
mod tests {
use super::super::object::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity;
use super::*;
use crate::storage_api_contracts::object::ObjectIO as _;
async fn encode_streaming_shard(data: &[u8], shard_size: usize) -> Bytes {
let mut writer = coding::BitrotWriter::new(Cursor::new(Vec::new()), shard_size, HashAlgorithm::HighwayHash256S);
@@ -295,4 +296,19 @@ mod tests {
assert!(err.to_string().contains("trailing data"));
}
}
#[tokio::test]
async fn no_parity_put_round_trip_large_stream() {
let (_temp_dirs, disks, set_disks) = hermetic_set_disks_for_pool_with_default_parity(1, 0, 0).await;
let bucket = "no-parity-put-round-trip";
for disk in &disks {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let payload = vec![0x5a; 13 * 1024 * 1024 + 727_213];
let mut reader = crate::set_disk::PutObjReader::from_vec(payload);
set_disks
.put_object(bucket, "object", &mut reader, &crate::set_disk::ObjectOptions::default())
.await
.expect("single-disk no-parity PUT should pass self-verify");
}
}
+837 -25
View File
@@ -38,6 +38,7 @@ use crate::disk::DiskAPI;
use crate::disk::local::{DELETE_DATA_DIR_MARKER_PREFIX, metadata_less_part_file};
use crate::io_support::bitrot::object_mmap_read_enabled;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use rustfs_common::mrf_channel::MrfDeleteMarkerPurge;
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit};
use tracing::trace;
@@ -55,6 +56,13 @@ pub(crate) struct HealedObjectAbsence {
pub removed: bool,
}
/// Conditions under which a missing object may become a storage-owned proof.
#[derive(Clone, Copy)]
pub(crate) struct AbsenceProofRequest<'a> {
pub retirement: Option<&'a MarkerRetirementContext<'a>>,
pub allow_unversioned: bool,
}
fn is_unavailable_heal_rpc(error: &DiskError) -> bool {
let DiskError::Io(error) = error else {
return false;
@@ -846,7 +854,7 @@ impl SetDisks {
);
let disks = self.get_disks_internal().await;
let bucket_heal_scope = crate::store::bucket_heal_scope(bucket);
let bucket_heal_scope = crate::store::bucket_heal_scope_for_object(bucket, object);
if let Some(scope) = &bucket_heal_scope {
scope.check()?;
}
@@ -971,12 +979,25 @@ impl SetDisks {
result.parity_blocks = result.disk_count - read_quorum as usize;
result.data_blocks = read_quorum as usize;
let ((mut online_disks, quorum_mod_time, quorum_etag), disk_len) = {
let ((quorum_disks, quorum_mod_time, quorum_etag), disk_len) = {
let disks = self.disks.read().await;
let disk_len = disks.len();
(Self::list_online_disks(&disks, &parts_metadata, &errs, read_quorum as usize), disk_len)
};
// A deep heal must inspect every reachable member, not only the
// quorum that agreed on the canonical metadata. A returning disk
// can legitimately carry an older xl.meta (or an explicit-version
// absence) while still being the exact stale target that needs
// repair. Restricting the repair set to quorum members silently
// turns that stale member into `verified_healthy`/`unchanged`
// (backlog#2612, #2596). Normal scans retain the quorum fast path.
let mut online_disks = if opts.scan_mode == HealScanMode::Deep {
self.get_disks_internal().await
} else {
quorum_disks
};
trace!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
@@ -1449,7 +1470,12 @@ impl SetDisks {
})
.transpose()
.map_err(DiskError::from)?;
let till_offset = erasure.shard_file_offset(0, part.size, part.size);
// This reader covers the whole encoded shard. A range
// offset can exclude the final encoded byte when the
// logical part ends inside a data block, allowing a
// one-byte tail truncation to evade deep Heal.
let till_offset = usize::try_from(erasure.shard_file_size(part.size as i64))
.map_err(|_| DiskError::FileCorrupt)?;
let use_mmap_read = object_mmap_read_enabled();
let mut readers = Vec::with_capacity(latest_disks.len());
@@ -1887,13 +1913,26 @@ impl SetDisks {
.zip(&errs)
.any(|(info, error)| error.is_none() && info.is_canonical_delete_marker())
{
let cleanup = match Self::retired_marker_candidate(&parts_metadata, &errs) {
Ok(marker) => {
self.remove_retired_marker(retirement, bucket, object, marker, opts, &disks)
.await
}
Err(error) => Err(error),
};
// A current-generation delete marker can be left behind on
// an inaccessible member after an acknowledged S3 DELETE.
// That case has no bucket-retirement record, so evaluate its
// stricter same-incarnation proof before the retired-marker
// path. The proof requires an exact marker identity, at
// least one replica already absent, and every target online.
let cleanup =
match Self::same_incarnation_marker_candidate(&parts_metadata, &errs, retirement.current_incarnation) {
Ok(marker) => {
self.remove_current_marker(retirement, bucket, object, marker, opts, &disks)
.await
}
Err(_) => match Self::retired_marker_candidate(&parts_metadata, &errs) {
Ok(marker) => {
self.remove_retired_marker(retirement, bucket, object, marker, opts, &disks)
.await
}
Err(error) => Err(error),
},
};
let absent = cleanup.is_ok();
let item = self
.dangling_heal_result(FileInfo::default(), &errs, bucket, object, version_id, absent)
@@ -2007,6 +2046,41 @@ impl SetDisks {
candidate.ok_or_else(|| DiskError::retired_marker_deferred("no exact marker candidate"))
}
fn same_incarnation_marker_candidate(
metadata: &[FileInfo],
errors: &[Option<DiskError>],
current_incarnation: Option<Uuid>,
) -> disk::error::Result<rustfs_filemeta::MetaDeleteMarker> {
let defer = DiskError::retired_marker_deferred;
let Some(current) = current_incarnation.filter(|id| !id.is_nil()) else {
return Err(defer("current bucket incarnation is unavailable"));
};
let mut candidate = None;
let mut missing_replica = false;
for (info, error) in metadata.iter().zip(errors) {
match error {
Some(DiskError::FileNotFound | DiskError::FileVersionNotFound) => {
missing_replica = true;
continue;
}
Some(_) => return Err(defer("not every replica is readable")),
None => {}
}
if !info.is_canonical_delete_marker() || info.delete_marker_incarnation() != Some(current) {
return Err(defer("marker is not a canonical current-generation marker"));
}
let marker = rustfs_filemeta::MetaDeleteMarker::from(info.clone());
if candidate.as_ref().is_some_and(|previous| previous != &marker) {
return Err(defer("surviving marker identities conflict"));
}
candidate = Some(marker);
}
if !missing_replica {
return Err(defer("marker is present on every replica"));
}
candidate.ok_or_else(|| defer("no exact current-generation marker candidate"))
}
async fn remove_retired_marker(
&self,
context: &MarkerRetirementContext<'_>,
@@ -2045,6 +2119,72 @@ impl SetDisks {
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
let result = self
.remove_marker_exact_under_fence(bucket, object, marker, version, disks)
.await;
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
result
}
async fn remove_current_marker(
&self,
context: &MarkerRetirementContext<'_>,
bucket: &str,
object: &str,
marker: rustfs_filemeta::MetaDeleteMarker,
opts: &HealOpts,
disks: &[Option<DiskStore>],
) -> disk::error::Result<()> {
let defer = DiskError::retired_marker_deferred;
if opts.dry_run || !opts.remove {
return Err(defer("cleanup requires remove=true and dry_run=false"));
}
if disks.len() != self.set_drive_count || disks.iter().any(Option::is_none) {
return Err(defer("every target disk must be online"));
}
let Some(current) = context.current_incarnation.filter(|id| !id.is_nil()) else {
return Err(defer("current bucket incarnation is unavailable"));
};
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
let incarnation = marker.into_fileinfo(bucket, object, false)?.delete_marker_incarnation();
if incarnation != Some(current) {
return Err(defer("marker is not from the current bucket incarnation"));
}
let Some(version) = marker.version_id.filter(|id| !id.is_nil()) else {
return Err(defer("cleanup requires an explicit non-null version"));
};
let marker_identity = marker.stable_identity();
let marker_bytes = marker.marshal_msg().map_err(DiskError::other)?;
let Some(purge) = MrfDeleteMarkerPurge::new(current, current, marker_identity, marker_bytes) else {
return Err(defer("marker purge proof payload is invalid"));
};
if !self.has_marker_purge_receipt(bucket, object, version, &purge).await {
return Err(defer("same-incarnation marker lacks an acknowledged delete receipt"));
}
let result = self
.remove_marker_exact_under_fence(bucket, object, marker, version, disks)
.await;
if context.lifecycle_guard.is_lock_lost() {
return Err(defer("bucket lifecycle fence was lost"));
}
if result.is_ok() {
self.consume_marker_purge_receipt(bucket, object, version, &purge).await;
}
result
}
async fn remove_marker_exact_under_fence(
&self,
bucket: &str,
object: &str,
marker: rustfs_filemeta::MetaDeleteMarker,
version: Uuid,
disks: &[Option<DiskStore>],
) -> disk::error::Result<()> {
let request = FileInfo {
volume: bucket.to_owned(),
name: object.to_owned(),
@@ -2085,13 +2225,14 @@ impl SetDisks {
let same_targets = selected.len() == disks.len() && selected.iter().zip(disks).all(|(current, original)| {
matches!((current, original), (Some(current), Some(original)) if std::sync::Arc::ptr_eq(current, original))
});
if context.lifecycle_guard.is_lock_lost()
|| !same_targets
if !same_targets
|| !errors
.iter()
.all(|error| matches!(error, Some(DiskError::FileNotFound | DiskError::FileVersionNotFound)))
{
return Err(defer("complete marker absence could not be verified under the original fence"));
return Err(DiskError::retired_marker_deferred(
"complete marker absence could not be verified under the original target set",
));
}
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(())
@@ -3012,8 +3153,18 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
self.heal_object_with_absence(bucket, object, version_id, opts, &mut None)
.await
self.heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut None,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}
#[tracing::instrument(skip(self))]
@@ -3098,8 +3249,9 @@ impl SetDisks {
version_id: &str,
opts: &HealOpts,
absence: &mut Option<HealedObjectAbsence>,
proof: AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>)> {
self.heal_object_with_retirement(bucket, object, version_id, opts, absence, None)
self.heal_object_with_retirement(bucket, object, version_id, opts, absence, proof)
.await
}
@@ -3110,7 +3262,7 @@ impl SetDisks {
version_id: &str,
opts: &HealOpts,
absence: &mut Option<HealedObjectAbsence>,
retirement: Option<&MarkerRetirementContext<'_>>,
proof: AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>)> {
*absence = None;
let _write_lock_guard = if !opts.no_lock {
@@ -3176,7 +3328,7 @@ impl SetDisks {
.await;
// Check the lease after the final await before publishing the proof.
if !opts.dry_run
&& !version_id.is_empty()
&& (proof.allow_unversioned || !version_id.is_empty())
&& !disks.is_empty()
&& disks.iter().all(Option::is_some)
&& errs
@@ -3205,7 +3357,7 @@ impl SetDisks {
ExplicitVersionHeal {
opts: &inner_opts,
allow_regeneration: true,
retirement,
retirement: proof.retirement,
},
absence,
)
@@ -3225,7 +3377,7 @@ impl SetDisks {
ExplicitVersionHeal {
opts: &inner_opts,
allow_regeneration: true,
retirement,
retirement: proof.retirement,
},
absence,
)
@@ -3277,7 +3429,7 @@ impl SetDisks {
#[cfg(test)]
mod heal_result_report_tests {
use super::{
DanglingCheckPartsFailure, DanglingDeleteFailure, DanglingDeleteSafety, ReadRepairCommitFingerprint,
AbsenceProofRequest, DanglingCheckPartsFailure, DanglingDeleteFailure, DanglingDeleteSafety, ReadRepairCommitFingerprint,
ReadRepairPauseScope, SetDisks, heal_writer_error_summary,
};
use super::{HEAL_RENAME_INCOMPLETE, HealRenameFailureScope, HealWriterFailureScope};
@@ -3288,7 +3440,9 @@ mod heal_result_report_tests {
use crate::disk::{DiskAPI as _, DiskOption, DiskStore, RUSTFS_META_TMP_BUCKET, ReadOptions, STORAGE_FORMAT_FILE, new_disk};
use crate::error::Error;
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
use crate::set_disk::ops::object::hermetic_set_disks_support::{
hermetic_set_disks_for_pool_with_default_parity_isolated, hermetic_set_disks_isolated,
};
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
@@ -4069,6 +4223,634 @@ mod heal_result_report_tests {
);
}
#[tokio::test]
async fn deep_heal_rebuilds_a_stale_current_delete_marker_metadata_replica() {
let (_temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "deep-heal-stale-current-marker";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("test bucket should be created");
}
let versioned = ObjectOptions {
no_lock: true,
versioned: true,
..Default::default()
};
let original = set
.put_object(bucket, object, &mut PutObjReader::from_vec(vec![0x41; 1024]), &versioned)
.await
.expect("versioned source object should be written");
let stale_metadata = disks[0]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("pre-marker metadata should be readable");
let marker = set
.delete_object(bucket, object, versioned)
.await
.expect("versioned delete should create a current marker");
let marker_version = marker.version_id.expect("delete marker should have a version id");
// Simulate a member that was offline while the acknowledged marker was
// committed on the other members: its physical root still contains the
// pre-delete metadata and the original data version.
let mut stale_metadata = stale_metadata;
stale_metadata.fresh = true;
disks[0]
.write_metadata("", bucket, object, stale_metadata)
.await
.expect("stale member metadata should be restored for the fixture");
assert_eq!(
disks[0]
.read_version("", bucket, object, &marker_version.to_string(), &ReadOptions::default())
.await
.expect_err("stale member must not contain the current marker")
.to_string(),
"file version not found"
);
let (result, error) = set
.heal_object(
bucket,
object,
&marker_version.to_string(),
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("deep heal should report the stale marker result");
assert!(error.is_none(), "deep heal should repair stale marker metadata: {error:?}");
assert_eq!(result.drives_healed(), Some(1));
let repaired = disks[0]
.read_version("", bucket, object, &marker_version.to_string(), &ReadOptions::default())
.await
.expect("rejoined member should contain the marker");
assert!(repaired.deleted, "repaired metadata must remain a delete marker");
assert_eq!(repaired.version_id, Some(marker_version));
assert!(original.version_id.is_some(), "source object should have a version id");
}
#[tokio::test]
async fn deep_heal_rebuilds_near_tail_truncated_part() {
let (temp_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "deep-heal-near-tail-truncation";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let expected_payload = vec![0x6d; 1024 * 1024];
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(expected_payload.clone()),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("source object should be written before shard truncation");
let source = disks[2]
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("source metadata should be readable");
let data_dir = source.data_dir.expect("non-inline source should have a data directory");
let truncated_part = temp_dirs[1]
.path()
.join(bucket)
.join(object)
.join(data_dir.to_string())
.join("part.1");
let original_len = tokio::fs::metadata(&truncated_part)
.await
.expect("target shard should exist")
.len();
assert!(original_len > 1, "test shard must be large enough to truncate");
let file = tokio::fs::OpenOptions::new()
.write(true)
.open(&truncated_part)
.await
.expect("target shard should be writable");
file.set_len(original_len - 1)
.await
.expect("target shard should be truncated");
let mut reader = set
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("GET should remain readable after a one-byte shard truncation");
let mut read_back = Vec::new();
tokio::io::copy(&mut reader, &mut read_back)
.await
.expect("GET should reconstruct the truncated shard through EC");
assert_eq!(read_back, expected_payload, "EC GET must preserve the object bytes");
let (result, error) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("deep heal should finish after a one-byte tail truncation");
assert!(error.is_none(), "deep heal should recover the truncated shard: {error:?}");
assert_eq!(result.drives_healed(), Some(1));
assert_eq!(result.before.drives[1].state, DriveState::Corrupt.to_string());
assert_eq!(
tokio::fs::metadata(&truncated_part)
.await
.expect("repaired shard should exist")
.len(),
original_len,
"deep heal must restore the complete shard length"
);
}
#[derive(Debug)]
struct FailedIntegrityReadTransport {
error: DiskError,
reads: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl crate::cluster::rpc::internode_data_transport::InternodeDataTransport for FailedIntegrityReadTransport {
async fn open_read(
&self,
_request: crate::cluster::rpc::internode_data_transport::ReadStreamRequest,
) -> crate::disk::error::Result<crate::disk::FileReader> {
self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let error = match &self.error {
DiskError::FileCorrupt => DiskError::from(rustfs_rio::new_test_remote_file_corrupt_http_io_error()),
DiskError::FileNotFound => DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error()),
DiskError::VolumeNotFound => DiskError::from(rustfs_rio::new_test_remote_volume_not_found_http_io_error()),
error => error.clone(),
};
Err(error)
}
async fn open_write(
&self,
_request: crate::cluster::rpc::internode_data_transport::WriteStreamRequest,
) -> crate::disk::error::Result<crate::disk::FileWriter> {
panic!("an integrity scan must not write remote data");
}
async fn open_walk_dir(
&self,
_request: crate::cluster::rpc::internode_data_transport::WalkDirStreamRequest,
) -> crate::disk::error::Result<crate::disk::FileReader> {
panic!("an object integrity scan must not walk remote directories");
}
fn name(&self) -> &'static str {
"failed-integrity-read"
}
fn capabilities(&self) -> crate::cluster::rpc::internode_data_transport::InternodeDataTransportCapabilities {
crate::cluster::rpc::internode_data_transport::InternodeDataTransportCapabilities::tcp_http()
}
}
async fn assert_remote_integrity_read_failure(expected_error: DiskError) {
use crate::disk::{CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN};
use crate::object_api::ShardIntegrityWriteMode;
let (dirs, disks, set) = hermetic_set_disks_for_pool_with_default_parity_isolated(4, 0, 2).await;
let bucket = "remote-integrity-read-failure";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("create fixture bucket");
}
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0x6d; 1024 * 1024 + 123]),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
no_lock: true,
..Default::default()
},
)
.await
.expect("write protected external fixture");
let mut files = Vec::new();
let mut snapshots = Vec::new();
for (dir, disk) in dirs.iter().zip(&disks) {
let file = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read fixture metadata");
assert!(file.data.is_none(), "the remote test must open an external shard");
let object_dir = dir.path().join(bucket).join(object);
let part_path = object_dir
.join(file.data_dir.expect("external shard directory").to_string())
.join("part.1");
for path in [part_path, object_dir.join("xl.meta")] {
let bytes = tokio::fs::read(&path).await.expect("snapshot fixture before scan");
snapshots.push((path, bytes));
}
files.push(file);
}
let transport = Arc::new(FailedIntegrityReadTransport {
error: expected_error.clone(),
reads: std::sync::atomic::AtomicUsize::new(0),
});
let endpoint = Endpoint {
url: url::Url::parse("http://remote-integrity.invalid:9000/data/disk0").expect("fixture endpoint"),
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let remote = crate::cluster::rpc::RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
transport.clone(),
)
.await
.expect("construct remote disk with failing data transport");
let mut verification_disks: Vec<_> = disks.iter().cloned().map(Some).collect();
verification_disks[0] = Some(Arc::new(crate::disk::Disk::Remote(Box::new(remote))));
let mut statuses = [(0, vec![CHECK_PART_UNKNOWN; disks.len()])].into_iter().collect();
let result = crate::io_support::shard_integrity::verify_deep_parts(
&files,
&verification_disks,
&files[0],
bucket,
object,
&mut statuses,
)
.await;
assert!(transport.reads.load(std::sync::atomic::Ordering::Relaxed) > 0);
if matches!(expected_error, DiskError::FileCorrupt | DiskError::FileNotFound) {
result.expect("typed remote payload damage must identify a repairable shard");
let expected_status = if expected_error == DiskError::FileCorrupt {
CHECK_PART_FILE_CORRUPT
} else {
CHECK_PART_FILE_NOT_FOUND
};
assert_eq!(statuses[&0][0], expected_status);
assert!(statuses[&0][1..].iter().all(|status| *status == CHECK_PART_SUCCESS));
let (heal, metadata, reason) = super::should_heal_object_on_disk(&None, &[statuses[&0][0]], &files[0], &files[0]);
assert!(heal, "remote payload damage must schedule repair: {expected_error:?}");
assert!(!metadata);
let expected_reason = if expected_error == DiskError::FileCorrupt {
DiskError::FileCorrupt
} else {
DiskError::PartMissingOrCorrupt
};
assert_eq!(reason, Some(expected_reason));
} else {
let error = result.expect_err("unavailable payload storage must abort health verification");
if expected_error.is_internode_http_status(500) {
assert!(error.is_internode_http_status(500), "preserve the original transport error: {error:?}");
} else {
assert_eq!(error, expected_error, "preserve the original storage failure");
}
assert_eq!(statuses[&0][0], CHECK_PART_UNKNOWN, "failed verification cannot certify the shard");
}
for (path, before) in snapshots {
assert_eq!(tokio::fs::read(path).await.expect("read fixture after scan"), before);
}
}
#[tokio::test]
async fn deep_scan_remote_payload_damage_schedules_repair() {
for error in [DiskError::FileCorrupt, DiskError::FileNotFound] {
assert_remote_integrity_read_failure(error).await;
}
}
#[tokio::test]
async fn deep_scan_remote_storage_failure_cannot_certify_healthy() {
for error in [
DiskError::from(rustfs_rio::new_test_internode_http_io_error(
rustfs_rio::InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
)),
DiskError::DiskNotFound,
DiskError::VolumeNotFound,
DiskError::FileAccessDenied,
] {
assert_remote_integrity_read_failure(error).await;
}
}
#[cfg(unix)]
#[tokio::test]
async fn deep_heal_legacy_verification_error_cannot_certify_healthy() {
use crate::object_api::ShardIntegrityWriteMode;
let (dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "legacy-deep-scan-verification-error";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("create legacy fixture bucket");
}
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0x6d; 1024 * 1024 + 123]),
&ObjectOptions {
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
no_lock: true,
..Default::default()
},
)
.await
.expect("write external legacy fixture");
let mut files = Vec::new();
let mut part_paths = Vec::new();
let mut preserved = Vec::new();
for (index, (dir, disk)) in dirs.iter().zip(&disks).enumerate() {
let file = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read legacy fixture metadata");
assert!(file.data.is_none(), "legacy verification must open the physical shard");
assert!(file.parts.iter().all(|part| part.integrity.is_none()));
let object_dir = dir.path().join(bucket).join(object);
let part = object_dir
.join(file.data_dir.expect("external legacy shard directory").to_string())
.join("part.1");
let metadata = object_dir.join("xl.meta");
let bytes = tokio::fs::read(&metadata).await.expect("snapshot legacy metadata");
preserved.push((metadata, bytes));
if index != 0 {
let bytes = tokio::fs::read(&part).await.expect("snapshot healthy legacy shard");
preserved.push((part.clone(), bytes));
}
part_paths.push(part);
files.push(file);
}
let broken_part = &part_paths[0];
tokio::fs::remove_file(broken_part)
.await
.expect("remove fixture shard before symlink fault");
std::os::unix::fs::symlink("part.1", broken_part).expect("create self-referential shard symlink");
let open_error = tokio::fs::File::open(broken_part)
.await
.expect_err("self-referential symlink must fail to open");
assert_eq!(open_error.raw_os_error(), Some(libc::ELOOP));
let verification_error = disks[0]
.verify_file(bucket, object, &files[0])
.await
.expect_err("legacy disk verifier must reject the symlink path");
assert_eq!(verification_error, DiskError::InvalidPath);
let error = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
},
)
.await
.expect_err("an unverified legacy part must fail the complete heal operation");
assert_eq!(error, DiskError::InvalidPath, "complete heal must preserve the verifier's path error");
assert_eq!(
tokio::fs::read_link(broken_part)
.await
.expect("failed heal must preserve the symlink fault"),
std::path::Path::new("part.1")
);
for (path, before) in preserved {
assert_eq!(tokio::fs::read(path).await.expect("read unchanged legacy fixture"), before);
}
}
async fn assert_h2_truncated_shards(
mode: crate::object_api::ShardIntegrityWriteMode,
coding_indexes: &[usize],
retained_len: fn(usize, usize) -> usize,
) {
use crate::object_api::ShardIntegrityWriteMode;
let (temp_dirs, disks, set) = hermetic_set_disks_for_pool_with_default_parity_isolated(16, 0, 4).await;
let bucket = "deep-heal-h2-truncation";
let object = "object.bin";
for disk in &disks {
disk.make_volume(bucket).await.expect("create bucket volume");
}
let expected_payload: Vec<_> = (0..5 * 1024 * 1024 + 123)
.map(|index| u8::try_from((index / 4096 + index) % 251).expect("payload byte fits"))
.collect();
set.put_object(
bucket,
object,
&mut PutObjReader::from_vec(expected_payload.clone()),
&ObjectOptions {
shard_integrity_write_mode: Some(mode),
no_lock: true,
..Default::default()
},
)
.await
.expect("commit all source shards");
let mut files = Vec::new();
let mut paths = Vec::new();
let mut original_shards = Vec::new();
let mut original_metadata = Vec::new();
let mut damaged = Vec::new();
for (slot, disk) in disks.iter().enumerate() {
let source = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read source metadata");
assert_eq!((source.erasure.data_blocks, source.erasure.parity_blocks), (12, 4));
assert_eq!(source.parts[0].integrity.is_some(), mode == ShardIntegrityWriteMode::Protected);
let directory = temp_dirs[slot].path().join(bucket).join(object);
let path = directory
.join(source.data_dir.expect("external shard data directory").to_string())
.join("part.1");
let original = tokio::fs::read(&path).await.expect("read original shard");
original_metadata.push(tokio::fs::read(directory.join("xl.meta")).await.expect("read xl.meta"));
if coding_indexes.contains(&source.erasure.index) {
let erasure = crate::erasure::coding::Erasure::try_new_with_options(
12,
4,
source.erasure.block_size,
source.uses_legacy_checksum,
)
.expect("valid erasure layout");
let stripe_size = erasure.shard_size() + source.erasure.get_checksum_info(1).algorithm.size();
let keep = retained_len(original.len(), stripe_size);
assert!(keep < original.len(), "fixture must truncate the shard");
tokio::fs::OpenOptions::new()
.write(true)
.open(&path)
.await
.expect("open target shard")
.set_len(u64::try_from(keep).expect("shard length fits"))
.await
.expect("truncate target shard");
damaged.push((slot, keep));
}
paths.push(path);
original_shards.push(original);
files.push(source);
}
assert_eq!(damaged.len(), coding_indexes.len());
if mode == ShardIntegrityWriteMode::Protected {
let mut statuses = [(0, vec![crate::disk::CHECK_PART_UNKNOWN; disks.len()])]
.into_iter()
.collect();
let online_disks: Vec<_> = disks.iter().cloned().map(Some).collect();
crate::io_support::shard_integrity::verify_deep_parts(
&files,
&online_disks,
&files[0],
bucket,
object,
&mut statuses,
)
.await
.expect("verify protected shards directly");
for (slot, status) in statuses[&0].iter().enumerate() {
let expected = if damaged.iter().any(|(index, _)| *index == slot) {
crate::disk::CHECK_PART_FILE_CORRUPT
} else {
crate::disk::CHECK_PART_SUCCESS
};
assert_eq!(*status, expected, "protected shard {slot}");
}
}
let opts = HealOpts {
no_lock: true,
scan_mode: HealScanMode::Deep,
..Default::default()
};
let (dry_run, error) = set
.heal_object(bucket, object, "", &HealOpts { dry_run: true, ..opts })
.await
.expect("dry-run should report corruption");
assert!(error.is_none(), "dry-run error: {error:?}");
assert_eq!(dry_run.drives_healed(), Some(0));
for (slot, keep) in &damaged {
assert_eq!(dry_run.after.drives[*slot].state, DriveState::Corrupt.to_string());
assert_eq!(
tokio::fs::read(&paths[*slot]).await.expect("dry-run preserved shard"),
original_shards[*slot][..*keep]
);
}
let recoverable = damaged.len() <= 4;
if recoverable {
let mut reader = set
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("degraded GET should remain readable");
let mut read_back = Vec::new();
tokio::io::copy(&mut reader, &mut read_back)
.await
.expect("read complete degraded GET");
assert_eq!(read_back, expected_payload);
}
let (result, error) = set
.heal_object(bucket, object, "", &opts)
.await
.expect("heal should report outcome");
if recoverable {
assert!(error.is_none(), "heal error: {error:?}");
assert_eq!(result.drives_healed(), Some(damaged.len()));
for (slot, _) in &damaged {
assert_eq!(result.before.drives[*slot].state, DriveState::Corrupt.to_string());
}
} else {
assert_eq!(error, Some(DiskError::ErasureReadQuorum));
assert_eq!(result.drives_healed(), Some(0));
}
for (slot, path) in paths.iter().enumerate() {
let expected = if !recoverable && let Some((_, keep)) = damaged.iter().find(|(index, _)| *index == slot) {
&original_shards[slot][..*keep]
} else {
original_shards[slot].as_slice()
};
assert_eq!(
tokio::fs::read(path).await.expect("read shard after heal"),
expected,
"physical shard {slot}"
);
if !recoverable {
let metadata_path = temp_dirs[slot].path().join(bucket).join(object).join("xl.meta");
assert_eq!(tokio::fs::read(metadata_path).await.expect("preserved metadata"), original_metadata[slot]);
}
}
if recoverable {
let mut reader = set
.get_object_reader(bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("GET after repair");
let mut read_back = Vec::new();
tokio::io::copy(&mut reader, &mut read_back)
.await
.expect("read repaired object");
assert_eq!(read_back, expected_payload);
let (healthy, error) = set.heal_object(bucket, object, "", &opts).await.expect("second deep scan");
assert!(error.is_none());
assert_eq!(healthy.drives_healed(), Some(0), "repair must converge");
assert_eq!(healthy.integrity_verified, mode == ShardIntegrityWriteMode::Protected);
}
}
#[tokio::test]
async fn deep_heal_rebuilds_h2_near_tail_truncated_part() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
for coding_index in [1, 13] {
assert_h2_truncated_shards(mode, &[coding_index], |length, _| length - 1).await;
}
}
}
#[tokio::test]
async fn deep_heal_rebuilds_h2_half_truncated_part() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
for coding_index in [1, 13] {
assert_h2_truncated_shards(mode, &[coding_index], |length, _| length / 2).await;
}
}
}
#[tokio::test]
async fn deep_heal_rebuilds_h2_stripe_boundary_truncated_parts_at_read_quorum() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
assert_h2_truncated_shards(mode, &[1, 2, 13, 16], |_, stripe| stripe).await;
}
}
#[tokio::test]
async fn deep_heal_rejects_h2_truncated_parts_below_read_quorum() {
use crate::object_api::ShardIntegrityWriteMode::{Legacy, Protected};
for mode in [Legacy, Protected] {
assert_h2_truncated_shards(mode, &[1, 2, 3, 13, 16], |length, _| length / 2).await;
}
}
#[tokio::test]
async fn deep_heal_inline_bitrot_rebuilds_physical_data_and_parity_shards() {
use crate::storage_api_contracts::range::HTTPRangeSpec;
@@ -5385,7 +6167,17 @@ mod heal_result_report_tests {
let failure = DanglingDeleteFailure::install(bucket, object, 0, DiskError::FaultyDisk);
let mut proof = None;
let (_, error) = set
.heal_object_with_absence(bucket, object, &version.to_string(), &opts, &mut proof)
.heal_object_with_absence(
bucket,
object,
&version.to_string(),
&opts,
&mut proof,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
.expect("heal should return a per-object failure");
assert!(
@@ -5401,7 +6193,17 @@ mod heal_result_report_tests {
);
drop(failure);
let (result, error) = set
.heal_object_with_absence(bucket, object, &version.to_string(), &opts, &mut proof)
.heal_object_with_absence(
bucket,
object,
&version.to_string(),
&opts,
&mut proof,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
.expect("retry should execute cleanup");
assert!(error.is_none(), "retry should complete: {error:?}");
@@ -5416,7 +6218,17 @@ mod heal_result_report_tests {
.all(|drive| drive.state == DriveState::Missing.to_string())
);
let (_, _) = set
.heal_object_with_absence(bucket, object, &version.to_string(), &opts, &mut proof)
.heal_object_with_absence(
bucket,
object,
&version.to_string(),
&opts,
&mut proof,
AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
.expect("already absent replay should execute");
assert!(!proof.expect("exact already-absent replay must remain provable").removed);
+24 -2
View File
@@ -341,14 +341,17 @@ impl SetDisks {
pub async fn renew_disk(&self, ep: &Endpoint) {
debug!("renew_disk: start {:?}", ep);
let previous_health = {
let previous_disk = {
let disks = self.disks.read().await;
disks
.iter()
.filter_map(|disk| disk.as_ref())
.find(|disk| disk.endpoint() == *ep)
.and_then(|disk| disk.local_health_tracker_epoch_for_reconnect())
.cloned()
};
let previous_health = previous_disk
.as_ref()
.and_then(|disk| disk.local_health_tracker_epoch_for_reconnect());
let (new_disk, fm) = match Self::connect_endpoint(ep, previous_health).await {
Ok(res) => res,
@@ -406,6 +409,25 @@ impl SetDisks {
}
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
if !new_disk.is_local() {
let mut disks = self.disks.write().await;
let slot = &mut disks[disk_idx];
let unchanged = match (slot.as_ref(), previous_disk.as_ref()) {
(None, None) => true,
(Some(current), Some(previous)) => Arc::ptr_eq(current, previous),
_ => false,
};
// A GET probe or another reconnect may have filled this slot while
// format I/O was pending. Do not replace its handle or start a
// second health monitor for an unpublished handle.
if unchanged {
new_disk.enable_health_check();
*slot = Some(new_disk);
}
return;
}
new_disk.enable_health_check();
if new_disk.is_local() {
+18 -4
View File
@@ -2776,6 +2776,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
opts.suppress_read_repair,
true,
true,
GET_OBJECT_PATH_LEGACY_DUPLEX,
@@ -2805,6 +2806,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
opts.suppress_read_repair,
true,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
@@ -2893,6 +2895,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
opts.suppress_read_repair,
true,
false,
GET_OBJECT_PATH_DIRECT_MEMORY,
@@ -2949,6 +2952,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
opts.suppress_read_repair,
object_class.as_str(),
size_bucket,
false,
@@ -2995,6 +2999,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
opts.suppress_read_repair,
object_class.as_str(),
size_bucket,
codec_streaming_gate.prefer_data_blocks_first_reader_setup,
@@ -3063,6 +3068,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
let set_index = self.set_index;
let pool_index = self.pool_index;
let skip_verify = opts.skip_verify_bitrot;
let suppress_read_repair = opts.suppress_read_repair;
// The producer runs in a separate Tokio task, so carry the caller's
// read policy across the task boundary explicitly. Tokio task-local
// values are not inherited by spawned tasks.
@@ -3093,6 +3099,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
set_index,
pool_index,
skip_verify,
suppress_read_repair,
false,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
@@ -6715,7 +6722,7 @@ fn remote_version_state_writer_enabled() -> bool {
}
fn remote_version_state_writer_fleet_proof() -> Option<RemoteVersionStateFleetProofToken> {
transaction_fencing_fleet_proof(remote_version_state_writer_requested())
crate::services::notification_sys::acquire_remote_version_state_writer_fleet_proof()
}
fn remote_version_state_writer_requested() -> bool {
@@ -7571,7 +7578,11 @@ impl SetDisks {
let fi = &transported;
let disks = self.disk_inventory().await;
let namespace_owner = (!is_meta_bucketname(bucket)).then(|| self.ctx.begin_namespace_commit());
let write_quorum = disks.len() / 2 + 1;
// Quorum is a property of the configured set, not of the current
// online snapshot. A decommission/offline refresh may temporarily
// shorten the snapshot; deriving quorum from it would turn a
// quorum-minus-one delete into an apparent success.
let write_quorum = self.set_drive_count / 2 + 1;
let rollback_dir = Uuid::new_v4();
let mut futures = Vec::with_capacity(disks.len());
@@ -7699,10 +7710,11 @@ impl SetDisks {
drop(namespace_owner);
if quorum_result.is_ok()
&& errs.iter().any(Option::is_some)
&& let Some(purge) = delete_marker_purge
&& let Some(purge) = delete_marker_purge.as_ref()
&& let Some(version) = fi.version_id.filter(|version| !version.is_nil())
{
let _ = self.persist_delete_marker_purge(bucket, object, version, purge).await;
let _ = self.persist_marker_purge_receipt(bucket, object, version, purge).await;
let _ = self.persist_delete_marker_purge(bucket, object, version, purge.clone()).await;
}
// An explicit purge can carry deleted=true for the existing marker.
// It must not create a repair intent that could reintroduce that marker.
@@ -9488,6 +9500,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let set_index = self.set_index;
let pool_index = self.pool_index;
let skip_verify = opts.skip_verify_bitrot;
let suppress_read_repair = opts.suppress_read_repair;
let metrics_size_bucket = rustfs_io_metrics::get_object_size_bucket(cloned_fi.size);
let erasure_cache = Arc::clone(&self.erasure_cache);
let producer = async move {
@@ -9505,6 +9518,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
set_index,
pool_index,
skip_verify,
suppress_read_repair,
false,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ use crate::diagnostics::get::{
use crate::disk::error::Error;
use crate::layout::disks_layout::MAX_ERASURE_SET_DRIVE_COUNT;
use smallvec::SmallVec;
use std::sync::atomic::{AtomicU8, Ordering};
/// Generic codec callers may exceed the production set limit; `SmallVec` then
/// spills without changing slot semantics.
@@ -25,6 +26,35 @@ pub(crate) const INLINE_SHARD_SLOTS: usize = MAX_ERASURE_SET_DRIVE_COUNT;
pub(crate) type ShardBuffers = SmallVec<[Option<Vec<u8>>; INLINE_SHARD_SLOTS]>;
pub(crate) type ShardErrors = SmallVec<[Option<Error>; INLINE_SHARD_SLOTS]>;
/// Repair evidence is independent of reconstruction quorum. Successful shards
/// must not vote away a minority's observed corruption. Streaming decoders
/// share this small accumulator with the reader that completes the response.
#[derive(Debug, Default)]
pub(crate) struct ShardReadRepair {
observed: AtomicU8,
}
impl ShardReadRepair {
pub(crate) fn observe(&self, errors: &[Option<Error>]) {
let flags = errors.iter().flatten().fold(0, |flags, error| {
flags
| match error {
Error::FileCorrupt => 1,
Error::FileNotFound => 2,
_ => 0,
}
});
if flags != 0 {
self.observed.fetch_or(flags, Ordering::Relaxed);
}
}
pub(crate) fn needed(&self, allow_missing: bool) -> bool {
let mask = if allow_missing { 3 } else { 1 };
self.observed.load(Ordering::Relaxed) & mask != 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShardReadCost {
Local,
@@ -164,6 +194,19 @@ mod tests {
use super::*;
use std::mem::size_of;
#[test]
fn repair_evidence_survives_healthy_stripes_and_excludes_unproven_failures() {
let evidence = ShardReadRepair::default();
evidence.observe(&[None, Some(Error::Timeout), Some(Error::DiskNotFound)]);
assert!(!evidence.needed(true));
evidence.observe(&[None, None, Some(Error::FileNotFound)]);
assert!(!evidence.needed(false), "unattempted reader slots do not prove a missing shard");
assert!(evidence.needed(true));
evidence.observe(&[None, None, Some(Error::FileCorrupt)]);
evidence.observe(&[None, None, None]);
assert!(evidence.needed(false), "a later healthy stripe must not erase observed corruption");
}
#[test]
fn stripe_scratch_capacity_matches_the_production_set_limit() {
type OversizedShardBuffers = SmallVec<[Option<Vec<u8>>; 32]>;
+62
View File
@@ -645,6 +645,17 @@ impl ECStore {
.as_ref()
.and_then(|info| info.created)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// A sidecar left by an interrupted legacy migration is the
// bucket's published incarnation; keep it rather than minting
// one that would invalidate fences taken on the stored value.
// A retired incarnation is residue of a deleted bucket and
// must not come back.
let store = metadata_sys::object_store_in(&self.ctx).await?;
if let Some(stored) = crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket).await?
&& !crate::bucket::retirement::is_retired(store, bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
return Err(Error::other(format!(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
@@ -2881,6 +2892,57 @@ mod tests {
);
}
/// rustfs/rustfs#8003: a force-create (site replication replay, admin
/// import) against a bucket whose legacy migration stopped after the
/// sidecar write must persist `.metadata.bin` under the stored incarnation
/// rather than fail closed or replace the published identity.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn issue_8003_force_create_adopts_existing_incarnation_sidecar() {
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
let bucket = format!("bucket-8003-sidecar-{}", Uuid::new_v4().simple());
ecstore
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let stored = metadata_sys::get_in(&ecstore.ctx, &bucket)
.await
.expect("metadata should load")
.bucket_incarnation_id;
assert!(!stored.is_nil());
let metadata_path = format!("{BUCKET_META_PREFIX}/{bucket}/{}", crate::bucket::metadata::BUCKET_METADATA_FILE);
crate::config::com::delete_config(ecstore.clone(), &metadata_path)
.await
.expect("simulate the interrupted migration by removing only .metadata.bin");
assert!(!any_disk_path_exists(&disk_paths, format!("{RUSTFS_META_BUCKET}/{metadata_path}")).await);
metadata_sys::remove_bucket_metadata_in(&ecstore.ctx, &bucket)
.await
.expect("drop the cached copy so the next read hits disk");
ecstore
.make_bucket(
&bucket,
&MakeBucketOptions {
force_create: true,
..Default::default()
},
)
.await
.expect("force create must repair the sidecar-only bucket");
let (repaired, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&ecstore.ctx, &bucket)
.await
.expect("repaired metadata should load");
assert!(persisted, "force create must persist .metadata.bin again");
assert!(repaired.bucket_incarnation_sidecar);
assert_eq!(
repaired.bucket_incarnation_id, stored,
"the repair must keep the incarnation the sidecar already published"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn make_bucket_seeds_new_bucket_durability_override() {
+381 -7
View File
@@ -58,6 +58,26 @@ pub(crate) fn bucket_heal_scope(bucket: &str) -> Option<Arc<BucketHealScope>> {
.flatten()
}
/// Resolve only the two canonical bucket records; other internal paths carry no bucket authority.
pub(super) fn bucket_metadata_owner(object: &str) -> Option<&str> {
use crate::bucket::metadata::{BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE};
let (prefix, rest) = object.split_once('/')?;
let (bucket, file) = rest.split_once('/')?;
(prefix == disk::BUCKET_META_PREFIX
&& matches!(file, BUCKET_METADATA_FILE | BUCKET_INCARNATION_FILE)
&& check_valid_bucket_name_strict(bucket).is_ok())
.then_some(bucket)
}
pub(crate) fn bucket_heal_scope_for_object(bucket: &str, object: &str) -> Option<Arc<BucketHealScope>> {
let owner = if bucket == RUSTFS_META_BUCKET {
bucket_metadata_owner(object)?
} else {
bucket
};
bucket_heal_scope(owner)
}
/// Storage-owned proof for the exact version and every selected erasure location.
/// This is an in-process result, never reconstructed from admin drive telemetry.
#[derive(Debug)]
@@ -296,6 +316,24 @@ impl ECStore {
.await
}
pub async fn heal_mrf_object_at_incarnation(
self: &Arc<Self>,
bucket: &str,
object: &str,
version_id: &str,
expected: uuid::Uuid,
opts: &HealOpts,
) -> Result<HealObjectStorageResult> {
let object = object.to_owned();
let version_id = version_id.to_owned();
self.run_bucket_heal_at_incarnation(bucket, expected, opts, |store, bucket, opts| async move {
store
.heal_object_with_authoritative_absence_proof(&bucket, &object, &version_id, &opts)
.await
})
.await
}
async fn acquire_heal_format_fence(
&self,
) -> Result<(
@@ -404,6 +442,97 @@ impl ECStore {
Ok(Arc::ptr_eq(&target_set, &pool.get_disks_by_key(POOL_META_NAME)))
}
/// Restore the bucket configuration and incarnation before retiring a replacement marker.
pub async fn heal_replacement_bucket_metadata(
self: &Arc<Self>,
bucket: &str,
opts: &HealOpts,
targets: &[String],
) -> Result<()> {
use crate::bucket::metadata::{BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE};
use crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in;
let (Some(pool_index), Some(set_index)) = (opts.pool, opts.set) else {
return Err(Error::PreconditionFailed);
};
if targets.is_empty() || opts.dry_run || opts.no_lock || check_valid_bucket_name_strict(bucket).is_err() {
return Err(Error::PreconditionFailed);
}
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let target_set = pool.get_disks_for_heal_object(BUCKET_METADATA_FILE, opts)?;
if targets.iter().any(|target| {
target_set
.set_endpoints
.iter()
.filter(|endpoint| endpoint.to_string() == *target)
.count()
!= 1
}) {
return Err(Error::PreconditionFailed);
}
let objects: Vec<_> = [BUCKET_METADATA_FILE, BUCKET_INCARNATION_FILE]
.into_iter()
.map(|file| format!("{}/{bucket}/{file}", disk::BUCKET_META_PREFIX))
.filter(|object| Arc::ptr_eq(&target_set, &pool.get_disks_by_key(object)))
.collect();
if objects.is_empty() {
return Ok(());
}
// Read the persisted identity without lazy migration: a replacement
// must never turn lost configuration into newly fabricated defaults.
let incarnation = self.bucket_incarnation_id_from_disk(bucket).await?;
let targets = targets.to_vec();
self.run_bucket_heal_at_incarnation(bucket, incarnation, opts, move |store, bucket, opts| async move {
// Match config writers: lifecycle, metadata transaction, object locks.
// Keep the transaction stable through target readback, including when
// the caller is cancelled and the storage owner finishes its write.
let transaction = acquire_bucket_metadata_transaction_read_lock_in(&store.ctx, &bucket).await?;
for object in objects {
let scope = bucket_heal_scope(&bucket).ok_or(Error::PreconditionFailed)?;
scope.check()?;
if transaction.is_lock_lost() {
return Err(Error::PreconditionFailed);
}
// Config objects occupy one pool, unlike pool.bin. Require a
// successful all-pool lookup before accepting another pool's ownership.
if !store.single_pool() {
let (owner, _) = store
.get_pool_info_for_delete_marker(RUSTFS_META_BUCKET, &object, &ObjectOptions::default())
.await?;
if owner.index != pool_index {
continue;
}
}
let metadata_opts = HealOpts { remove: false, ..opts };
let (result, error) = store.heal_object(RUSTFS_META_BUCKET, &object, "", &metadata_opts).await?;
if let Some(error) = error {
return Err(error);
}
for target in &targets {
let mut drives = result.after.drives.iter().filter(|drive| &drive.endpoint == target);
if !drives.next().is_some_and(|drive| drive.state == "ok") || drives.next().is_some() {
return Err(Error::PreconditionFailed);
}
}
if !store
.replacement_targets_have_version(RUSTFS_META_BUCKET, &object, "", pool_index, set_index, &targets)
.await?
{
return Err(Error::PreconditionFailed);
}
}
if transaction.is_lock_lost() {
return Err(Error::PreconditionFailed);
}
Ok(())
})
.await
}
/// Return every live erasure set selected by an object-heal scope.
pub async fn heal_erasure_set_scopes(&self, opts: &HealOpts) -> Result<Vec<(usize, usize)>> {
let pools = self.get_pools_for_heal_object(opts)?;
@@ -684,8 +813,18 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
self.handle_heal_object_with_absence(bucket, object, version_id, opts, &mut None, None)
.await
self.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut None,
crate::set_disk::AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}
pub async fn heal_object_with_proof(
@@ -695,7 +834,34 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
) -> Result<HealObjectStorageResult> {
if opts.dry_run || opts.no_lock || version_id.is_empty() || super::utils::is_reserved_or_invalid_bucket(bucket, false) {
self.heal_object_with_absence_proof(bucket, object, version_id, opts, false)
.await
}
async fn heal_object_with_authoritative_absence_proof(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
) -> Result<HealObjectStorageResult> {
self.heal_object_with_absence_proof(bucket, object, version_id, opts, true)
.await
}
async fn heal_object_with_absence_proof(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
if opts.dry_run
|| opts.no_lock
|| (!allow_unversioned_absence && version_id.is_empty())
|| super::utils::is_reserved_or_invalid_bucket(bucket, false)
{
let (item, error) = self.handle_heal_object(bucket, object, version_id, opts).await?;
return Ok(HealObjectStorageResult {
item,
@@ -738,7 +904,17 @@ impl ECStore {
};
let mut proofs = None;
let (item, mut error) = self
.handle_heal_object_with_absence(bucket, object, version_id, opts, &mut proofs, Some(&retirement))
.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut proofs,
crate::set_disk::AbsenceProofRequest {
retirement: Some(&retirement),
allow_unversioned: allow_unversioned_absence,
},
)
.await?;
// Read the authoritative incarnation only for an absence candidate.
// The lifecycle guard has pinned it throughout the storage operation.
@@ -780,7 +956,7 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
absence: &mut Option<Vec<crate::set_disk::HealedObjectAbsence>>,
retirement: Option<&crate::bucket::retirement::MarkerRetirementContext<'_>>,
proof: crate::set_disk::AbsenceProofRequest<'_>,
) -> Result<(HealResultItem, Option<Error>)> {
trace!(
event = EVENT_HEAL_OBJECT_STARTED,
@@ -871,7 +1047,7 @@ impl ECStore {
}
#[cfg(test)]
crate::core::pools::notify_decommission_external_heal_operation_started(store_id);
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, retirement)
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, proof)
.await
}
});
@@ -895,7 +1071,7 @@ impl ECStore {
move |opts| async move {
#[cfg(test)]
crate::core::pools::notify_decommission_external_heal_operation_started(store_id);
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, retirement)
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, proof)
.await
},
));
@@ -1280,6 +1456,16 @@ mod tests {
.await
.is_err()
);
for file in [".metadata.bin", ".bucket-incarnation"] {
let metadata = format!("buckets/{bucket}/{file}");
assert!(
store
.rename_local_data_at_incarnation(&disk_ref, (&bucket, object), &fi, (RUSTFS_META_BUCKET, &metadata), old)
.await
.is_err(),
"stale remote heal must not replace the successor's {file}"
);
}
assert!(store.heal_bucket_at_incarnation(&bucket, old, &opts).await.is_err());
assert!(
store
@@ -1678,6 +1864,194 @@ mod tests {
(temp_dir, store, shutdown)
}
#[test]
fn bucket_metadata_heal_owner_rejects_path_aliases_and_unrelated_records() {
for file in [".metadata.bin", ".bucket-incarnation"] {
assert_eq!(bucket_metadata_owner(&format!("buckets/example/{file}")), Some("example"));
}
for path in [
"/buckets/example/.metadata.bin",
"buckets/../.metadata.bin",
"buckets/example/../.metadata.bin",
"buckets/example/.metadata.bin/extra",
"buckets/example//.metadata.bin",
"buckets/example/.metadata.bin.old",
"config/iam/example/.metadata.bin",
"buckets/example/usage.json",
] {
assert_eq!(bucket_metadata_owner(path), None, "unexpected bucket authority for {path}");
}
}
#[tokio::test]
async fn replacement_bucket_metadata_respects_pool_ownership_and_required_records() {
let (root, store, shutdown) = multi_pool_heal_store().await;
let bucket = "replacement-bucket-metadata";
for invalid_bucket in ["MixedCase", "../example", RUSTFS_META_BUCKET] {
assert!(matches!(
store
.heal_replacement_bucket_metadata(
invalid_bucket,
&HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
&[root.path().join("pool0-disk0").to_string_lossy().into_owned()],
)
.await,
Err(Error::PreconditionFailed)
));
}
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket and its persisted records");
let mut owned = 0;
for pool in 0..2 {
let target = root.path().join(format!("pool{pool}-disk0"));
let records: Vec<_> = [".metadata.bin", ".bucket-incarnation"]
.into_iter()
.map(|file| {
let path = target.join(RUSTFS_META_BUCKET).join("buckets").join(bucket).join(file);
let exists = path.join("xl.meta").exists();
if exists {
std::fs::remove_dir_all(&path).expect("remove replacement metadata shard");
owned += 1;
}
(path, exists)
})
.collect();
store
.heal_replacement_bucket_metadata(
bucket,
&HealOpts {
pool: Some(pool),
set: Some(0),
recreate: true,
..Default::default()
},
&[target.to_string_lossy().into_owned()],
)
.await
.expect("repair owned records and accept authoritative ownership in the other pool");
for (path, existed) in records {
assert_eq!(path.join("xl.meta").exists(), existed, "wrong placement for {}", path.display());
}
}
assert_eq!(owned, 2, "each required record must have exactly one owning pool");
let object = format!("buckets/{bucket}/.metadata.bin");
let owner = store
.get_pool_idx_existing_with_opts(RUSTFS_META_BUCKET, &object, &ObjectOptions::default())
.await
.expect("persisted metadata owner");
let disk = store.pools[owner].disk_set[0].disks.read().await[0]
.clone()
.expect("metadata target disk");
let fi = disk
.read_version(
"",
RUSTFS_META_BUCKET,
&object,
"",
&disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("inline metadata to repair through target-side admission");
assert!(fi.data.is_some(), "fixture must contain an inline metadata shard");
let target_path = root.path().join(format!("pool{owner}-disk0"));
std::fs::remove_dir_all(target_path.join(RUSTFS_META_BUCKET).join(&object)).expect("remove remote target shard");
let incarnation = store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("current incarnation");
store
.rename_local_data_at_incarnation(
&disk.endpoint().to_string(),
(disk::RUSTFS_META_TMP_BUCKET, "metadata-heal-control"),
&fi,
(RUSTFS_META_BUCKET, &object),
incarnation,
)
.await
.expect("the target-side RPC admission must accept current bucket metadata repair");
assert!(target_path.join(RUSTFS_META_BUCKET).join(&object).join("xl.meta").exists());
let (configuration, _) = read_config_no_lock_preserve_empty_with_metadata(store.clone(), &object)
.await
.expect("read the current bucket configuration");
save_config(store.clone(), &object, configuration)
.await
.expect("publish a newer config revision in the same bucket incarnation");
let committed =
std::fs::read(target_path.join(RUSTFS_META_BUCKET).join(&object).join("xl.meta")).expect("new target configuration");
assert!(
store
.rename_local_data_at_incarnation(
&disk.endpoint().to_string(),
(disk::RUSTFS_META_TMP_BUCKET, "delayed-metadata-heal"),
&fi,
(RUSTFS_META_BUCKET, &object),
incarnation,
)
.await
.is_err(),
"a delayed repair must not overwrite a newer config in the same incarnation"
);
assert_eq!(
std::fs::read(target_path.join(RUSTFS_META_BUCKET).join(&object).join("xl.meta")).expect("retained configuration"),
committed,
"rejected repair must leave the newer configuration unchanged"
);
for pool in 0..2 {
for disk in 0..4 {
let path = root
.path()
.join(format!("pool{pool}-disk{disk}"))
.join(RUSTFS_META_BUCKET)
.join("buckets")
.join(bucket)
.join(".metadata.bin");
if path.exists() {
std::fs::remove_dir_all(path).expect("remove every copy of required bucket configuration");
}
}
}
metadata_sys::remove_bucket_metadata_in(&store.ctx, bucket)
.await
.expect("remove the cached configuration so repair cannot rely on it");
assert!(
store
.heal_replacement_bucket_metadata(
bucket,
&HealOpts {
pool: Some(0),
set: Some(0),
recreate: true,
..Default::default()
},
&[root.path().join("pool0-disk0").to_string_lossy().into_owned()],
)
.await
.is_err(),
"total metadata loss must not be accepted as ownership in another pool"
);
for pool in 0..2 {
assert!(
!root
.path()
.join(format!("pool{pool}-disk0"))
.join(RUSTFS_META_BUCKET)
.join(&object)
.exists(),
"missing required configuration must not be recreated with defaults"
);
}
shutdown.cancel();
}
#[tokio::test]
#[serial_test::serial]
async fn absence_proof_requires_every_selected_pool() {
+24 -6
View File
@@ -4090,7 +4090,7 @@ mod tests {
.collect::<Vec<_>>();
for disk in &disks {
disk.close().await.expect("fault injection should stop per-disk monitoring");
disk.force_runtime_state_for_test(crate::disk::health_state::RuntimeDriveHealthState::Offline);
disk.force_offline_for_test();
}
// Sets has an independent endpoint monitor that renews missing slots.
@@ -4127,7 +4127,7 @@ mod tests {
.collect::<Vec<_>>();
for disk in &disks {
disk.close().await.expect("fault injection should stop per-disk monitoring");
disk.force_runtime_state_for_test(crate::disk::health_state::RuntimeDriveHealthState::Offline);
disk.force_offline_for_test();
}
set.connect_disks().await;
for disk in &disks {
@@ -10052,9 +10052,18 @@ mod tests {
recovered_transition_version.clone(),
)))
.await;
let transaction_stats = recover_transition_transaction_records(store.clone(), 100, None)
let transaction_stats = {
let _proof = crate::services::notification_sys::install_current_remote_version_state_fleet_proof_for_test();
temp_env::async_with_vars(
[
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE, Some("true")),
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED, Some("true")),
],
recover_transition_transaction_records(store.clone(), 100, None),
)
.await
.expect("transition recovery should advance and consume the migrated transaction before completion");
.expect("transition recovery should advance and consume the migrated transaction before completion")
};
backend.set_transition_candidate_probe_override(None).await;
assert_eq!(
(
@@ -21938,9 +21947,18 @@ mod tests {
expected_removes.push((transaction.remote_object, put_version));
}
let stats = recover_transition_transaction_records(store.clone(), 100, None)
let stats = {
let _proof = crate::services::notification_sys::install_current_remote_version_state_fleet_proof_for_test();
temp_env::async_with_vars(
[
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE, Some("true")),
(rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED, Some("true")),
],
recover_transition_transaction_records(store.clone(), 100, None),
)
.await
.expect("transition transaction recovery should run");
.expect("transition transaction recovery should run")
};
assert_eq!((stats.scanned, stats.recovered, stats.retained, stats.failed), (3, 3, 0, 0));
assert_eq!(transition_transaction_record_count(store.clone()).await, 0);
+67 -2
View File
@@ -66,7 +66,7 @@ use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::duplex;
@@ -341,6 +341,18 @@ pub struct ListPathOptions {
pub cursor_generation: Option<String>,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
/// Shared by the collector and raw producers to preserve bounded-walk
/// completion when filtering removes all entries from a batch.
pub producer_limit_reached: Option<Arc<AtomicBool>>,
}
fn ensure_producer_limit_state(options: &mut ListPathOptions) -> Arc<AtomicBool> {
let state = options
.producer_limit_reached
.clone()
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
options.producer_limit_reached = Some(state.clone());
state
}
async fn can_skip_hidden_prefix_check(options: &ListPathOptions) -> bool {
@@ -4304,6 +4316,7 @@ impl ECStore {
// cancel channel
let cancel = CancellationToken::new();
let _cancel_guard = cancel.clone().drop_guard();
ensure_producer_limit_state(&mut o);
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
@@ -5047,6 +5060,15 @@ async fn gather_results(
}
}
// A producer can close its stream after exhausting its own scan budget
// before this collector reaches its output limit. In that case the input
// channel closing is not authoritative EOF: the caller must advertise a
// continuation page even when every remaining entry was filtered out.
let producer_limit_reached = opts
.producer_limit_reached
.as_ref()
.is_some_and(|reached| reached.load(Ordering::Acquire));
// finish not full, return eof
let filtered = scanned_entries.saturating_sub(candidate_entries);
if let Some(started) = gather_started {
@@ -5083,7 +5105,7 @@ async fn gather_results(
o: MetaCacheEntries(entries),
..Default::default()
}),
err: Some(rustfs_filemeta::Error::Unexpected),
err: (!producer_limit_reached).then_some(rustfs_filemeta::Error::Unexpected),
})
.await
.is_err()
@@ -5734,6 +5756,7 @@ impl Sets {
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
let (sender, recv) = mpsc::channel(o.limit as usize);
ensure_producer_limit_state(&mut o);
let sets = self.clone();
let opts = o.clone();
let cancel_rx1 = cancel.clone();
@@ -6777,6 +6800,7 @@ impl SetDisks {
let (err_tx, mut err_rx) = broadcast::channel::<Arc<Error>>(1);
let (sender, recv) = mpsc::channel(o.limit as usize);
ensure_producer_limit_state(&mut o);
let set = self.clone();
let opts = o.clone();
let cancel_rx1 = cancel.clone();
@@ -7098,6 +7122,7 @@ impl SetDisks {
forward_to: opts.marker,
min_disks: raw_min_disks,
per_disk_limit: limit,
producer_limit_reached: opts.producer_limit_reached.clone(),
// A foreground listing is bounded by lack of drive progress (the walk
// stall timeout) and by the page limit, never by how long a healthy
// walk takes — a large prefix on slow media is not a fault (#4644).
@@ -7328,6 +7353,7 @@ mod test {
};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
@@ -8347,6 +8373,45 @@ mod test {
assert!(!cancel.is_cancelled());
}
#[tokio::test]
async fn list_path_gather_results_preserves_bounded_producer_after_filtering() {
let (entry_tx, entry_rx) = mpsc::channel(4);
let (result_tx, mut result_rx) = mpsc::channel(1);
let cancel = CancellationToken::new();
let producer_limit_reached = Arc::new(AtomicBool::new(true));
entry_tx
.send(test_meta_entry("outside-prefix"))
.await
.expect("filtered test entry should be queued");
drop(entry_tx);
let handle = tokio::spawn(gather_results(
cancel,
ListPathOptions {
bucket: "bucket".to_owned(),
prefix: "requested/".to_owned(),
limit: 8,
incl_deleted: true,
producer_limit_reached: Some(producer_limit_reached),
..Default::default()
},
entry_rx,
result_tx,
));
let result = result_rx.recv().await.expect("bounded producer result should be delivered");
assert!(result.entries.expect("entries should be present").entries().is_empty());
assert!(result.err.is_none(), "bounded producer must not be reported as EOF");
assert_eq!(
handle
.await
.expect("gather task should not panic")
.expect("gather should succeed"),
GatherResultsState::InputClosed
);
}
/// A-1 guard (rustfs/backlog#1306): pin that a *successful* send is never
/// misclassified as `ConsumerGone`. With the receiver alive, gather_results
/// must return the real drain state and deliver the payload carrying the
+1 -1
View File
@@ -426,8 +426,8 @@ mod bucket_fence;
pub(crate) use bucket::await_bucket_namespace_operation;
pub use bucket_fence::BucketIncarnationFenceGuard;
mod heal;
pub(crate) use heal::bucket_heal_scope;
pub use heal::{HealObjectAbsenceProof, HealObjectStorageResult};
pub(crate) use heal::{bucket_heal_scope, bucket_heal_scope_for_object};
mod heal_walk;
pub use heal_walk::HealWalkVersion;
mod init;
+162
View File
@@ -9124,6 +9124,168 @@ mod tests {
assert_eq!(dirs.len(), 16);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn current_marker_delete_ack_removes_stale_rejoined_copy() {
let bucket = "current-marker-stale-rejoin";
let object = "marker.bin";
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_dirs, original) = make_local_set_disks_with_ctx(16, 4, ctx.clone()).await;
let store = Arc::new(new_prepared_reader_test_store_with_ctx(&[original], ctx).await);
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.handle_make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create versioned bucket");
let mut history = Vec::new();
for value in 1..=3_u8 {
let payload = vec![value; 4097];
let version = store
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(payload.clone()),
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("write the historical data versions")
.version_id
.expect("historical version must have an explicit identity");
history.push((version, payload));
}
let marker = store
.delete_object(
bucket,
object,
ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("create current marker")
.version_id
.expect("marker has explicit version");
let current = store
.bucket_incarnation_id_from_disk(bucket)
.await
.expect("bucket incarnation");
let set = &store.pools[0].disk_set[0];
let before = set.disks.read().await.clone();
for slot in 12..16 {
set.disks.write().await[slot] = None;
}
store
.delete_object(
bucket,
object,
ObjectOptions {
versioned: true,
version_id: Some(marker.to_string()),
..Default::default()
},
)
.await
.expect("acknowledged delete should remove the marker from the online quorum");
*set.disks.write().await = before.clone();
for disk in before.iter().take(12).flatten() {
assert!(matches!(
disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default())
.await,
Err(crate::disk::error::DiskError::FileVersionNotFound | crate::disk::error::DiskError::FileNotFound)
));
}
for disk in before.iter().skip(12).flatten() {
assert!(
disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default())
.await
.is_ok(),
"offline member must retain the stale current marker"
);
}
let healed = store
.heal_object_at_incarnation(
bucket,
object,
&marker.to_string(),
current,
&rustfs_heal_contracts::heal_channel::HealOpts {
remove: true,
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("stale current marker heal should complete");
assert!(healed.error.is_none(), "stale current marker heal: {:?}", healed.error);
assert!(healed.absence.as_ref().is_some_and(|proof| proof.removed));
for disk in before.iter().flatten() {
assert!(matches!(
disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default())
.await,
Err(crate::disk::error::DiskError::FileVersionNotFound | crate::disk::error::DiskError::FileNotFound)
));
}
let replay = store
.heal_object_at_incarnation(
bucket,
object,
&marker.to_string(),
current,
&rustfs_heal_contracts::heal_channel::HealOpts {
remove: true,
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
..Default::default()
},
)
.await
.expect("replay the exact removed marker identity");
assert!(replay.error.is_none(), "exact marker replay: {:?}", replay.error);
let absence = replay.absence.expect("exact replay must prove authoritative absence");
assert!(!absence.removed, "replay must not claim another physical repair");
assert_eq!(absence.bucket_incarnation_id, current);
assert_eq!(absence.version_id, marker.to_string());
let listed = store
.clone()
.inner_list_object_versions(bucket, "", None, None, None, 100)
.await
.expect("list surviving versions after marker cleanup");
assert_eq!(listed.objects.len(), history.len(), "an absent marker is not enumerable in a new walk");
assert!(listed.objects.iter().all(|info| !info.delete_marker));
for (version, expected) in history {
assert!(listed.objects.iter().any(|info| info.version_id == Some(version)));
let mut reader = store
.handle_get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(version.to_string()),
..Default::default()
},
)
.await
.expect("historical data must remain readable after cleanup and replay");
let mut actual = Vec::new();
reader
.stream
.read_to_end(&mut actual)
.await
.expect("read all historical bytes");
assert_eq!(actual, expected);
}
}
#[tokio::test]
async fn multipool_delete_marker_stays_with_existing_versions() {
let bucket = "multipool-marker-routing";
+40 -2
View File
@@ -161,14 +161,52 @@ impl ECStore {
"incarnation-bound rename requires namespace locking and a non-nil identity",
));
}
let bucket = if destination.0 == RUSTFS_META_BUCKET {
super::heal::bucket_metadata_owner(destination.1).ok_or(DiskError::FileAccessDenied)?
} else {
destination.0
};
let guard = Arc::new(
self.acquire_bucket_incarnation_fence(destination.0, expected)
self.acquire_bucket_incarnation_fence(bucket, expected)
.await
.map_err(|error| DiskError::other(error.to_string()))?,
);
if guard.is_lock_lost() {
return Err(DiskError::other("bucket heal incarnation fence was lost"));
}
let transaction = if destination.0 == RUSTFS_META_BUCKET {
// The receiver must retain the config fence after an RPC timeout.
// A delayed request must also reject a newer config in the same
// bucket incarnation before publishing its older repair payload.
let transaction = crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket)
.await
.map_err(|error| DiskError::other(error.to_string()))?;
let (current, _) = self
.get_pool_info_for_delete_marker(
destination.0,
destination.1,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.map_err(|error| DiskError::other(error.to_string()))?;
let current = current.object_info;
if transaction.is_lock_lost()
|| guard.is_lock_lost()
|| fi.mod_time.is_none()
|| current.mod_time != fi.mod_time
|| current.data_dir != fi.data_dir
|| current.size != fi.size
|| current.etag != fi.get_etag()
{
return Err(DiskError::FileAccessDenied);
}
Some(transaction)
} else {
None
};
rename_local_data_with_ctx(
&self.ctx,
disk_ref,
@@ -176,7 +214,7 @@ impl ECStore {
fi,
destination,
RenameDataGuards {
external_guard: Some(guard),
external_guard: Some(Arc::new((guard, transaction))),
..Default::default()
},
)
+41
View File
@@ -907,6 +907,21 @@ impl ErasureSetHealer {
}
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
let targets = if self.pool_metadata_target_endpoints.is_empty() {
self.target_endpoints.as_ref()
} else {
self.pool_metadata_target_endpoints.as_ref()
};
if self.replacement_task_id.is_some() || (!self.heal_opts.dry_run && self.heal_opts.recreate && !targets.is_empty()) {
self.verify_replacement_identity_fence("bucket metadata").await?;
// Recheck even resumed buckets: their user-object cursor does not
// prove that the replacement holds the internal bucket records.
for bucket in buckets {
self.storage
.heal_replacement_bucket_metadata(bucket, &self.heal_opts, targets)
.await?;
}
}
self.heal_replacement_pool_metadata(
set_disk_id,
&mut ErasureSetPassCounters {
@@ -2267,6 +2282,8 @@ mod resume_loop_tests {
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
pool_metadata_placement: Mutex<Option<ReplacementCommitEvidence>>,
bucket_metadata_calls: Mutex<Vec<String>>,
bucket_metadata_failure: AtomicBool,
fail_listing: AtomicBool,
fail_listing_buckets: Mutex<HashSet<String>>,
}
@@ -2462,6 +2479,13 @@ mod resume_loop_tests {
None => Ok(true),
}
}
async fn heal_replacement_bucket_metadata(&self, bucket: &str, _opts: &HealOpts, _targets: &[String]) -> Result<()> {
self.bucket_metadata_calls.lock().unwrap().push(bucket.to_owned());
if self.bucket_metadata_failure.load(Ordering::SeqCst) {
return Err(Error::Storage(EcstoreError::PreconditionFailed));
}
Ok(())
}
async fn replacement_targets_have_version(
&self,
_bucket: &str,
@@ -3058,6 +3082,18 @@ mod resume_loop_tests {
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
env.storage.bucket_metadata_failure.store(true, Ordering::SeqCst);
assert!(healer.heal_erasure_set(&["b".to_string()], "pool_0_set_0").await.is_err());
let incomplete = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("failed metadata repair must retain replacement intent")
.get_state()
.await;
assert!(!incomplete.completed);
assert_ne!(incomplete.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
assert!(env.storage.calls().is_empty(), "metadata failure must stop completion before pool.bin");
env.storage.bucket_metadata_failure.store(false, Ordering::SeqCst);
let error = healer
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
.await
@@ -3073,6 +3109,11 @@ mod resume_loop_tests {
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
assert_eq!(state.retry_count, 1);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
assert_eq!(
*env.storage.bucket_metadata_calls.lock().unwrap(),
vec!["b", "b"],
"resuming a completed user scan must retry bucket metadata"
);
}
#[tokio::test]
+87 -52
View File
@@ -72,6 +72,41 @@ const LEGACY_ROOT_HEAL_PATH: &str = ".";
const MAX_RECOVERABLE_HEAL_RETRIES: u32 = 3;
const MAX_RECOVERABLE_HEAL_RETRY_DELAY: Duration = Duration::from_secs(30);
const RESUME_GC_INTERVAL: Duration = Duration::from_secs(60 * 60);
const BEST_EFFORT_QUEUE_RESERVE_PERCENT: usize = 10;
const BEST_EFFORT_SOURCE_COUNT: usize = 3;
fn is_best_effort_source(source: HealRequestSource) -> bool {
matches!(
source,
HealRequestSource::Scanner | HealRequestSource::AutoHeal | HealRequestSource::ReadRepair
)
}
fn best_effort_source_quota_index(source: HealRequestSource) -> Option<usize> {
match source {
HealRequestSource::Scanner => Some(0),
HealRequestSource::ReadRepair => Some(1),
HealRequestSource::AutoHeal => Some(2),
_ => None,
}
}
fn best_effort_queue_reserve(queue_capacity: usize) -> usize {
match queue_capacity {
0..=2 => 0,
capacity => (capacity.saturating_mul(BEST_EFFORT_QUEUE_RESERVE_PERCENT) / 100).max(BEST_EFFORT_SOURCE_COUNT),
}
}
fn best_effort_source_reserves(queue_capacity: usize) -> [usize; BEST_EFFORT_SOURCE_COUNT] {
let reserve = best_effort_queue_reserve(queue_capacity);
if reserve == 0 {
return [0; BEST_EFFORT_SOURCE_COUNT];
}
let base = reserve / BEST_EFFORT_SOURCE_COUNT;
let remainder = reserve % BEST_EFFORT_SOURCE_COUNT;
[base + usize::from(remainder > 0), base + usize::from(remainder > 1), base]
}
// Admission/scheduler outcomes for per-object requests (Object/Metadata/
// ECDecode) log via demote_to_debug_when! — MRF, autoheal, and scanner
@@ -965,11 +1000,7 @@ struct HealQueueContext<'a> {
impl HealManager {
fn classify_full_admission(request: &HealRequest, config: &HealConfig) -> HealAdmissionResult {
let best_effort_source = matches!(
request.source,
HealRequestSource::Scanner | HealRequestSource::AutoHeal | HealRequestSource::ReadRepair
);
if best_effort_source || (request.priority == HealPriority::Low && config.low_priority_drop_when_full) {
if request.priority == HealPriority::Low && config.low_priority_drop_when_full {
HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)
} else {
HealAdmissionResult::Full
@@ -980,32 +1011,6 @@ impl HealManager {
queue_len.saturating_mul(100).checked_div(queue_capacity).unwrap_or(0)
}
fn classify_pressure_admission(
request: &HealRequest,
queue_len: usize,
queue_capacity: usize,
) -> Option<HealAdmissionResult> {
if request.force_start || queue_capacity == 0 {
return None;
}
let queue_usage_pct = Self::queue_usage_pct(queue_len, queue_capacity);
if queue_usage_pct < 80 {
return None;
}
match request.source {
HealRequestSource::ReadRepair => Some(HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped)),
HealRequestSource::Scanner if request.priority == HealPriority::Low => {
Some(HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped))
}
HealRequestSource::AutoHeal if queue_usage_pct >= 95 => {
Some(HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped))
}
_ => None,
}
}
fn duplicate_admission_for_request(request: &HealRequest, config: &HealConfig) -> HealAdmissionResult {
if request.priority == HealPriority::Low && !config.low_priority_merge_enable {
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped)
@@ -1156,6 +1161,8 @@ impl HealManager {
publish_heal_queue_length(queue);
let queue_capacity = config.queue_size;
let per_object_request = request.heal_type.is_per_object();
let best_effort_reserve = best_effort_queue_reserve(queue_capacity);
let best_effort_source_reserves = best_effort_source_reserves(queue_capacity);
if queue_len >= queue_capacity && !request.force_start {
if Self::can_displace_queued_work(&request)
@@ -1243,27 +1250,55 @@ impl HealManager {
return HealAdmissionDecision::new(admission);
}
if let Some(admission) = Self::classify_pressure_admission(&request, queue_len, queue_capacity) {
Self::record_admission_metric(request.source, admission, context);
if let HealAdmissionResult::Dropped(reason) = admission {
debug!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
priority = ?request.priority,
source = request.source.as_str(),
context,
queue_len,
queue_capacity,
queue_usage_pct = Self::queue_usage_pct(queue_len, queue_capacity),
reason = reason.as_str(),
result = "dropped_pressure",
"Heal queue request dropped under pressure"
);
}
return HealAdmissionDecision::new(admission);
if !is_best_effort_source(request.source)
&& request.priority <= HealPriority::Normal
&& !request.force_start
&& best_effort_reserve > 0
&& queue_len >= queue_capacity.saturating_sub(best_effort_reserve)
{
Self::record_admission_metric(request.source, HealAdmissionResult::Full, context);
demote_to_debug_when!(per_object_request, warn, target: "rustfs::heal::manager", {
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
priority = ?request.priority,
source = request.source.as_str(),
context,
queue_len,
queue_capacity,
reserved_for_best_effort = best_effort_reserve,
result = "rejected_for_reserved_capacity",
"Normal heal request deferred to preserve best-effort capacity"
});
return HealAdmissionDecision::new(HealAdmissionResult::Full);
}
if is_best_effort_source(request.source)
&& request.priority <= HealPriority::Normal
&& !request.force_start
&& best_effort_reserve > 0
&& queue_len >= queue_capacity.saturating_sub(best_effort_reserve)
&& let Some(source_index) = best_effort_source_quota_index(request.source)
&& queue.best_effort_source_count(request.source) >= best_effort_source_reserves[source_index]
{
Self::record_admission_metric(request.source, HealAdmissionResult::Full, context);
demote_to_debug_when!(per_object_request, warn, target: "rustfs::heal::manager", {
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
priority = ?request.priority,
source = request.source.as_str(),
context,
queue_len,
queue_capacity,
reserved_for_best_effort = best_effort_reserve,
source_quota = best_effort_source_reserves[source_index],
result = "rejected_for_source_quota",
"Best-effort source deferred to preserve per-source capacity"
});
return HealAdmissionDecision::new(HealAdmissionResult::Full);
}
if queue_capacity > 0 {
+147
View File
@@ -34,6 +34,33 @@ pub(super) struct PriorityHealQueue {
pub(super) sequence: u64,
/// Deduplication index for queued requests
pub(super) dedup_keys: HashMap<String, DedupKeyEntry>,
pub(super) best_effort_queued: usize,
pub(super) best_effort_queued_by_source: [usize; 3],
/// Number of MRF/Normal tasks dispatched since a best-effort task ran.
/// This is kept with the queue so event-driven scheduler wakeups cannot
/// reset the fairness budget between cycles.
pub(super) mrf_normal_dispatches_since_best_effort: usize,
pub(super) last_best_effort_source: Option<HealRequestSource>,
}
const MRF_NORMAL_DISPATCH_QUANTUM: usize = 4;
fn best_effort_source_index(source: HealRequestSource) -> Option<usize> {
match source {
HealRequestSource::Scanner => Some(0),
HealRequestSource::ReadRepair => Some(1),
HealRequestSource::AutoHeal => Some(2),
_ => None,
}
}
fn best_effort_source_order(last: Option<HealRequestSource>) -> [usize; 3] {
match last {
Some(HealRequestSource::Scanner) => [1, 2, 0],
Some(HealRequestSource::ReadRepair) => [2, 0, 1],
Some(HealRequestSource::AutoHeal) => [0, 1, 2],
_ => [0, 1, 2],
}
}
/// Wrapper for heap items to implement proper ordering
@@ -264,6 +291,10 @@ impl PriorityHealQueue {
heap: BinaryHeap::new(),
sequence: 0,
dedup_keys: HashMap::new(),
best_effort_queued: 0,
best_effort_queued_by_source: [0; 3],
mrf_normal_dispatches_since_best_effort: 0,
last_best_effort_source: None,
}
}
@@ -271,9 +302,11 @@ impl PriorityHealQueue {
self.heap.len()
}
#[cfg(test)]
pub(super) fn pop_next(&mut self) -> Option<HealRequest> {
self.heap.pop().map(|item| {
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &item.dedup_key);
self.remove_best_effort_count(item.request.source);
item.request
})
}
@@ -301,12 +334,19 @@ impl PriorityHealQueue {
})
.refcount += 1;
self.sequence += 1;
let source = request.source;
self.heap.push(PriorityQueueItem {
priority: request.priority,
sequence: self.sequence,
dedup_key: key,
request,
});
if is_best_effort_source(source) {
self.best_effort_queued += 1;
if let Some(index) = best_effort_source_index(source) {
self.best_effort_queued_by_source[index] += 1;
}
}
QueuePushOutcome::Accepted
}
@@ -356,6 +396,7 @@ impl PriorityHealQueue {
let displaced = displaced.map(|item| {
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &item.dedup_key);
self.remove_best_effort_count(item.request.source);
self.refresh_dedup_representative(&item.dedup_key);
item.request
});
@@ -400,6 +441,7 @@ impl PriorityHealQueue {
pub(super) fn pop(&mut self) -> Option<HealRequest> {
self.heap.pop().map(|item| {
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &item.dedup_key);
self.remove_best_effort_count(item.request.source);
item.request
})
}
@@ -429,12 +471,115 @@ impl PriorityHealQueue {
(
selected.map(|item| {
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &item.dedup_key);
self.remove_best_effort_count(item.request.source);
item.request
}),
skipped,
)
}
pub(super) fn pop_runnable_with_fairness<F, G>(&mut self, can_run: F, skip_label: G) -> (Option<HealRequest>, Vec<String>)
where
F: Fn(&HealRequest) -> bool,
G: Fn(&HealRequest) -> Option<String>,
{
if self.mrf_normal_dispatches_since_best_effort < MRF_NORMAL_DISPATCH_QUANTUM {
let (chosen, skipped) = self.pop_runnable_with_skips(can_run, skip_label);
if let Some(request) = chosen.as_ref() {
self.record_dispatch(request);
}
return (chosen, skipped);
}
if self.best_effort_queued == 0 {
self.mrf_normal_dispatches_since_best_effort = 0;
let (chosen, skipped) = self.pop_runnable_with_skips(can_run, skip_label);
if let Some(request) = chosen.as_ref() {
self.record_dispatch(request);
}
return (chosen, skipped);
}
let mut items = std::mem::take(&mut self.heap).into_vec();
let mut skipped = Vec::new();
let mut first_runnable: Option<usize> = None;
let mut best_effort_candidates: [Option<usize>; 3] = [None; 3];
for (index, item) in items.iter().enumerate() {
if !can_run(&item.request) {
if let Some(label) = skip_label(&item.request) {
skipped.push(label);
}
continue;
}
if first_runnable.is_none_or(|current| item.cmp(&items[current]).is_gt()) {
first_runnable = Some(index);
}
let Some(source_index) = best_effort_source_index(item.request.source) else {
continue;
};
if best_effort_candidates[source_index].is_none_or(|current| {
item.priority > items[current].priority
|| (item.priority == items[current].priority && item.sequence < items[current].sequence)
}) {
best_effort_candidates[source_index] = Some(index);
}
}
let chosen_index = first_runnable.filter(|index| {
let item = &items[*index];
item.priority == HealPriority::Normal
&& matches!(item.request.source, HealRequestSource::Mrf | HealRequestSource::Internal)
});
let best_effort_priority = best_effort_candidates
.into_iter()
.flatten()
.map(|index| items[index].priority)
.max();
let chosen_index = chosen_index
.and_then(|_| {
let priority = best_effort_priority?;
best_effort_source_order(self.last_best_effort_source)
.into_iter()
.find_map(|source| best_effort_candidates[source].filter(|index| items[*index].priority == priority))
})
.or(first_runnable);
let chosen = chosen_index.map(|index| items.swap_remove(index));
self.heap = BinaryHeap::from(items);
let chosen = chosen.map(|item| {
let request = item.request;
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &item.dedup_key);
self.remove_best_effort_count(request.source);
self.record_dispatch(&request);
request
});
(chosen, skipped)
}
fn record_dispatch(&mut self, request: &HealRequest) {
if is_best_effort_source(request.source) {
self.mrf_normal_dispatches_since_best_effort = 0;
self.last_best_effort_source = Some(request.source);
} else if matches!(request.source, HealRequestSource::Mrf | HealRequestSource::Internal)
&& request.priority == HealPriority::Normal
{
self.mrf_normal_dispatches_since_best_effort = self.mrf_normal_dispatches_since_best_effort.saturating_add(1);
}
}
fn remove_best_effort_count(&mut self, source: HealRequestSource) {
if is_best_effort_source(source) {
self.best_effort_queued = self.best_effort_queued.saturating_sub(1);
if let Some(index) = best_effort_source_index(source) {
self.best_effort_queued_by_source[index] = self.best_effort_queued_by_source[index].saturating_sub(1);
}
}
}
pub(super) fn best_effort_source_count(&self, source: HealRequestSource) -> usize {
best_effort_source_index(source).map_or(0, |index| self.best_effort_queued_by_source[index])
}
fn restore_deferred_items(&mut self, deferred: Vec<PriorityQueueItem>) {
if deferred.is_empty() {
return;
@@ -590,6 +735,7 @@ impl PriorityHealQueue {
if removed.is_none() && item.request.id == request_id {
let key = item.dedup_key.clone();
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &key);
self.remove_best_effort_count(item.request.source);
affected_key = Some(key);
removed = Some(item.request);
} else {
@@ -615,6 +761,7 @@ impl PriorityHealQueue {
while let Some(item) = self.heap.pop() {
if should_remove(&item.request) {
Self::decrement_or_remove_dedup_key(&mut self.dedup_keys, &item.dedup_key);
self.remove_best_effort_count(item.request.source);
affected_keys.push(item.dedup_key);
removed.push(item.request);
} else {
+2 -2
View File
@@ -150,7 +150,7 @@ impl HealManager {
for _ in 0..available_slots {
let selected_request = if config.set_bulkhead_enable || mainline_pressure.is_some() {
let max_concurrent_per_set = config.max_concurrent_per_set;
let (selected_request, skipped_sets) = queue.pop_runnable_with_skips(
let (selected_request, skipped_sets) = queue.pop_runnable_with_fairness(
|request| {
let set_allowed = !config.set_bulkhead_enable
|| can_schedule_request(request, &running_per_set, max_concurrent_per_set);
@@ -164,7 +164,7 @@ impl HealManager {
}
selected_request
} else {
queue.pop_next()
queue.pop_runnable_with_fairness(|_| true, |_| None).0
};
if let Some(mut request) = selected_request {
+425 -8
View File
@@ -1488,6 +1488,164 @@ fn test_priority_queue_ordering() {
assert_eq!(queue.len(), 0);
}
#[test]
fn test_priority_queue_fairness_gives_best_effort_bounded_service() {
let mut queue = PriorityHealQueue::new();
for index in 0..5 {
assert_eq!(
queue.push(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf)),
QueuePushOutcome::Accepted
);
}
assert_eq!(
queue.push(bucket_request("scanner", HealPriority::Low, HealRequestSource::Scanner)),
QueuePushOutcome::Accepted
);
for _ in 0..4 {
let (request, skipped) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert!(skipped.is_empty());
assert_eq!(
request.expect("MRF request should run before the fairness quantum").source,
HealRequestSource::Mrf
);
}
let (request, skipped) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert!(skipped.is_empty());
assert_eq!(
request
.expect("best-effort work must get a bounded service opportunity")
.source,
HealRequestSource::Scanner
);
}
#[test]
fn test_priority_queue_fairness_covers_internal_normal_background_work() {
let mut queue = PriorityHealQueue::new();
for index in 0..5 {
assert_eq!(
queue.push(bucket_request(
&format!("internal-{index}"),
HealPriority::Normal,
HealRequestSource::Internal
)),
QueuePushOutcome::Accepted
);
}
assert_eq!(
queue.push(bucket_request("scanner", HealPriority::Low, HealRequestSource::Scanner)),
QueuePushOutcome::Accepted
);
for _ in 0..4 {
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(request.expect("internal background work should run").source, HealRequestSource::Internal);
}
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(
request.expect("Scanner should get a service opportunity").source,
HealRequestSource::Scanner
);
}
#[test]
fn test_priority_queue_fairness_round_robins_best_effort_sources() {
let mut queue = PriorityHealQueue::new();
for index in 0..8 {
assert_eq!(
queue.push(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf)),
QueuePushOutcome::Accepted
);
}
for (bucket, source) in [
("scanner", HealRequestSource::Scanner),
("read-repair", HealRequestSource::ReadRepair),
("auto-heal", HealRequestSource::AutoHeal),
] {
assert_eq!(queue.push(bucket_request(bucket, HealPriority::Low, source)), QueuePushOutcome::Accepted);
}
for _ in 0..4 {
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(request.expect("first MRF quantum should run").source, HealRequestSource::Mrf);
}
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(
request.expect("Scanner should get the first best-effort turn").source,
HealRequestSource::Scanner
);
for _ in 0..4 {
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(request.expect("second MRF quantum should run").source, HealRequestSource::Mrf);
}
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(
request.expect("ReadRepair should get the next best-effort turn").source,
HealRequestSource::ReadRepair
);
}
#[test]
fn test_priority_queue_fairness_keeps_best_effort_priority_order() {
let mut queue = PriorityHealQueue::new();
for index in 0..4 {
assert_eq!(
queue.push(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf)),
QueuePushOutcome::Accepted
);
}
assert_eq!(
queue.push(bucket_request("scanner", HealPriority::Low, HealRequestSource::Scanner)),
QueuePushOutcome::Accepted
);
assert_eq!(
queue.push(bucket_request("read-repair", HealPriority::Normal, HealRequestSource::ReadRepair)),
QueuePushOutcome::Accepted
);
for _ in 0..4 {
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(request.expect("MRF request should run").source, HealRequestSource::Mrf);
}
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(
request.expect("higher-priority best-effort work should run first").source,
HealRequestSource::ReadRepair
);
}
#[test]
fn test_priority_queue_fairness_skips_blocked_best_effort_source() {
let mut queue = PriorityHealQueue::new();
for index in 0..4 {
assert_eq!(
queue.push(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf)),
QueuePushOutcome::Accepted
);
}
assert_eq!(
queue.push(bucket_request("scanner", HealPriority::Low, HealRequestSource::Scanner)),
QueuePushOutcome::Accepted
);
assert_eq!(
queue.push(bucket_request("read-repair", HealPriority::Low, HealRequestSource::ReadRepair)),
QueuePushOutcome::Accepted
);
for _ in 0..4 {
let (request, _) = queue.pop_runnable_with_fairness(|_| true, |_| None);
assert_eq!(request.expect("MRF request should run").source, HealRequestSource::Mrf);
}
let (request, _) = queue.pop_runnable_with_fairness(|request| request.source != HealRequestSource::Scanner, |_| None);
assert_eq!(
request.expect("runnable ReadRepair should bypass blocked Scanner").source,
HealRequestSource::ReadRepair
);
}
#[test]
fn test_priority_queue_fifo_same_priority() {
let mut queue = PriorityHealQueue::new();
@@ -4500,7 +4658,7 @@ async fn test_displacing_registered_mrf_task_drops_notice_ownership() {
}
#[tokio::test]
async fn test_submit_heal_request_drops_read_repair_under_pressure() {
async fn test_submit_heal_request_admits_read_repair_under_pressure() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new_without_root_recovery_for_test(
storage,
@@ -4510,7 +4668,7 @@ async fn test_submit_heal_request_drops_read_repair_under_pressure() {
}),
);
for index in 0..8 {
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(
@@ -4529,12 +4687,12 @@ async fn test_submit_heal_request_drops_read_repair_under_pressure() {
.await
.expect("read repair admission should return a result");
assert_eq!(admission, HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped));
assert_eq!(admission, HealAdmissionResult::Accepted);
assert_eq!(manager.get_queue_length().await, 8);
}
#[tokio::test]
async fn test_submit_heal_request_drops_low_scanner_under_pressure() {
async fn test_submit_heal_request_admits_low_scanner_under_pressure() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new_without_root_recovery_for_test(
storage,
@@ -4544,7 +4702,7 @@ async fn test_submit_heal_request_drops_low_scanner_under_pressure() {
}),
);
for index in 0..8 {
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(
@@ -4563,10 +4721,220 @@ async fn test_submit_heal_request_drops_low_scanner_under_pressure() {
.await
.expect("scanner admission should return a result");
assert_eq!(admission, HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped));
assert_eq!(admission, HealAdmissionResult::Accepted);
assert_eq!(manager.get_queue_length().await, 8);
}
#[tokio::test]
async fn test_mrf_admission_preserves_best_effort_queue_reserve() {
let manager = HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage),
Some(HealConfig {
queue_size: 10,
..HealConfig::default()
}),
);
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf,))
.await
.expect("MRF request should be admitted before the reserve threshold"),
HealAdmissionResult::Accepted
);
}
assert_eq!(
manager
.submit_heal_request(bucket_request("mrf-deferred", HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("MRF admission should return a typed result"),
HealAdmissionResult::Full
);
assert_eq!(
manager
.submit_heal_request(bucket_request("internal-deferred", HealPriority::Normal, HealRequestSource::Internal))
.await
.expect("ordinary background admission should return a typed result"),
HealAdmissionResult::Full
);
assert_eq!(
manager
.submit_heal_request(bucket_request("scanner-reserved", HealPriority::Low, HealRequestSource::Scanner))
.await
.expect("Scanner should consume the reserved slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("read-repair-reserved", HealPriority::Low, HealRequestSource::ReadRepair))
.await
.expect("ReadRepair should consume its reserved slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("auto-heal-reserved", HealPriority::Low, HealRequestSource::AutoHeal))
.await
.expect("AutoHeal should consume its reserved slot"),
HealAdmissionResult::Accepted
);
assert_eq!(manager.get_queue_length().await, 10);
}
#[tokio::test]
async fn test_small_queue_keeps_per_source_best_effort_slots() {
let manager = HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage),
Some(HealConfig {
queue_size: 9,
..HealConfig::default()
}),
);
for index in 0..6 {
assert_eq!(
manager
.submit_heal_request(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("MRF request should be admitted before the per-source reserve"),
HealAdmissionResult::Accepted
);
}
assert_eq!(
manager
.submit_heal_request(bucket_request("mrf-deferred", HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("MRF admission should return a typed result"),
HealAdmissionResult::Full
);
assert_eq!(
manager
.submit_heal_request(bucket_request("scanner-reserved", HealPriority::Low, HealRequestSource::Scanner))
.await
.expect("Scanner should consume its reserved slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("read-repair-reserved", HealPriority::Low, HealRequestSource::ReadRepair))
.await
.expect("ReadRepair should consume its reserved slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("auto-heal-reserved", HealPriority::Low, HealRequestSource::AutoHeal))
.await
.expect("AutoHeal should consume its reserved slot"),
HealAdmissionResult::Accepted
);
}
#[tokio::test]
async fn test_queue_size_four_keeps_mrf_and_each_best_effort_source() {
let manager = HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage),
Some(HealConfig {
queue_size: 4,
..HealConfig::default()
}),
);
assert_eq!(
manager
.submit_heal_request(bucket_request("mrf", HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("MRF should retain one queue slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("scanner", HealPriority::Low, HealRequestSource::Scanner))
.await
.expect("Scanner should use its source slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("read-repair", HealPriority::Low, HealRequestSource::ReadRepair))
.await
.expect("ReadRepair should use its source slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("auto-heal", HealPriority::Low, HealRequestSource::AutoHeal))
.await
.expect("AutoHeal should use its source slot"),
HealAdmissionResult::Accepted
);
}
#[tokio::test]
async fn test_high_priority_mrf_bypasses_best_effort_reserve() {
let manager = HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage),
Some(HealConfig {
queue_size: 10,
..HealConfig::default()
}),
);
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("normal MRF request should be admitted before the reserve"),
HealAdmissionResult::Accepted
);
}
assert_eq!(
manager
.submit_heal_request(bucket_request("urgent-mrf", HealPriority::Urgent, HealRequestSource::Mrf))
.await
.expect("urgent MRF admission should return a typed result"),
HealAdmissionResult::Accepted
);
}
#[tokio::test]
async fn test_high_priority_best_effort_bypasses_source_quota() {
let manager = HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage),
Some(HealConfig {
queue_size: 10,
..HealConfig::default()
}),
);
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("normal MRF request should be admitted before the reserve"),
HealAdmissionResult::Accepted
);
}
assert_eq!(
manager
.submit_heal_request(bucket_request("scanner-low", HealPriority::Low, HealRequestSource::Scanner))
.await
.expect("low Scanner request should be admitted into its source slot"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("scanner-high", HealPriority::High, HealRequestSource::Scanner))
.await
.expect("high Scanner retry should bypass its low-priority quota"),
HealAdmissionResult::Accepted
);
}
#[tokio::test]
async fn test_submit_heal_request_accepts_admin_high_under_pressure() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
@@ -4578,7 +4946,7 @@ async fn test_submit_heal_request_accepts_admin_high_under_pressure() {
}),
);
for index in 0..8 {
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(
@@ -4598,7 +4966,56 @@ async fn test_submit_heal_request_accepts_admin_high_under_pressure() {
.expect("admin admission should return a result");
assert_eq!(admission, HealAdmissionResult::Accepted);
assert_eq!(manager.get_queue_length().await, 9);
assert_eq!(manager.get_queue_length().await, 8);
}
#[tokio::test]
async fn test_admin_normal_displaces_best_effort_when_queue_is_full() {
let manager = HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage),
Some(HealConfig {
queue_size: 10,
..HealConfig::default()
}),
);
for index in 0..7 {
assert_eq!(
manager
.submit_heal_request(bucket_request(&format!("mrf-{index}"), HealPriority::Normal, HealRequestSource::Mrf))
.await
.expect("MRF request should be queued"),
HealAdmissionResult::Accepted
);
}
assert_eq!(
manager
.submit_heal_request(bucket_request("scanner", HealPriority::Low, HealRequestSource::Scanner))
.await
.expect("Scanner request should be queued"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("read-repair", HealPriority::Low, HealRequestSource::ReadRepair))
.await
.expect("ReadRepair request should be queued"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("auto-heal", HealPriority::Low, HealRequestSource::AutoHeal))
.await
.expect("AutoHeal request should be queued"),
HealAdmissionResult::Accepted
);
assert_eq!(
manager
.submit_heal_request(bucket_request("admin", HealPriority::Normal, HealRequestSource::Admin))
.await
.expect("admin admission should return a typed result"),
HealAdmissionResult::Accepted
);
}
#[tokio::test]
+43
View File
@@ -532,6 +532,19 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("storage does not support incarnation-bound object healing"))
}
/// Durable MRF repair may request an authoritative unversioned absence proof.
async fn heal_mrf_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
) -> Result<HealStorageObjectResult> {
self.heal_object_at_incarnation(bucket, object, version_id, expected, opts)
.await
}
/// Heal object using ecstore
async fn heal_object(
&self,
@@ -578,6 +591,11 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("replacement pool metadata placement is unsupported"))
}
/// Heal and physically verify bucket configuration on its replacement targets.
async fn heal_replacement_bucket_metadata(&self, _bucket: &str, _opts: &HealOpts, _targets: &[String]) -> Result<()> {
Err(Error::Storage(StorageError::PreconditionFailed))
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
@@ -967,6 +985,24 @@ impl HealStorageAPI for ECStoreHealStorage {
.await)
}
async fn heal_mrf_object_at_incarnation(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
expected: Uuid,
opts: &HealOpts,
) -> Result<HealStorageObjectResult> {
let result = self
.ecstore
.heal_mrf_object_at_incarnation(bucket, object, version_id.unwrap_or_default(), expected, opts)
.await
.map_err(|error| incarnation_storage_error(bucket, expected, error))?;
Ok(self
.object_result_with_receipt(bucket, object, version_id, opts, result, Some(expected))
.await)
}
async fn get_object_meta(&self, bucket: &str, object: &str) -> Result<Option<HealObjectInfo>> {
debug!(
target: "rustfs::heal::storage",
@@ -1564,6 +1600,13 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn heal_replacement_bucket_metadata(&self, bucket: &str, opts: &HealOpts, targets: &[String]) -> Result<()> {
self.ecstore
.heal_replacement_bucket_metadata(bucket, opts, targets)
.await
.map_err(Error::Storage)
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
+77
View File
@@ -298,6 +298,18 @@ impl HealTask {
"Heal object stage entered"
);
let object_size = result.object_size as u64;
let ok_drive_state = DriveState::Ok.to_string();
if self.source == HealRequestSource::Admin
&& !self.options.dry_run
&& !result.after.drives.is_empty()
&& result.after.drives.iter().any(|drive| drive.state != ok_drive_state)
{
return Err(Error::TaskExecutionFailed {
message: format!(
"Heal left one or more drives unhealthy for {bucket}/{object}; retry after the missing drives are restored"
),
});
}
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_OBJECT_RESULT,
@@ -417,6 +429,10 @@ impl HealTask {
/// Recreate missing object (for EC decode scenarios)
async fn recreate_missing_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
if self.source == HealRequestSource::Mrf {
return self.recreate_missing_mrf_object(bucket, object, version_id).await;
}
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_OBJECT_STAGE,
@@ -517,4 +533,65 @@ impl HealTask {
}
}
}
/// Durable MRF responsibilities may complete only with an exact storage proof.
async fn recreate_missing_mrf_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
let heal_opts = HealOpts {
recursive: false,
dry_run: self.options.dry_run,
remove: false,
recreate: true,
scan_mode: HealScanMode::Deep,
update_parity: true,
no_lock: self.options.no_lock,
read_repair: false,
pool: self.options.pool_index,
set: self.options.set_index,
};
let mut expected = self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
let bucket_incarnation_id = self
.outcome_bucket_incarnation_id(bucket, self.options.dry_run)
.await?
.ok_or_else(|| Error::TaskExecutionFailed {
message: format!("Missing bucket incarnation for durable MRF repair {bucket}/{object}"),
})?;
expected.bucket_incarnation_id = Some(bucket_incarnation_id);
let storage_result = self
.await_with_control(self.storage.heal_mrf_object_at_incarnation(
bucket,
object,
version_id,
bucket_incarnation_id,
&heal_opts,
))
.await?;
if let Some(error) = storage_result.error {
return Err(Error::TaskExecutionFailed {
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
});
}
let object_size = storage_result.item.object_size as u64;
let authoritatively_absent = matches!(
storage_result.receipt.as_ref().map(|receipt| &receipt.disposition),
Some(HealObjectDisposition::AuthoritativelyAbsent)
);
if !self.record_verified_storage_receipt(expected, storage_result.receipt).await {
return Err(Error::TaskExecutionFailed {
message: format!("Missing exact storage proof for durable MRF repair {bucket}/{object}"),
});
}
{
let mut progress = self.progress.write().await;
if authoritatively_absent {
progress.update_object_progress(1, 0, 0, 1, 0);
} else {
progress.update_object_progress(1, 1, 0, 0, object_size);
}
}
self.record_result_item(storage_result.item).await;
Ok(())
}
}
+95
View File
@@ -3471,6 +3471,40 @@ async fn test_prefix_heal_lists_and_repairs_objects_under_prefix() {
);
}
#[tokio::test]
async fn admin_object_heal_fails_when_drive_remains_unhealthy() {
let storage = Arc::new(MockStorage::default());
storage.heal_object_outcomes.lock().expect("outcomes").insert(
"object-a".to_string(),
VecDeque::from([MockHealObjectOutcome::UnavailableDrive(DriveState::Missing)]),
);
let mut request = HealRequest::new(
HealType::Object {
bucket: "bucket-a".to_string(),
object: "object-a".to_string(),
version_id: None,
},
HealOptions {
recreate_missing: false,
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::Admin;
let task = HealTask::from_request(request, storage);
let error = task
.execute()
.await
.expect_err("admin heal must not finish with an unhealthy drive");
assert!(error.to_string().contains("left one or more drives unhealthy"));
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.failed, 1);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.execution, HealExecutionOutcome::CompletedWithErrors);
}
#[tokio::test]
async fn test_data_usage_cache_lock_timeout_does_not_fail_object_heal() {
let storage = Arc::new(MockStorage::default());
@@ -3855,6 +3889,67 @@ async fn test_heal_recreate_scanner_non_dir_not_found_fails() {
assert!(matches!(task.get_status().await, HealTaskStatus::Failed { .. }));
}
#[tokio::test]
async fn mrf_recreate_missing_object_records_exact_absence_receipt_with_scope() {
let incarnation = Uuid::new_v4();
let storage = Arc::new(MockStorage {
object_exists: Mutex::new(Some(false)),
bucket_incarnation_id: Mutex::new(Some(incarnation)),
heal_object_receipts: Mutex::new(HashMap::from([(
"deleted.bin".to_string(),
VecDeque::from([HealObjectReceipt {
identity: HealObjectIdentity {
kind: HealObjectKind::Object,
bucket: "bucket-a".to_string(),
object: "deleted.bin".to_string(),
version_id: None,
bucket_incarnation_id: Some(incarnation),
pool_index: Some(2),
set_index: Some(3),
},
disposition: HealObjectDisposition::AuthoritativelyAbsent,
}]),
)])),
..Default::default()
});
let mut request = HealRequest::new(
HealType::Object {
bucket: "bucket-a".to_string(),
object: "deleted.bin".to_string(),
version_id: None,
},
HealOptions {
recreate_missing: true,
pool_index: Some(2),
set_index: Some(3),
timeout: None,
..Default::default()
},
HealPriority::Normal,
);
request.source = HealRequestSource::Mrf;
let task = HealTask::from_request(request, storage.clone());
task.execute()
.await
.expect("a complete MRF absence receipt must complete the task");
let opts = storage.object_heal_opts.lock().unwrap()[0];
assert_eq!(opts.pool, Some(2));
assert_eq!(opts.set, Some(3));
assert!(opts.recreate);
assert_eq!(opts.scan_mode, HealScanMode::Deep);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.unchanged, 1);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.objects.len(), 1);
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::AuthoritativelyAbsent);
assert_eq!(
(outcome.objects[0].identity.pool_index, outcome.objects[0].identity.set_index),
(Some(2), Some(3))
);
}
#[tokio::test]
async fn test_heal_scanner_missing_object_without_recreate_probes_storage() {
let storage = Arc::new(MockStorage {
@@ -36,6 +36,7 @@ use rustfs_heal::heal::{
};
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
use serial_test::serial;
use sha2::{Digest, Sha256};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -722,6 +723,91 @@ mod serial_tests {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn test_recursive_deep_heal_reports_stale_delete_marker_repair() {
let (disk_paths, ecstore, storage) = heal_env().await;
let manager = HealManager::new(
storage.clone(),
Some(HealConfig {
heal_interval: Duration::from_millis(1),
..Default::default()
}),
);
manager.start().await.expect("heal manager should start");
let bucket = "b5-stale-delete-marker-recursive";
let object = "obj.bin";
create_versioned_bucket(&ecstore, bucket).await;
put_unversioned(&ecstore, bucket, "control/object.bin", &versioned_test_data(42)).await;
let historical_data = versioned_test_data(43);
let historical = put_versioned(&ecstore, bucket, object, &historical_data).await;
let target = &disk_paths[1];
let target_meta = xl_meta_path(&object_dir(target, bucket, object));
let stale_meta = std::fs::read(&target_meta).expect("target xl.meta must exist before marker creation");
let stale_digest = Sha256::digest(&stale_meta);
let marker = put_delete_marker(&ecstore, bucket, object).await;
std::fs::write(&target_meta, &stale_meta).expect("restore stale target xl.meta");
let request = HealRequest::new(
HealType::Bucket {
bucket: bucket.to_string(),
},
HealOptions {
recursive: true,
scan_mode: HealScanMode::Deep,
pool_index: Some(0),
set_index: Some(0),
..Default::default()
},
HealPriority::Normal,
);
let task_id = request.id.clone();
assert!(
manager
.submit_heal_request(request)
.await
.expect("submit recursive heal")
.is_admitted()
);
wait_for_task(&manager, &task_id, Duration::from_secs(60)).await;
let report = manager.get_task_report(&task_id).await.expect("completed task report");
let outcome = report.outcome.expect("recursive task must have an outcome");
assert_eq!(outcome.counters.failed, 0);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.counters.processed, 3);
assert_eq!(outcome.counters.healed, 1, "delete marker repair must be counted as healed");
assert_eq!(outcome.counters.unchanged, 2);
let repaired_meta = std::fs::read(&target_meta).expect("repaired target xl.meta must exist");
assert_ne!(
Sha256::digest(&repaired_meta),
stale_digest,
"deep heal must replace stale rejoined metadata"
);
let marker_info = physical_version(target, bucket, object, &marker);
assert!(
marker_info.deleted && marker_info.is_latest,
"target metadata must converge to delete-marker latest"
);
let historical_info = physical_version(target, bucket, object, &historical);
assert!(!historical_info.is_latest, "historical version must no longer be marked latest");
assert_eq!(
read_version(&ecstore, bucket, object, &historical).await,
historical_data,
"historical version must remain readable after marker convergence"
);
let marker_receipt = outcome
.objects
.iter()
.find(|item| item.identity.version_id.as_deref() == Some(marker.as_str()))
.expect("deep heal must emit a receipt for the repaired delete marker");
assert_eq!(marker_receipt.disposition, HealObjectDisposition::Repaired);
manager.stop().await.expect("heal manager should stop");
}
/// A missing xl.meta must produce an exact marker repair receipt, while a
/// healthy replay must prove metadata health without claiming payload integrity.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -717,6 +717,49 @@ mod absence_receipt_regressions {
assert_versions(&store, bucket, &old, &current).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn unversioned_missing_object_produces_fenced_absence_receipt() {
let bucket = "absence-receipt-unversioned";
let object = "deleted.bin";
let (_paths, store, storage) = heal_env_n(16).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create unversioned bucket");
let incarnation = storage
.admit_bucket_incarnation(bucket)
.await
.expect("admit bucket incarnation for the durable proof");
let result = storage
.heal_mrf_object_at_incarnation(
bucket,
object,
None,
incarnation,
&HealOpts {
pool: Some(0),
set: Some(0),
..deep_heal_opts()
},
)
.await
.expect("fenced absence probe should return a storage result");
assert!(
result.error.is_none(),
"complete unversioned absence must not remain an error: {:?}",
result.error
);
let receipt = result.receipt.expect("complete unversioned absence needs a receipt");
assert_eq!(receipt.disposition, HealObjectDisposition::AuthoritativelyAbsent);
assert_eq!(receipt.identity.bucket, bucket);
assert_eq!(receipt.identity.object, object);
assert!(receipt.identity.version_id.is_none());
assert_eq!((receipt.identity.pool_index, receipt.identity.set_index), (Some(0), Some(0)));
assert_eq!(receipt.identity.bucket_incarnation_id, Some(incarnation));
}
#[test]
#[serial]
fn historical_absence_receipt_bucket_outcome_matches_c06() {
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Regression for replacement healing requiring pool.bin on a non-owning set.
//! Replacement healing must restore internal records only on their owning sets.
#![recursion_limit = "256"]
use http::HeaderMap;
@@ -101,6 +101,31 @@ async fn replacement_pool_metadata_follows_real_two_set_placement() {
storage.replacement_pool_metadata_required(&opts).is_err(),
"invalid scope must fail closed"
);
assert!(
storage
.heal_replacement_bucket_metadata(bucket, &opts, &[paths[0].to_string_lossy().into_owned()])
.await
.is_err(),
"bucket metadata repair must reject an incomplete or invalid replacement scope"
);
}
for targets in [vec![], vec![paths[4].to_string_lossy().into_owned()]] {
assert!(
storage
.heal_replacement_bucket_metadata(
bucket,
&HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
&targets,
)
.await
.is_err(),
"empty or wrong-set targets must be rejected before repairing metadata"
);
}
let mut owning_set = None;
@@ -120,6 +145,17 @@ async fn replacement_pool_metadata_follows_real_two_set_placement() {
assert!(owning_set.replace(set).is_none(), "only one set owns pool.bin");
std::fs::remove_dir_all(&metadata_path).unwrap();
}
let bucket_records: Vec<_> = [".metadata.bin", ".bucket-incarnation"]
.into_iter()
.map(|file| {
let path = target_path.join(RUSTFS_META_BUCKET).join("buckets").join(bucket).join(file);
let existed = path.join("xl.meta").exists();
if existed {
std::fs::remove_dir_all(&path).expect("remove bucket metadata shard from replacement");
}
(path, existed)
})
.collect();
// Select a user key using the real placement algorithm, independently
// of the metadata-scope decision under test.
let key = (0..1000)
@@ -191,6 +227,14 @@ async fn replacement_pool_metadata_follows_real_two_set_placement() {
"completed repair must clear its healing marker"
);
assert_eq!(metadata_path.join("xl.meta").exists(), owns_metadata);
for (path, owned) in bucket_records {
assert_eq!(
path.join("xl.meta").exists(),
owned,
"replacement must restore each owned bucket record without creating a wrong-set copy: {}",
path.display()
);
}
for (relative, bytes) in original_parts {
assert_eq!(std::fs::read(object_path.join(relative)).unwrap(), bytes, "reconstructed shard differs");
}
@@ -90,6 +90,82 @@ async fn partial_write_persistence_failure_is_reported_and_retained_for_retry()
);
}
#[test]
fn unversioned_deleted_partial_write_is_discharged_by_an_absence_proof() {
const STACK_SIZE: usize = 8 * 1024 * 1024;
std::thread::Builder::new()
.name("mrf-partial-write-absence".to_owned())
.stack_size(STACK_SIZE)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.thread_stack_size(STACK_SIZE)
.enable_all()
.build()
.expect("partial-write absence runtime should build");
runtime.block_on(unversioned_deleted_partial_write_is_discharged_by_an_absence_proof_inner());
})
.expect("partial-write absence test thread should spawn")
.join()
.expect("partial-write absence test thread should finish");
}
async fn unversioned_deleted_partial_write_is_discharged_by_an_absence_proof_inner() {
use rustfs_common::mrf_channel::{MrfScope, persist_partial_write_intent};
temp_env::async_with_vars([("RUSTFS_HEAL_MRF_ENABLE", Some("true"))], async {
let root = tempfile::tempdir().expect("partial-write absence fixture directory");
let env = TestECStoreEnv::builder().disk_count(16).base_dir(root.path()).build().await;
env.make_bucket("partial-absence", false).await;
let mut coordinator_pool = env.endpoint_pools.as_ref()[0].clone();
let mut endpoints = coordinator_pool.endpoints.as_ref().to_vec();
for endpoint in endpoints.iter_mut().skip(4) {
endpoint.is_local = false;
}
coordinator_pool.endpoints = Endpoints::from(endpoints);
init_local_disks(EndpointServerPools::from(vec![coordinator_pool]))
.await
.expect("coordinator journal disks");
let manager = manager(&env);
mrf_queue::spawn_mrf_consumer(manager.clone());
for (object, version_id) in [
("deleted-unversioned.bin", None),
("deleted-versioned.bin", Some(uuid::Uuid::new_v4())),
] {
persist_partial_write_intent(
"partial-absence",
object,
version_id,
MrfScope {
pool_index: 0,
set_index: 0,
},
)
.await
.expect("durable partial-write responsibility must commit before scheduling");
assert!(snapshot_contains(object).await, "committed responsibility must exist before repair runs");
}
manager.start().await.expect("MRF scheduler should start");
for object in ["deleted-unversioned.bin", "deleted-versioned.bin"] {
assert!(
wait_until(|| async { !snapshot_contains(object).await }).await,
"complete absence proof must discharge the durable responsibility"
);
}
assert!(
wait_until(|| async {
let snapshot = manager.operations_snapshot().await;
snapshot.queue_length == 0 && snapshot.active_tasks == 0
})
.await,
"discharged absence repair must leave no queued work"
);
manager.stop().await.expect("absence manager should stop");
})
.await;
}
async fn wait_until<F: FnMut() -> Fut, Fut: Future<Output = bool>>(mut probe: F) -> bool {
tokio::time::timeout(Duration::from_secs(60), async {
loop {
+11
View File
@@ -69,6 +69,15 @@ pub fn record_remote_lock_rpc_timeout(peer: &str, op: &'static str) {
counter!("rustfs_remote_lock_rpc_timeouts_total", "peer" => peer.to_string(), "op" => op).increment(1);
}
/// Record a lock acquisition rejected by the per-peer request breaker or
/// in-flight admission limit.
#[inline(always)]
pub fn record_remote_lock_request_suppressed(peer: &str, op: &'static str, reason: &'static str) {
use metrics::counter;
counter!("rustfs_remote_lock_request_suppressed_total", "peer" => peer.to_string(), "op" => op, "reason" => reason)
.increment(1);
}
/// Record the cached lock channel to `peer` being evicted after an RPC failure.
#[inline(always)]
pub fn record_remote_lock_channel_eviction(peer: &str, trigger: &'static str) {
@@ -229,6 +238,7 @@ mod tests {
record_early_release();
record_contention_event();
record_remote_lock_rpc_timeout("http://peer:9000", "lock");
record_remote_lock_request_suppressed("http://peer:9000", "lock", "breaker_open");
record_remote_lock_channel_eviction("http://peer:9000", "timeout");
record_remote_lock_channel_eviction_suppressed("http://peer:9000", "peer_recently_served");
record_remote_lock_rpc_detached("lock", "detached");
@@ -251,6 +261,7 @@ mod tests {
"rustfs_lock_early_releases",
"rustfs_lock_contentions",
"rustfs_remote_lock_rpc_timeouts_total",
"rustfs_remote_lock_request_suppressed_total",
"rustfs_remote_lock_channel_evictions_total",
"rustfs_remote_lock_channel_evictions_suppressed_total",
"rustfs_remote_lock_rpc_detached_total",
+16 -1
View File
@@ -241,7 +241,9 @@ impl RuleValidate for LifecycleRule {
return Err(std::io::Error::other(ERR_LIFECYCLE_INVALID_DEL_MARKER_EXPIRATION_DAYS));
}
// Rule must have at least one action
let has_expiration = self.expiration.is_some();
let has_expiration = self.expiration.as_ref().is_some_and(|expiration| {
expiration.days.is_some() || expiration.date.is_some() || expiration.expired_object_delete_marker.is_some()
});
let has_transition = self.transitions.as_ref().is_some_and(|t| !t.is_empty());
// `NewerNoncurrentVersions` on its own is a MinIO extension, not an AWS
// form: it keeps the newest N noncurrent versions and expires the rest
@@ -4455,6 +4457,19 @@ mod tests {
.expect_err("a negative retention count on a transition must be rejected");
}
#[tokio::test]
async fn validate_rejects_empty_expiration_action() {
let mut rule = rule_with_filter(LifecycleRuleFilter::default());
rule.expiration = Some(LifecycleExpiration::default());
let err = config_with_rules(vec![rule])
.validate(&ObjectLockConfiguration::default())
.await
.expect_err("an empty Expiration object must not count as an action");
assert_eq!(err.to_string(), ERR_LIFECYCLE_RULE_MUST_HAVE_ACTION);
}
#[tokio::test]
async fn zero_newer_noncurrent_versions_means_no_count_constraint() {
// Zero carries no constraint, matching how the batch limit path has
+146 -24
View File
@@ -13,12 +13,11 @@
// limitations under the License.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime};
use tokio::sync::RwLock;
use tokio::time::Instant;
use tokio::time::{Instant, timeout};
use crate::{
FastLockGuard, GlobalLockManager, LockClient, LockId, LockInfo, LockManager, LockMetadata, LockPriority, LockRequest,
@@ -131,35 +130,52 @@ impl LocalClient {
self.manager.clone().unwrap_or_else(crate::get_global_lock_manager)
}
/// Get the shard index for a given lock ID
fn get_shard_index(&self, lock_id: &LockId) -> usize {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
lock_id.hash(&mut hasher);
(hasher.finish() as usize) & self.shard_mask
/// Guard entries for one resource share a shard so expiration checks do not
/// need to take every shard's write lock on the acquisition hot path.
fn get_resource_shard_index(&self, resource: &crate::ObjectKey) -> usize {
resource.shard_index(self.shard_mask)
}
/// Get the shard for a given lock ID
fn get_shard(&self, lock_id: &LockId) -> &Arc<RwLock<HashMap<LockId, LocalGuardEntry>>> {
let index = self.get_shard_index(lock_id);
let index = self.get_resource_shard_index(&lock_id.resource);
&self.guard_storage[index]
}
async fn reclaim_expired_guards_for_resource(&self, resource: &crate::ObjectKey) -> usize {
let expired_entries = Self::extract_expired_guards(&self.guard_storage, Some(resource)).await;
let shard = &self.guard_storage[self.get_resource_shard_index(resource)];
let has_expired = {
let guards = shard.read().await;
guards
.iter()
.any(|(lock_id, entry)| &lock_id.resource == resource && entry.is_expired())
};
if !has_expired {
return 0;
}
let expired_entries = {
let mut guards = shard.write().await;
guards
.extract_if(|lock_id, entry| &lock_id.resource == resource && entry.is_expired())
.map(|(_, entry)| entry)
.collect::<Vec<_>>()
};
Self::release_reclaimed_guards(expired_entries, Some(resource))
}
async fn extract_expired_guards(storage: &GuardStorage, resource: Option<&crate::ObjectKey>) -> Vec<LocalGuardEntry> {
async fn extract_expired_guards(storage: &GuardStorage) -> Vec<LocalGuardEntry> {
let mut expired_entries = Vec::new();
for shard in storage.iter() {
let has_expired = {
let guards = shard.read().await;
guards.values().any(LocalGuardEntry::is_expired)
};
if !has_expired {
continue;
}
let mut guards = shard.write().await;
expired_entries.extend(
guards
.extract_if(|lock_id, entry| {
resource.is_none_or(|resource| &lock_id.resource == resource) && entry.is_expired()
})
.map(|(_, entry)| entry),
);
expired_entries.extend(guards.extract_if(|_, entry| entry.is_expired()).map(|(_, entry)| entry));
}
expired_entries
}
@@ -197,7 +213,7 @@ impl LocalClient {
let Some(storage) = storage.upgrade() else {
break;
};
let expired_entries = Self::extract_expired_guards(&storage, None).await;
let expired_entries = Self::extract_expired_guards(&storage).await;
Self::release_reclaimed_guards(expired_entries, None);
}
});
@@ -214,11 +230,24 @@ impl Default for LocalClient {
impl LockClient for LocalClient {
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
self.ensure_reaper();
let lock_manager = self.get_lock_manager();
let reclaimed_before_acquire = self.reclaim_expired_guards_for_resource(&request.resource).await;
if request.lock_id.resource != request.resource {
return Ok(LockResponse::failure(
"Lock id resource does not match the requested resource",
Duration::ZERO,
));
}
let acquire_deadline = Instant::now()
.checked_add(request.acquire_timeout)
.unwrap_or_else(Instant::now);
let lock_manager = self.get_lock_manager();
let initial_remaining = acquire_deadline.saturating_duration_since(Instant::now());
let reclaimed_before_acquire = if initial_remaining.is_zero() {
0
} else {
timeout(initial_remaining, self.reclaim_expired_guards_for_resource(&request.resource))
.await
.unwrap_or(0)
};
let build_lock_request = |acquire_timeout| match request.lock_type {
LockType::Exclusive => crate::ObjectLockRequest::new_write(request.resource.clone(), request.owner.clone())
@@ -262,7 +291,11 @@ impl LockClient for LocalClient {
return Ok(LockResponse::success(lock_info, Duration::ZERO));
}
Err(crate::fast_lock::LockResult::Timeout) => {
if !retried_after_reclaim && self.reclaim_expired_guards_for_resource(&request.resource).await > 0 {
if !retried_after_reclaim {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if !remaining.is_zero() {
let _ = timeout(remaining, self.reclaim_expired_guards_for_resource(&request.resource)).await;
}
retried_after_reclaim = true;
continue;
}
@@ -272,7 +305,11 @@ impl LockClient for LocalClient {
current_owner,
current_mode,
}) => {
if !retried_after_reclaim && self.reclaim_expired_guards_for_resource(&request.resource).await > 0 {
if !retried_after_reclaim {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if !remaining.is_zero() {
let _ = timeout(remaining, self.reclaim_expired_guards_for_resource(&request.resource)).await;
}
retried_after_reclaim = true;
continue;
}
@@ -290,8 +327,11 @@ impl LockClient for LocalClient {
async fn release(&self, lock_id: &LockId) -> Result<bool> {
let shard = self.get_shard(lock_id);
let mut guards = shard.write().await;
if let Some(guard) = guards.remove(lock_id) {
let guard = {
let mut guards = shard.write().await;
guards.remove(lock_id)
};
if let Some(guard) = guard {
// Guard automatically releases the lock when dropped
drop(guard.guard);
Ok(true)
@@ -418,6 +458,88 @@ mod tests {
panic!("lock guard was not reaped before test deadline");
}
#[tokio::test]
async fn acquire_does_not_wait_on_unrelated_guard_shards() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_secs(60));
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "resource-shard-hot-path");
let target_shard = client.get_resource_shard_index(&resource);
let mut unrelated_writes = Vec::new();
for (index, shard) in client.guard_storage.iter().enumerate() {
if index != target_shard {
unrelated_writes.push(shard.write().await);
}
}
let lock_request = request(resource, "owner", Duration::from_secs(30));
let response = tokio::time::timeout(Duration::from_millis(100), client.acquire_lock(&lock_request))
.await
.expect("an acquire must not sweep unrelated guard shards")
.expect("local acquire should return a response");
assert!(response.success);
drop(unrelated_writes);
assert!(client.release(&lock_request.lock_id).await.unwrap());
}
#[tokio::test]
async fn reclaim_expired_guard_only_writes_the_resource_shard() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_secs(60));
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "resource-shard-expired");
let target_shard = client.get_resource_shard_index(&resource);
let expired = request(resource.clone(), "expired-owner", Duration::ZERO);
assert!(client.acquire_lock(&expired).await.unwrap().success);
let mut unrelated_writes = Vec::new();
for (index, shard) in client.guard_storage.iter().enumerate() {
if index != target_shard {
unrelated_writes.push(shard.write().await);
}
}
let replacement = request(resource, "replacement-owner", Duration::from_secs(30));
let response = tokio::time::timeout(Duration::from_millis(100), client.acquire_lock(&replacement))
.await
.expect("expired guard reclaim must only write the resource shard")
.expect("local acquire should return a response");
assert!(response.success);
drop(unrelated_writes);
assert!(client.release(&replacement.lock_id).await.unwrap());
}
#[tokio::test]
async fn acquire_deadline_includes_resource_shard_reclaim_wait() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_secs(60));
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "resource-shard-deadline");
let target_shard = client.get_resource_shard_index(&resource);
let _target_write = client.guard_storage[target_shard].write().await;
let lock_request = request(resource, "owner", Duration::from_secs(30)).with_acquire_timeout(Duration::from_millis(10));
let response = tokio::time::timeout(Duration::from_millis(100), client.acquire_lock(&lock_request))
.await
.expect("acquire should return after its reclaim budget expires")
.expect("local acquire should return a response");
assert!(!response.success);
}
#[tokio::test]
async fn acquire_rejects_a_lock_id_for_another_resource() {
let client = LocalClient::with_manager(Arc::new(GlobalLockManager::new()));
let mut lock_request = request(crate::ObjectKey::new("bucket", "requested"), "owner", Duration::from_secs(30));
lock_request.lock_id.resource = crate::ObjectKey::new("bucket", "other");
let response = client.acquire_lock(&lock_request).await.unwrap();
assert!(!response.success);
assert!(response.error.unwrap().contains("does not match"));
}
#[tokio::test(flavor = "current_thread")]
async fn expired_guard_is_reaped_without_resource_reacquire() {
let manager = Arc::new(GlobalLockManager::new());
+51 -2
View File
@@ -53,7 +53,9 @@ impl NotifyBucketConfigManager {
self.rule_engine.has_subscriber(bucket, event).await
}
pub async fn load_bucket_notification_config(
/// Validates that the runtime can accept `cfg` for `bucket` without
/// mutating any state.
pub async fn validate_bucket_notification_config(
&self,
bucket: &str,
cfg: &BucketNotificationConfig,
@@ -98,6 +100,11 @@ impl NotifyBucketConfigManager {
);
}
Ok(())
}
/// Publishes an already validated `cfg` to the runtime rule state.
pub async fn apply_bucket_notification_config(&self, bucket: &str, cfg: &BucketNotificationConfig) {
self.subscriber_view.apply_bucket_config(bucket, cfg);
self.rule_engine.set_bucket_rules(bucket, cfg.get_rules_map().clone()).await;
info!(
@@ -109,6 +116,15 @@ impl NotifyBucketConfigManager {
rule_count = cfg.get_rules_map().inner().len(),
"notify bucket config state"
);
}
pub async fn load_bucket_notification_config(
&self,
bucket: &str,
cfg: &BucketNotificationConfig,
) -> Result<(), NotificationError> {
self.validate_bucket_notification_config(bucket, cfg).await?;
self.apply_bucket_notification_config(bucket, cfg).await;
Ok(())
}
@@ -122,7 +138,7 @@ impl NotifyBucketConfigManager {
mod tests {
use super::NotifyBucketConfigManager;
use crate::{
BucketNotificationConfig, integration::NotificationMetrics,
BucketNotificationConfig, NotificationError, integration::NotificationMetrics,
notification_system_subscriber::NotificationSystemSubscriberView, notifier::EventNotifier, rule_engine::NotifyRuleEngine,
};
use rustfs_s3_types::EventName;
@@ -156,4 +172,37 @@ mod tests {
manager.remove_bucket_notification_config("bucket").await;
assert!(!manager.subscriber_view.has_subscriber("bucket", &EventName::ObjectCreatedPut));
}
#[tokio::test]
async fn validate_bucket_notification_config_rejects_missing_targets_without_applying_rules() {
let manager = build_manager();
let target_id = TargetID::new("primary".to_string(), "webhook".to_string());
let mut cfg = BucketNotificationConfig::new("us-east-1");
cfg.add_rule(&[EventName::ObjectCreatedPut], "*".to_string(), target_id);
let err = manager
.validate_bucket_notification_config("bucket", &cfg)
.await
.expect_err("validation must fail when the runtime has no notify targets");
assert!(matches!(err, NotificationError::Configuration(_)));
assert!(!manager.has_subscriber("bucket", &EventName::ObjectCreatedPut).await);
assert!(!manager.subscriber_view.has_subscriber("bucket", &EventName::ObjectCreatedPut));
}
#[tokio::test]
async fn failed_validation_keeps_previous_bucket_rules() {
let manager = build_manager();
let target_id = TargetID::new("primary".to_string(), "webhook".to_string());
let mut cfg = BucketNotificationConfig::new("us-east-1");
cfg.add_rule(&[EventName::ObjectCreatedPut], "*".to_string(), target_id);
manager.apply_bucket_notification_config("bucket", &cfg).await;
assert!(manager.has_subscriber("bucket", &EventName::ObjectCreatedPut).await);
let err = manager
.validate_bucket_notification_config("bucket", &cfg)
.await
.expect_err("validation must fail when the runtime has no notify targets");
assert!(matches!(err, NotificationError::Configuration(_)));
assert!(manager.has_subscriber("bucket", &EventName::ObjectCreatedPut).await);
}
}
+37 -10
View File
@@ -239,6 +239,42 @@ pub mod notifier_global {
.await
}
fn bucket_notification_config_for_rules(
region: &str,
event_rules: &[(Vec<EventName>, String, String, Vec<TargetID>)],
) -> BucketNotificationConfig {
let mut bucket_config = BucketNotificationConfig::new(region);
for (event_names, prefix, suffix, target_ids) in event_rules {
// Use `new_pattern` to construct a matching pattern
let pattern = crate::rules::pattern::new_pattern(Some(prefix.as_str()), Some(suffix.as_str()));
for target_id in target_ids {
bucket_config.add_rule(event_names, pattern.clone(), target_id.clone());
}
}
bucket_config
}
/// Checks that the runtime can accept `event_rules` for `region` without
/// publishing them. Callers use this to fail before persisting a bucket
/// notification configuration the notify subsystem would reject.
pub async fn validate_event_specific_rules(
bucket_name: &str,
region: &str,
event_rules: &[(Vec<EventName>, String, String, Vec<TargetID>)],
) -> Result<(), NotificationError> {
let bucket_config = bucket_notification_config_for_rules(region, event_rules);
// Get global NotificationSystem instance
let notification_sys = notification_system().ok_or(NotificationError::Lifecycle(LifecycleError::NotInitialized))?;
notification_sys
.validate_bucket_notification_config(bucket_name, &bucket_config)
.await
}
/// Dynamically add notification rules according to different event types.
///
/// # Parameter
@@ -256,16 +292,7 @@ pub mod notifier_global {
region: &str,
event_rules: &[(Vec<EventName>, String, String, Vec<TargetID>)],
) -> Result<(), NotificationError> {
let mut bucket_config = BucketNotificationConfig::new(region);
for (event_names, prefix, suffix, target_ids) in event_rules {
// Use `new_pattern` to construct a matching pattern
let pattern = crate::rules::pattern::new_pattern(Some(prefix.as_str()), Some(suffix.as_str()));
for target_id in target_ids {
bucket_config.add_rule(event_names, pattern.clone(), target_id.clone());
}
}
let bucket_config = bucket_notification_config_for_rules(region, event_rules);
// Get global NotificationSystem instance
let notification_sys = notification_system().ok_or(NotificationError::Lifecycle(LifecycleError::NotInitialized))?;
+13
View File
@@ -372,6 +372,19 @@ impl NotificationSystem {
self.services.config_manager.lifecycle().is_converged()
}
/// Validates a bucket notification configuration against the runtime targets
/// without publishing it, so callers can reject a request before persisting.
pub async fn validate_bucket_notification_config(
&self,
bucket: &str,
cfg: &BucketNotificationConfig,
) -> Result<(), NotificationError> {
self.services
.bucket_config_manager
.validate_bucket_notification_config(bucket, cfg)
.await
}
/// Loads the bucket notification configuration
pub async fn load_bucket_notification_config(
&self,
+1 -1
View File
@@ -65,7 +65,7 @@ rustfs-config = { workspace = true, features = ["constants"] }
rustfs-io-metrics = { workspace = true }
rustfs-utils = { workspace = true, features = ["os"] }
futures = { workspace = true }
tokio = { workspace = true, features = ["sync", "time", "fs", "rt-multi-thread"] }
tokio = { workspace = true, features = ["sync", "time", "fs", "rt-multi-thread","macros"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
+6
View File
@@ -139,6 +139,7 @@ opentelemetry-stdout = { workspace = true }
opentelemetry-otlp = { workspace = true, features = ["gzip-http", "reqwest-rustls"] }
opentelemetry-semantic-conventions = { workspace = true, features = ["semconv_experimental"] }
percent-encoding = { workspace = true }
reqwest.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tracing = { workspace = true, features = ["std", "attributes"] }
@@ -167,3 +168,8 @@ tempfile = { workspace = true }
temp-env = { workspace = true }
log.workspace = true
metrics-util = { workspace = true, features = ["debugging"] }
rcgen.workspace = true
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "std"] }
rustls-pki-types.workspace = true
tokio = { workspace = true, features = ["io-util", "net"] }
tokio-rustls = { workspace = true, default-features = false, features = ["aws-lc-rs"] }
+3
View File
@@ -115,6 +115,7 @@ All configuration is read from environment variables at startup.
| `RUSTFS_OBS_TRACE_ENDPOINT` | _(empty)_ | Dedicated trace endpoint (overrides root + `/v1/traces`) |
| `RUSTFS_OBS_METRIC_ENDPOINT` | _(empty)_ | Dedicated metrics endpoint |
| `RUSTFS_OBS_LOG_ENDPOINT` | _(empty)_ | Dedicated log endpoint |
| `RUSTFS_OBS_TLS_CA_FILE` | _(empty)_ | Absolute PEM CA bundle added to system trust for OTLP/HTTP |
| `RUSTFS_OBS_PROFILING_ENDPOINT` | _(empty)_ | Dedicated profiling endpoint (e.g. Pyroscope) |
| `RUSTFS_OBS_TRACES_EXPORT_ENABLED` | `true` | Toggle trace export |
| `RUSTFS_OBS_METRICS_EXPORT_ENABLED` | `true` | Toggle metrics export |
@@ -124,6 +125,8 @@ All configuration is read from environment variables at startup.
| `RUSTFS_OBS_SAMPLE_RATIO` | `0.1` | Trace sampling ratio `0.0`–`1.0` |
| `RUSTFS_OBS_METER_INTERVAL` | `30` | Metrics export interval (seconds) |
`RUSTFS_OBS_TLS_CA_FILE` is read only while OTLP exporters are initialized. Its PEM certificates are added to, not substituted for, the platform trust roots, and TLS chain, expiry, and hostname checks remain enabled. Restart RustFS after rotating the bundle. A missing, unreadable, non-absolute, empty, or invalid bundle fails OTLP initialization.
The export interval is separate from application metric collection. Without interval overrides, node/disk metrics refresh every 60 seconds (`RUSTFS_METRICS_NODE_INTERVAL_SEC`), cluster metrics every 60 seconds (`RUSTFS_METRICS_CLUSTER_INTERVAL_SEC`), and per-bucket metrics every 300 seconds (`RUSTFS_METRICS_BUCKET_INTERVAL_SEC`). See the [collection defaults](src/metrics/config.rs) and [interval configuration](src/metrics/scheduler.rs) for collector-specific and global overrides.
With OTLP → Collector → Prometheus, the time from a failure to an alert also includes underlying health detection, collection and export work, transport and Collector batching, Prometheus `scrape_interval` and `evaluation_interval`, and any alert rule `for` duration. A shorter scrape interval cannot refresh a value that RustFS has not yet collected and exported. These periodic intervals do not guarantee a fixed detection or alert latency; see the [Prometheus alert rule documentation](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/) for the role of `for`.
+26 -1
View File
@@ -40,7 +40,8 @@ use rustfs_config::observability::{
ENV_OBS_LOG_STDOUT_ENABLED, ENV_OBS_LOG_ZSTD_COMPRESSION_LEVEL, ENV_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, ENV_OBS_LOG_ZSTD_WORKERS,
ENV_OBS_LOGGER_LEVEL, ENV_OBS_LOGS_EXPORT_ENABLED, ENV_OBS_METER_INTERVAL, ENV_OBS_METRIC_ENDPOINT,
ENV_OBS_METRICS_EXPORT_ENABLED, ENV_OBS_PROFILING_ENDPOINT, ENV_OBS_PROFILING_EXPORT_ENABLED, ENV_OBS_SAMPLE_RATIO,
ENV_OBS_SERVICE_NAME, ENV_OBS_SERVICE_VERSION, ENV_OBS_TRACE_ENDPOINT, ENV_OBS_TRACES_EXPORT_ENABLED, ENV_OBS_USE_STDOUT,
ENV_OBS_SERVICE_NAME, ENV_OBS_SERVICE_VERSION, ENV_OBS_TLS_CA_FILE, ENV_OBS_TRACE_ENDPOINT, ENV_OBS_TRACES_EXPORT_ENABLED,
ENV_OBS_USE_STDOUT,
};
use rustfs_config::{
APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_TIME, DEFAULT_OBS_LOG_FILENAME,
@@ -139,6 +140,9 @@ pub struct OtelConfig {
pub metric_timeout_millis: Option<u64>,
/// Timeout (milliseconds) for log OTLP HTTP export.
pub log_timeout_millis: Option<u64>,
/// Absolute path to a PEM bundle whose certificates are added to the
/// system trust roots used by OTLP/HTTP exporters.
pub tls_ca_file: Option<String>,
/// Dedicated profiling endpoint.
pub profiling_endpoint: Option<String>,
/// Whether to export distributed traces (default: `true`).
@@ -292,6 +296,9 @@ impl OtelConfig {
trace_timeout_millis: get_env_opt_u64(ENV_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS),
metric_timeout_millis: get_env_opt_u64(ENV_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS),
log_timeout_millis: get_env_opt_u64(ENV_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS),
tls_ca_file: get_env_opt_str(ENV_OBS_TLS_CA_FILE)
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty()),
profiling_endpoint: get_env_opt_str(ENV_OBS_PROFILING_ENDPOINT),
traces_export_enabled: Some(get_env_bool(ENV_OBS_TRACES_EXPORT_ENABLED, DEFAULT_OBS_TRACES_EXPORT_ENABLED)),
metrics_export_enabled: Some(get_env_bool(ENV_OBS_METRICS_EXPORT_ENABLED, DEFAULT_OBS_METRICS_EXPORT_ENABLED)),
@@ -509,4 +516,22 @@ mod tests {
});
});
}
#[test]
fn otlp_tls_ca_file_environment_preserves_unset_empty_and_configured_values() {
with_profiling_env_lock(|| {
temp_env::with_var_unset(ENV_OBS_TLS_CA_FILE, || {
assert_eq!(OtelConfig::extract_otel_config_from_env(None).tls_ca_file, None);
});
temp_env::with_var(ENV_OBS_TLS_CA_FILE, Some(""), || {
assert_eq!(OtelConfig::extract_otel_config_from_env(None).tls_ca_file, None);
});
temp_env::with_var(ENV_OBS_TLS_CA_FILE, Some("/etc/rustfs/otlp-ca.pem"), || {
assert_eq!(
OtelConfig::extract_otel_config_from_env(None).tls_ca_file.as_deref(),
Some("/etc/rustfs/otlp-ca.pem")
);
});
});
}
}
+12
View File
@@ -60,6 +60,18 @@ pub enum TelemetryError {
BuildMetricExporter(String),
#[error("Log exporter build failed: {0}")]
BuildLogExporter(String),
#[error("RUSTFS_OBS_TLS_CA_FILE must be an absolute path")]
OtlpTlsCaPathNotAbsolute,
#[error("Failed to read RUSTFS_OBS_TLS_CA_FILE")]
OtlpTlsCaRead(#[source] std::io::Error),
#[error("RUSTFS_OBS_TLS_CA_FILE does not contain a PEM certificate")]
OtlpTlsCaEmpty,
#[error("RUSTFS_OBS_TLS_CA_FILE does not contain a valid PEM certificate")]
OtlpTlsCaInvalid,
#[error("Failed to parse RUSTFS_OBS_TLS_CA_FILE as a PEM certificate bundle")]
OtlpTlsCaParse(#[source] reqwest::Error),
#[error("Failed to build OTLP HTTP client")]
BuildOtlpHttpClient(#[source] reqwest::Error),
#[error("Install metrics recorder failed: {0}")]
InstallMetricsRecorder(String),
#[error("Tracing subscriber init failed: {0}")]
+12
View File
@@ -89,6 +89,18 @@ fn rust_log_requests_verbose(rust_log: &str) -> bool {
})
}
/// Returns `true` when the effective logging configuration selects a verbose
/// (`debug`/`trace`) level.
///
/// Follows the same precedence as [`build_env_filter`]: `RUST_LOG` overrides
/// `logger_level` when it is set.
pub(super) fn effective_verbose_logging(logger_level: &str) -> bool {
match env::var("RUST_LOG").ok().filter(|value| !value.trim().is_empty()) {
Some(rust_log) => rust_log_requests_verbose(&rust_log),
None => is_verbose_level(logger_level),
}
}
fn should_suppress_noisy_crates(logger_level: &str, default_level: Option<&str>, rust_log: Option<&str>) -> bool {
if let Some(level) = default_level {
return !is_verbose_level(level);
+143 -13
View File
@@ -36,7 +36,7 @@ use crate::cleaner::LogCleaner;
use crate::cleaner::types::{CompressionAlgorithm, FileMatchMode};
use crate::config::OtelConfig;
use crate::global::{METRIC_LOG_CLEANER_RUN_FAILURES_TOTAL, METRIC_LOG_CLEANER_RUNS_TOTAL, set_observability_metric_enabled};
use crate::telemetry::filter::{build_env_filter, pyroscope_log_filter};
use crate::telemetry::filter::{build_env_filter, effective_verbose_logging, pyroscope_log_filter};
use crate::telemetry::rolling::{RollingAppender, Rotation};
use metrics::counter;
use rustfs_config::observability::{
@@ -44,10 +44,11 @@ use rustfs_config::observability::{
DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, DEFAULT_OBS_LOG_DELETE_EMPTY_FILES, DEFAULT_OBS_LOG_DRY_RUN,
DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_MATCH_MODE, DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES,
DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS, DEFAULT_OBS_LOG_PARALLEL_COMPRESS,
DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP,
DEFAULT_OBS_LOG_ZSTD_WORKERS,
DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_SPAN_EVENTS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL,
DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, DEFAULT_OBS_LOG_ZSTD_WORKERS, ENV_OBS_LOG_SPAN_EVENTS,
};
use rustfs_config::{APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_ROTATION_TIME};
use rustfs_utils::get_env_str;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use std::{collections::BTreeMap, fmt};
@@ -78,6 +79,37 @@ pub(super) fn resolve_file_stdout_mirror(configured: Option<bool>, is_production
configured.unwrap_or(!is_production)
}
/// Resolves the span lifecycle events emitted by the JSON log layers.
///
/// Defaults to [`FmtSpan::NONE`]. A record per span lifecycle multiplies log
/// volume on object hot paths and, combined with the ancestor span list,
/// produced multi-megabyte lines that journald truncated (backlog#2642).
/// Operators opt back in with `RUSTFS_OBS_LOG_SPAN_EVENTS=close|full`.
pub(super) fn resolve_span_events() -> FmtSpan {
match get_env_str(ENV_OBS_LOG_SPAN_EVENTS, DEFAULT_OBS_LOG_SPAN_EVENTS)
.trim()
.to_ascii_lowercase()
.as_str()
{
"close" => FmtSpan::CLOSE,
"full" => FmtSpan::FULL,
"none" | "" => FmtSpan::NONE,
unknown => {
// An unrecognised value must not silently re-enable span events.
eprintln!("{STDERR_WARNING_PREFIX} obs: unknown {ENV_OBS_LOG_SPAN_EVENTS}={unknown:?}; falling back to none");
FmtSpan::NONE
}
}
}
/// Whether JSON log records embed the full ancestor span list.
///
/// `with_span_list(true)` re-serializes every ancestor span on each record, so
/// it is reserved for verbose levels where that context earns its volume.
pub(super) fn resolve_span_list(logger_level: &str) -> bool {
effective_verbose_logging(logger_level)
}
#[cfg(unix)]
pub(super) fn validate_stdout_sink(file_appender: &RollingAppender) -> Result<(), TelemetryError> {
use std::os::fd::AsFd;
@@ -222,7 +254,12 @@ where
request_id
}
pub(super) fn build_json_log_layer<S, W>(writer: W, enable_ansi: bool, span_events: FmtSpan) -> impl tracing_subscriber::Layer<S>
pub(super) fn build_json_log_layer<S, W>(
writer: W,
enable_ansi: bool,
span_events: FmtSpan,
span_list: bool,
) -> impl tracing_subscriber::Layer<S>
where
S: Subscriber + for<'span> LookupSpan<'span>,
W: for<'writer> tracing_subscriber::fmt::MakeWriter<'writer> + Send + Sync + 'static,
@@ -239,7 +276,7 @@ where
.json()
.flatten_event(true)
.with_current_span(true)
.with_span_list(true)
.with_span_list(span_list)
.with_span_events(span_events)
.map_event_format(RequestIdJsonFormat::new)
}
@@ -311,11 +348,12 @@ pub(super) fn init_local_logging(
fn init_stdout_only(_config: &OtelConfig, logger_level: &str, is_production: bool) -> Result<OtelGuard, TelemetryError> {
let env_filter = build_env_filter(logger_level, None);
let (nb, guard) = tracing_appender::non_blocking(std::io::stdout());
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let span_events = resolve_span_events();
let span_list = resolve_span_list(logger_level);
// Keep stdout formatting JSON-shaped even in local-only mode so operators
// can ship the same log schema to external collectors if needed.
let fmt_layer = build_json_log_layer(nb, std::io::stdout().is_terminal(), span_events);
let fmt_layer = build_json_log_layer(nb, std::io::stdout().is_terminal(), span_events, span_list);
tracing_subscriber::registry()
.with(env_filter)
@@ -413,11 +451,12 @@ fn init_file_logging_internal(
// ── 4. Build subscriber layers ────────────────────────────────────────────
let env_filter = build_env_filter(logger_level, None);
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let span_events = resolve_span_events();
let span_list = resolve_span_list(logger_level);
// File output stays machine-readable and free of ANSI sequences so the
// resulting files are safe to parse or ship to log processors.
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone());
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone(), span_list);
// Optional stdout mirror: enabled explicitly via `log_stdout_enabled`, or
// unconditionally in non-production environments so developers still see
@@ -425,7 +464,10 @@ fn init_file_logging_internal(
let (stdout_layer, stdout_guard) = if resolve_file_stdout_mirror(config.log_stdout_enabled, is_production) {
let (stdout_nb, stdout_guard) = tracing_appender::non_blocking(std::io::stdout());
let enable_color = std::io::stdout().is_terminal();
(Some(build_json_log_layer(stdout_nb, enable_color, span_events)), Some(stdout_guard))
(
Some(build_json_log_layer(stdout_nb, enable_color, span_events, span_list)),
Some(stdout_guard),
)
} else {
(None, None)
};
@@ -773,7 +815,7 @@ mod tests {
fn render_json_log(enable_ansi: bool) -> (String, Value) {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), enable_ansi, FmtSpan::NONE);
let layer = build_json_log_layer(writer.clone(), enable_ansi, FmtSpan::NONE, false);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
@@ -793,9 +835,32 @@ mod tests {
(raw, parsed)
}
fn render_event_in_nested_spans(depth: usize, span_list: bool) -> String {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE, span_list);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
let spans = (0..depth)
.map(|level| tracing::info_span!("layer", depth = level, payload = "x".repeat(1024)))
.collect::<Vec<_>>();
let _guards = spans.iter().map(tracing::Span::enter).collect::<Vec<_>>();
info!(component = "obs_contract_test", "nested event");
});
String::from_utf8(
writer
.inner
.lock()
.expect("shared writer lock should not be poisoned")
.clone(),
)
.expect("log output should be valid UTF-8")
}
fn render_json_log_with_request_span() -> Value {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE);
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE, false);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
@@ -818,7 +883,7 @@ mod tests {
fn render_json_log_with_recovery_monitor_child_span() -> Value {
let writer = SharedWriter::default();
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE);
let layer = build_json_log_layer(writer.clone(), false, FmtSpan::NONE, true);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
@@ -1075,6 +1140,10 @@ mod tests {
assert_eq!(parsed["request-id"], Value::String("req-123".to_string()));
assert_eq!(parsed["message"], Value::String("inside request span".to_string()));
assert_eq!(parsed["span"]["request_id"], Value::String("req-123".to_string()));
assert!(
parsed.get("spans").is_none(),
"ancestor span list must stay out of records when span_list is disabled: {parsed}"
);
}
#[test]
@@ -1112,4 +1181,65 @@ mod tests {
assert_eq!(resolve_log_cleanup_file_pattern(&config, "rustfs.log"), "rustfs.log");
}
/// Regression for backlog#2642: records must not embed the ancestor span
/// list, so their size stays flat as the enclosing span chain deepens.
#[test]
fn record_size_does_not_grow_with_span_depth_when_span_list_is_disabled() {
let shallow = render_event_in_nested_spans(1, false);
let deep = render_event_in_nested_spans(32, false);
assert!(
deep.len() <= shallow.len() * 2,
"ancestor spans leaked into the record: shallow={} deep={}",
shallow.len(),
deep.len()
);
// The flag is what keeps it bounded: re-enabling the list must show the
// growth the default avoids.
let deep_with_list = render_event_in_nested_spans(32, true);
assert!(
deep_with_list.len() > deep.len(),
"enabling the span list must reintroduce per-ancestor growth"
);
}
#[test]
fn span_events_default_to_none_and_parse_overrides() {
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, None::<&str>, || {
assert_eq!(resolve_span_events(), FmtSpan::NONE);
});
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, Some("close"), || {
assert_eq!(resolve_span_events(), FmtSpan::CLOSE);
});
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, Some("FULL"), || {
assert_eq!(resolve_span_events(), FmtSpan::FULL);
});
temp_env::with_var(ENV_OBS_LOG_SPAN_EVENTS, Some("verbose"), || {
assert_eq!(
resolve_span_events(),
FmtSpan::NONE,
"an unrecognised value must not re-enable span events"
);
});
}
#[test]
fn span_list_is_reserved_for_verbose_levels() {
temp_env::with_var("RUST_LOG", None::<&str>, || {
assert!(!resolve_span_list("info"), "info records must not embed the ancestor span list");
assert!(!resolve_span_list("warn"));
assert!(!resolve_span_list("error"));
assert!(resolve_span_list("debug"));
assert!(resolve_span_list("trace"));
});
temp_env::with_var("RUST_LOG", Some("debug"), || {
assert!(
resolve_span_list("info"),
"RUST_LOG=debug overrides the base level and should keep span context"
);
});
}
}
+260 -11
View File
@@ -64,11 +64,11 @@ use rustfs_config::{
DEFAULT_OBS_METRICS_EXPORT_ENABLED, DEFAULT_OBS_TRACES_EXPORT_ENABLED, METER_INTERVAL, SAMPLE_RATIO,
};
use std::collections::HashMap;
use std::{fs, io::IsTerminal, time::Duration};
use std::{fs, io::IsTerminal, path::Path, time::Duration};
use tracing::{info, warn};
use tracing_error::ErrorLayer;
use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer};
use tracing_subscriber::{fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const GET_OBJECT_DURATION_HISTOGRAM_METRICS: &[&str] = &[
"rustfs_io_get_object_request_duration_seconds",
@@ -83,6 +83,12 @@ const GET_OBJECT_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
const DEFAULT_OTLP_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
const OTEL_EXPORTER_OTLP_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TIMEOUT";
const OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT";
const OTEL_EXPORTER_OTLP_METRICS_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT";
const OTEL_EXPORTER_OTLP_LOGS_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT";
#[cfg(all(
feature = "pyroscope",
any(target_os = "macos", all(target_os = "linux", target_env = "gnu", target_arch = "x86_64"))
@@ -130,6 +136,8 @@ pub(super) fn init_observability_http(
logger_level: &str,
is_production: bool,
) -> Result<OtelGuard, TelemetryError> {
let otlp_tls_ca_bundle = OtlpTlsCaBundle::load(config)?;
// ── Resource & sampling ──────────────────────────────────────────────────
// Build the common resource once so all enabled signals report the same
// service identity and deployment metadata.
@@ -153,10 +161,12 @@ pub(super) fn init_observability_http(
let log_ep = resolve_signal_endpoint(config.log_endpoint.as_deref(), root_ep, "/v1/logs");
// ── Tracer provider (HTTP) ────────────────────────────────────────────────
let tracer_provider = build_tracer_provider(&trace_ep, config, res.clone(), sampler, use_stdout)?;
let tracer_provider =
build_tracer_provider(&trace_ep, config, otlp_tls_ca_bundle.as_ref(), res.clone(), sampler, use_stdout)?;
// ── Meter provider (HTTP) ─────────────────────────────────────────────────
let meter_provider = build_meter_provider(&metric_ep, config, res.clone(), &service_name, use_stdout)?;
let meter_provider =
build_meter_provider(&metric_ep, config, otlp_tls_ca_bundle.as_ref(), res.clone(), &service_name, use_stdout)?;
// ── Logger Logic ──────────────────────────────────────────────────────────
// Logging is the only signal that may intentionally route to either OTLP
@@ -175,7 +185,7 @@ pub(super) fn init_observability_http(
// Init OTLP logger logic.
// We initialize the OTLP collector and honor the configured stdout setting
// (e.g. via RUSTFS_OBS_USE_STDOUT / config.use_stdout) when building the provider.
logger_provider = build_logger_provider(&log_ep, config, res, use_stdout)?;
logger_provider = build_logger_provider(&log_ep, config, otlp_tls_ca_bundle.as_ref(), res, use_stdout)?;
// Build bridge to capture `tracing` events.
otel_bridge = logger_provider.as_ref().map(OpenTelemetryTracingBridge::new);
@@ -184,7 +194,8 @@ pub(super) fn init_observability_http(
// active, the OpenTelemetry bridge is the authoritative sink for
// `tracing` events unless local file logging is needed as a fallback.
}
let span_events = if is_production { FmtSpan::CLOSE } else { FmtSpan::FULL };
let span_events = crate::telemetry::local::resolve_span_events();
let span_list = crate::telemetry::local::resolve_span_list(logger_level);
// ── Case 2: File Logging
// If a log directory is configured and OTLP log export is unavailable, use
// the same rolling-file behavior as the local-only telemetry backend.
@@ -221,7 +232,7 @@ pub(super) fn init_observability_http(
crate::telemetry::local::validate_stdout_sink(&file_appender)?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone());
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone(), span_list);
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files);
Ok((file_layer, guard, cleanup_handle, rotation_str))
})();
@@ -267,7 +278,7 @@ pub(super) fn init_observability_http(
if force_stdout_logging || crate::telemetry::local::resolve_file_stdout_mirror(config.log_stdout_enabled, is_production) {
let (stdout_nb, stdout_g) = tracing_appender::non_blocking(std::io::stdout());
stdout_guard = Some(stdout_g);
stdout_layer_opt = Some(build_json_log_layer(stdout_nb, std::io::stdout().is_terminal(), span_events));
stdout_layer_opt = Some(build_json_log_layer(stdout_nb, std::io::stdout().is_terminal(), span_events, span_list));
}
let local_file_fallback_enabled = file_layer_opt.is_some();
let stdout_mirror_enabled = stdout_guard.is_some();
@@ -320,6 +331,7 @@ pub(super) fn init_observability_http(
fn build_tracer_provider(
trace_ep: &str,
config: &OtelConfig,
otlp_tls_ca_bundle: Option<&OtlpTlsCaBundle>,
res: opentelemetry_sdk::Resource,
sampler: Sampler,
use_stdout: bool,
@@ -337,9 +349,13 @@ fn build_tracer_provider(
if !trace_headers.is_empty() {
exporter_builder = exporter_builder.with_headers(trace_headers);
}
if let Some(timeout) = resolve_signal_timeout(config.endpoint_timeout_millis, config.trace_timeout_millis) {
let timeout = resolve_signal_timeout(config.endpoint_timeout_millis, config.trace_timeout_millis);
if let Some(timeout) = timeout {
exporter_builder = exporter_builder.with_timeout(timeout);
}
if let Some(http_client) = build_otlp_http_client(otlp_tls_ca_bundle, timeout, OTEL_EXPORTER_OTLP_TRACES_TIMEOUT)? {
exporter_builder = exporter_builder.with_http_client(http_client);
}
let exporter = exporter_builder
.build()
.map_err(|e| TelemetryError::BuildSpanExporter(e.to_string()))?;
@@ -408,6 +424,7 @@ fn resolve_signal_endpoint(signal_endpoint: Option<&str>, root_ep: &str, suffix:
fn build_meter_provider(
metric_ep: &str,
config: &OtelConfig,
otlp_tls_ca_bundle: Option<&OtlpTlsCaBundle>,
res: opentelemetry_sdk::Resource,
service_name: &str,
use_stdout: bool,
@@ -426,9 +443,13 @@ fn build_meter_provider(
if !metric_headers.is_empty() {
exporter_builder = exporter_builder.with_headers(metric_headers);
}
if let Some(timeout) = resolve_signal_timeout(config.endpoint_timeout_millis, config.metric_timeout_millis) {
let timeout = resolve_signal_timeout(config.endpoint_timeout_millis, config.metric_timeout_millis);
if let Some(timeout) = timeout {
exporter_builder = exporter_builder.with_timeout(timeout);
}
if let Some(http_client) = build_otlp_http_client(otlp_tls_ca_bundle, timeout, OTEL_EXPORTER_OTLP_METRICS_TIMEOUT)? {
exporter_builder = exporter_builder.with_http_client(http_client);
}
let exporter = exporter_builder
.build()
.map_err(|e| TelemetryError::BuildMetricExporter(e.to_string()))?;
@@ -484,6 +505,7 @@ fn is_get_object_duration_histogram_metric(name: &str) -> bool {
fn build_logger_provider(
log_ep: &str,
config: &OtelConfig,
otlp_tls_ca_bundle: Option<&OtlpTlsCaBundle>,
res: opentelemetry_sdk::Resource,
use_stdout: bool,
) -> Result<Option<SdkLoggerProvider>, TelemetryError> {
@@ -500,9 +522,13 @@ fn build_logger_provider(
if !log_headers.is_empty() {
exporter_builder = exporter_builder.with_headers(log_headers);
}
if let Some(timeout) = resolve_signal_timeout(config.endpoint_timeout_millis, config.log_timeout_millis) {
let timeout = resolve_signal_timeout(config.endpoint_timeout_millis, config.log_timeout_millis);
if let Some(timeout) = timeout {
exporter_builder = exporter_builder.with_timeout(timeout);
}
if let Some(http_client) = build_otlp_http_client(otlp_tls_ca_bundle, timeout, OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)? {
exporter_builder = exporter_builder.with_http_client(http_client);
}
let exporter = exporter_builder
.build()
.map_err(|e| TelemetryError::BuildLogExporter(e.to_string()))?;
@@ -659,16 +685,239 @@ fn resolve_signal_timeout(common_timeout_millis: Option<u64>, signal_timeout_mil
.map(Duration::from_millis)
}
struct OtlpTlsCaBundle {
certificates: Vec<reqwest::Certificate>,
}
impl OtlpTlsCaBundle {
fn load(config: &OtelConfig) -> Result<Option<Self>, TelemetryError> {
let Some(ca_file) = config.tls_ca_file.as_deref() else {
return Ok(None);
};
let path = Path::new(ca_file);
if !path.is_absolute() {
return Err(TelemetryError::OtlpTlsCaPathNotAbsolute);
}
let pem_bundle = fs::read(path).map_err(TelemetryError::OtlpTlsCaRead)?;
if pem_bundle.iter().all(u8::is_ascii_whitespace) {
return Err(TelemetryError::OtlpTlsCaEmpty);
}
let certificates = reqwest::Certificate::from_pem_bundle(&pem_bundle).map_err(TelemetryError::OtlpTlsCaParse)?;
if certificates.is_empty() {
return Err(TelemetryError::OtlpTlsCaInvalid);
}
Ok(Some(Self { certificates }))
}
fn build_http_client(
&self,
configured_timeout: Option<Duration>,
otel_signal_timeout_env: &str,
) -> Result<reqwest::Client, TelemetryError> {
let timeout = configured_timeout.unwrap_or_else(|| resolve_otlp_http_timeout(otel_signal_timeout_env));
reqwest::Client::builder()
.timeout(timeout)
.tls_certs_merge(self.certificates.clone())
.build()
.map_err(TelemetryError::BuildOtlpHttpClient)
}
}
fn build_otlp_http_client(
otlp_tls_ca_bundle: Option<&OtlpTlsCaBundle>,
configured_timeout: Option<Duration>,
otel_signal_timeout_env: &str,
) -> Result<Option<reqwest::Client>, TelemetryError> {
otlp_tls_ca_bundle
.map(|bundle| bundle.build_http_client(configured_timeout, otel_signal_timeout_env))
.transpose()
}
fn resolve_otlp_http_timeout(signal_timeout_env: &str) -> Duration {
[signal_timeout_env, OTEL_EXPORTER_OTLP_TIMEOUT]
.into_iter()
.find_map(|name| std::env::var(name).ok().and_then(|value| value.parse::<u64>().ok()))
.map(Duration::from_millis)
.unwrap_or(DEFAULT_OTLP_HTTP_TIMEOUT)
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::io::{self, Write};
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const LOG_TRACER_CHILD_ENV: &str = "RUSTFS_OBS_LOG_TRACER_CHILD";
fn config_with_tls_ca_file(path: &Path) -> OtelConfig {
OtelConfig {
tls_ca_file: Some(path.display().to_string()),
..OtelConfig::default()
}
}
fn load_tls_ca_bundle(path: &Path) -> OtlpTlsCaBundle {
OtlpTlsCaBundle::load(&config_with_tls_ca_file(path))
.expect("load CA bundle")
.expect("configured CA file should produce a bundle")
}
async fn spawn_test_tls_server() -> (String, String, tokio::task::JoinHandle<bool>) {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let certified =
rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).expect("generate TLS server certificate");
let ca_pem = certified.cert.pem();
let private_key = rustls_pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der())
.expect("convert TLS server private key");
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certified.cert.der().clone()], private_key)
.expect("build TLS server config");
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind TLS test server");
let endpoint = format!("https://{}", listener.local_addr().expect("read TLS test server address"));
let server = tokio::spawn(async move {
let Ok((stream, _)) = listener.accept().await else {
return false;
};
let Ok(mut stream) = acceptor.accept(stream).await else {
return false;
};
let mut request = Vec::new();
let mut buffer = [0_u8; 1_024];
loop {
let Ok(read) = stream.read(&mut buffer).await else {
return false;
};
if read == 0 {
return false;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok")
.await
.is_ok()
&& stream.shutdown().await.is_ok()
});
(endpoint, ca_pem, server)
}
#[tokio::test]
async fn otlp_http_client_trusts_every_certificate_in_a_pem_bundle() {
let (first_endpoint, first_ca_pem, first_server) = spawn_test_tls_server().await;
let (second_endpoint, second_ca_pem, second_server) = spawn_test_tls_server().await;
let file = tempfile::NamedTempFile::new().expect("create CA bundle file");
std::fs::write(file.path(), format!("{first_ca_pem}\n{second_ca_pem}")).expect("write CA bundle");
let bundle = load_tls_ca_bundle(file.path());
let client = build_otlp_http_client(Some(&bundle), Some(Duration::from_millis(250)), OTEL_EXPORTER_OTLP_TRACES_TIMEOUT)
.expect("build client with CA bundle");
let client = client.expect("custom CA should build a client");
assert_eq!(
client
.get(first_endpoint)
.send()
.await
.expect("first bundled CA should complete TLS handshake")
.status(),
reqwest::StatusCode::OK
);
assert_eq!(
client
.get(second_endpoint)
.send()
.await
.expect("second bundled CA should complete TLS handshake")
.status(),
reqwest::StatusCode::OK
);
assert!(first_server.await.expect("first bundled TLS server task"));
assert!(second_server.await.expect("second bundled TLS server task"));
}
#[test]
fn otlp_http_client_rejects_relative_missing_empty_and_invalid_ca_files() {
let relative = OtelConfig {
tls_ca_file: Some("otlp-ca.pem".to_string()),
..OtelConfig::default()
};
assert!(matches!(OtlpTlsCaBundle::load(&relative), Err(TelemetryError::OtlpTlsCaPathNotAbsolute)));
let missing_directory = tempfile::tempdir().expect("create missing CA directory");
let missing = OtelConfig {
tls_ca_file: Some(missing_directory.path().join("otlp-ca.pem").display().to_string()),
..OtelConfig::default()
};
assert!(matches!(OtlpTlsCaBundle::load(&missing), Err(TelemetryError::OtlpTlsCaRead(_))));
let empty = tempfile::NamedTempFile::new().expect("create empty CA file");
assert!(matches!(
OtlpTlsCaBundle::load(&config_with_tls_ca_file(empty.path())),
Err(TelemetryError::OtlpTlsCaEmpty)
));
let invalid = tempfile::NamedTempFile::new().expect("create invalid CA file");
std::fs::write(invalid.path(), b"not a PEM certificate").expect("write invalid CA file");
assert!(matches!(
OtlpTlsCaBundle::load(&config_with_tls_ca_file(invalid.path())),
Err(TelemetryError::OtlpTlsCaInvalid)
));
}
#[tokio::test]
async fn otlp_http_client_trusts_the_configured_ca_and_rejects_another_ca() {
let (endpoint, ca_pem, trusted_server) = spawn_test_tls_server().await;
let trusted_file = tempfile::NamedTempFile::new().expect("create trusted CA file");
std::fs::write(trusted_file.path(), ca_pem).expect("write trusted CA file");
let trusted_bundle = load_tls_ca_bundle(trusted_file.path());
let trusted_client =
build_otlp_http_client(Some(&trusted_bundle), Some(Duration::from_secs(1)), OTEL_EXPORTER_OTLP_TRACES_TIMEOUT)
.expect("build trusted client")
.expect("custom CA should build a client");
assert_eq!(
trusted_client
.get(&endpoint)
.send()
.await
.expect("configured CA should complete TLS handshake")
.status(),
reqwest::StatusCode::OK
);
assert!(trusted_server.await.expect("trusted TLS server task"));
let (endpoint, _ca_pem, untrusted_server) = spawn_test_tls_server().await;
let wrong_ca =
rcgen::generate_simple_self_signed(vec!["wrong.test".to_string()]).expect("generate unrelated CA certificate");
let untrusted_file = tempfile::NamedTempFile::new().expect("create unrelated CA file");
std::fs::write(untrusted_file.path(), wrong_ca.cert.pem()).expect("write unrelated CA file");
let untrusted_bundle = load_tls_ca_bundle(untrusted_file.path());
let untrusted_client =
build_otlp_http_client(Some(&untrusted_bundle), Some(Duration::from_secs(1)), OTEL_EXPORTER_OTLP_TRACES_TIMEOUT)
.expect("build untrusted client")
.expect("custom CA should build a client");
assert!(untrusted_client.get(&endpoint).send().await.is_err());
assert!(!untrusted_server.await.expect("untrusted TLS server task"));
}
#[test]
fn otlp_http_client_is_not_built_without_a_custom_ca_file() {
assert!(
build_otlp_http_client(None, None, OTEL_EXPORTER_OTLP_TRACES_TIMEOUT)
.expect("no CA file keeps the default client")
.is_none()
);
}
#[derive(Clone)]
struct TestWriter(Arc<Mutex<Vec<u8>>>);
+32 -2
View File
@@ -133,6 +133,8 @@ pub struct DeleteObjectCall {
}
struct Inner {
capture_upload_bodies: bool,
upload_bodies: Vec<Vec<u8>>,
// Response queues. Each method pops from its own queue. Empty queue
// plus no default means a configured-miss error.
get_object: VecDeque<Result<GetObjectOutput, DummyError>>,
@@ -190,6 +192,8 @@ struct Inner {
impl Inner {
fn new() -> Self {
Self {
capture_upload_bodies: false,
upload_bodies: Vec::new(),
get_object: VecDeque::new(),
get_object_range: VecDeque::new(),
put_object: VecDeque::new(),
@@ -265,6 +269,30 @@ impl DummyBackend {
}
}
/// Opt in to consuming upload bodies for byte-for-byte protocol tests.
pub fn capture_upload_bodies(&self) {
self.inner.lock().expect("lock").capture_upload_bodies = true;
}
pub fn upload_bodies(&self) -> Vec<Vec<u8>> {
self.inner.lock().expect("lock").upload_bodies.clone()
}
async fn record_upload_body(&self, body: &mut Option<StreamingBlob>) -> Result<(), DummyError> {
use futures_util::TryStreamExt;
if !self.inner.lock().expect("lock").capture_upload_bodies {
return Ok(());
}
let mut bytes = Vec::new();
if let Some(mut stream) = body.take() {
while let Some(chunk) = stream.try_next().await.map_err(|e| DummyError::Injected(e.to_string()))? {
bytes.extend_from_slice(&chunk);
}
}
self.inner.lock().expect("lock").upload_bodies.push(bytes);
Ok(())
}
// Queue-configuration helpers. Each test stages the responses it
// expects in order. The method pops in FIFO order.
@@ -629,7 +657,8 @@ impl StorageBackend for DummyBackend {
}
}
async fn put_object(&self, input: PutObjectInput, _credentials: &Credentials) -> Result<PutObjectOutput, Self::Error> {
async fn put_object(&self, mut input: PutObjectInput, _credentials: &Credentials) -> Result<PutObjectOutput, Self::Error> {
self.record_upload_body(&mut input.body).await?;
// Decide control flow while holding the lock. Release before
// awaiting so the stall path does not hold the Mutex across
// an await point.
@@ -789,7 +818,8 @@ impl StorageBackend for DummyBackend {
}
}
async fn upload_part(&self, input: UploadPartInput, _credentials: &Credentials) -> Result<UploadPartOutput, Self::Error> {
async fn upload_part(&self, mut input: UploadPartInput, _credentials: &Credentials) -> Result<UploadPartOutput, Self::Error> {
self.record_upload_body(&mut input.body).await?;
// Record the call and decide the control flow while holding the
// lock. Release the lock before awaiting so the stall path does
// not hold the Mutex across an await point.
+4 -4
View File
@@ -150,10 +150,10 @@ pub fn is_operation_supported(protocol: super::session::Protocol, action: &S3Act
S3Action::HeadObject => true, // SIZE command
// Multipart operations
S3Action::CreateMultipartUpload => false,
S3Action::UploadPart => false,
S3Action::CompleteMultipartUpload => false,
S3Action::AbortMultipartUpload => false,
S3Action::CreateMultipartUpload => true,
S3Action::UploadPart => true,
S3Action::CompleteMultipartUpload => true,
S3Action::AbortMultipartUpload => true,
S3Action::ListMultipartUploads => false,
S3Action::ListParts => false,
+527 -48
View File
@@ -16,12 +16,12 @@ use crate::common::client::s3::StorageBackend as S3StorageBackend;
use crate::common::gateway::S3Action;
use crate::common::gateway::authorize_operation;
use async_trait::async_trait;
use futures_util::stream;
use rustfs_utils::MaskedAccessKey;
use rustfs_utils::path;
use s3s::dto::*;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::io::AsyncRead;
use tracing::{debug, error};
use unftp_core::storage::{Error, ErrorKind, Fileinfo, Metadata, Result, StorageBackend};
@@ -94,12 +94,12 @@ impl Metadata for FtpsMetadata {
/// FTPS storage driver implementation
pub struct FtpsDriver<S> {
/// Storage backend for S3 operations
storage: S,
storage: Arc<S>,
}
impl<S> Debug for FtpsDriver<S>
where
S: S3StorageBackend + Debug,
S: S3StorageBackend + Debug + 'static,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FtpsDriver").field("storage", &"StorageBackend").finish()
@@ -108,11 +108,13 @@ where
impl<S> FtpsDriver<S>
where
S: S3StorageBackend + Debug,
S: S3StorageBackend + Debug + 'static,
{
/// Create a new FTPS driver with the given storage backend
pub fn new(storage: S) -> Self {
Self { storage }
Self {
storage: Arc::new(storage),
}
}
/// List all buckets (for root path)
@@ -232,7 +234,7 @@ where
#[async_trait]
impl<S> StorageBackend<super::server::FtpsUser> for FtpsDriver<S>
where
S: S3StorageBackend + Debug,
S: S3StorageBackend + Debug + 'static,
{
type Metadata = FtpsMetadata;
@@ -544,55 +546,18 @@ where
.await
.map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?;
// Convert AsyncRead to bytes
let bytes_vec = {
let mut buffer = Vec::new();
let mut reader = bytes;
tokio::io::copy(&mut reader, &mut buffer)
.await
.map_err(|e| Error::new(ErrorKind::TransientFileNotAvailable, e.to_string()))?;
buffer
};
let file_size = bytes_vec.len();
let mut put_builder = PutObjectInput::builder();
put_builder.set_bucket(bucket.clone());
put_builder.set_key(key.clone());
put_builder.set_content_length(Some(file_size as i64));
// Create StreamingBlob with known size
let data_bytes = bytes::Bytes::from(bytes_vec);
let stream = stream::once(async move { Ok::<bytes::Bytes, std::io::Error>(data_bytes) });
let streaming_blob = s3s::dto::StreamingBlob::wrap(stream);
put_builder.set_body(Some(streaming_blob));
let put_input = put_builder
.build()
.map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build PutObjectInput"))?;
match self.storage.put_object(put_input, session_context.credentials()).await {
Ok(_output) => {
Ok(file_size as u64) // Return the size of the uploaded object
}
Err(e) => {
upload::upload(Arc::clone(&self.storage), session_context, bytes, &bucket, &key)
.await
.inspect_err(|e| {
error!(
event = EVENT_FTPS_OBJECT_PUT_FAILED,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_FTPS_DRIVER,
username = %masked_username,
path = %path_str,
bucket = %bucket,
object = %key,
file_size,
error = ?e,
error = %e,
"ftps object put failed"
);
Err(Error::new(
ErrorKind::PermanentFileNotAvailable,
format!("Failed to upload object: {:?}", e),
))
}
}
})
}
async fn del<P: AsRef<Path> + Send>(&self, user: &super::server::FtpsUser, path: P) -> Result<()> {
@@ -927,3 +892,517 @@ mod tests {
assert!(parse_s3_path("/bucket/__XLDIR__").is_err());
}
}
mod upload {
use crate::common::{
client::s3::StorageBackend,
gateway::{S3Action, authorize_operation},
session::SessionContext,
};
use bytes::Bytes;
use s3s::dto::*;
use std::{
sync::{Arc, LazyLock},
time::Duration,
};
use tokio::{
io::{AsyncRead, AsyncReadExt},
sync::Semaphore,
};
use unftp_core::storage::{Error, ErrorKind, Result};
// One in-flight part per upload; never buffer the next part while the backend consumes this one.
const PART_SIZE: usize = 16 * 1024 * 1024;
const MAX_PARTS: i32 = 10_000;
const ABORT_TIMEOUT: Duration = Duration::from_secs(30);
static ABORT_PERMITS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| Arc::new(Semaphore::new(32)));
const EVENT_FTPS_UPLOAD_CLEANUP: &str = "ftps_upload_cleanup";
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
const LOG_SUBSYSTEM_FTPS_UPLOAD: &str = "ftps_upload";
fn upload_error(error: impl std::fmt::Display) -> Error {
Error::new(ErrorKind::TransientFileNotAvailable, error.to_string())
}
async fn authorize(session: &SessionContext, action: S3Action, bucket: &str, key: &str) -> Result<()> {
authorize_operation(session, &action, bucket, Some(key))
.await
.map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))
}
fn next_part_number(completed: usize) -> Result<i32> {
let completed = i32::try_from(completed).map_err(upload_error)?;
if completed >= MAX_PARTS {
return Err(Error::new(
ErrorKind::ExceededStorageAllocationError,
"FTPS multipart upload exceeds 10000 parts",
));
}
Ok(completed + 1)
}
async fn read_part(reader: &mut (impl AsyncRead + Unpin)) -> Result<Vec<u8>> {
let mut part = Vec::with_capacity(PART_SIZE);
reader
.take(u64::try_from(PART_SIZE).map_err(upload_error)?)
.read_to_end(&mut part)
.await
.map_err(upload_error)?;
Ok(part)
}
fn body(part: Vec<u8>) -> StreamingBlob {
StreamingBlob::from_bytes(Bytes::from(part))
}
// Keep the upload ID alive across all cancellable awaits after CreateMultipartUpload.
// A process/runtime crash still requires an AbortIncompleteMultipartUpload lifecycle rule.
struct PendingUpload<S: StorageBackend + 'static> {
storage: Arc<S>,
session: SessionContext,
input: Option<AbortMultipartUploadInput>,
}
impl<S: StorageBackend + 'static> PendingUpload<S> {
async fn abort(&mut self) {
if let Some(input) = self.input.as_ref() {
abort_upload(&*self.storage, &self.session, input.clone()).await;
self.input = None;
}
}
}
async fn abort_upload<S: StorageBackend>(storage: &S, session: &SessionContext, input: AbortMultipartUploadInput) {
let cleanup = async {
authorize(session, S3Action::AbortMultipartUpload, &input.bucket, &input.key).await?;
storage
.abort_multipart_upload(input, session.credentials())
.await
.map_err(upload_error)?;
Ok::<_, Error>(())
};
if !matches!(tokio::time::timeout(ABORT_TIMEOUT, cleanup).await, Ok(Ok(()))) {
tracing::warn!(
event = EVENT_FTPS_UPLOAD_CLEANUP,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_FTPS_UPLOAD,
result = "abort_failed",
"FTPS incomplete upload requires lifecycle cleanup"
);
}
}
impl<S: StorageBackend + 'static> Drop for PendingUpload<S> {
fn drop(&mut self) {
let Some(input) = self.input.take() else {
return;
};
let (Ok(runtime), Ok(permit)) =
(tokio::runtime::Handle::try_current(), Arc::clone(&ABORT_PERMITS).try_acquire_owned())
else {
tracing::warn!(
event = EVENT_FTPS_UPLOAD_CLEANUP,
component = LOG_COMPONENT_PROTOCOLS,
subsystem = LOG_SUBSYSTEM_FTPS_UPLOAD,
result = "abort_unavailable",
"FTPS incomplete upload requires lifecycle cleanup"
);
return;
};
let storage = Arc::clone(&self.storage);
let session = self.session.clone();
runtime.spawn(async move {
let _permit = permit;
abort_upload(&*storage, &session, input).await;
});
}
}
pub(super) async fn upload<S: StorageBackend + 'static>(
storage: Arc<S>,
session: &SessionContext,
mut reader: impl AsyncRead + Unpin,
bucket: &str,
key: &str,
) -> Result<u64> {
let mut part = read_part(&mut reader).await?;
if part.len() < PART_SIZE {
let size = u64::try_from(part.len()).map_err(upload_error)?;
authorize(session, S3Action::PutObject, bucket, key).await?;
let input = PutObjectInput::builder()
.bucket(bucket.to_owned())
.key(key.to_owned())
.content_length(Some(i64::try_from(size).map_err(upload_error)?))
.body(Some(body(part)))
.build()
.map_err(upload_error)?;
storage.put_object(input, session.credentials()).await.map_err(upload_error)?;
return Ok(size);
}
authorize(session, S3Action::CreateMultipartUpload, bucket, key).await?;
let input = CreateMultipartUploadInput::builder()
.bucket(bucket.to_owned())
.key(key.to_owned())
.build()
.map_err(upload_error)?;
let output = storage
.create_multipart_upload(input, session.credentials())
.await
.map_err(upload_error)?;
let upload_id = output
.upload_id
.filter(|id| !id.is_empty())
.ok_or_else(|| upload_error("CreateMultipartUpload returned no upload ID"))?;
let abort = AbortMultipartUploadInput::builder()
.bucket(bucket.to_owned())
.key(key.to_owned())
.upload_id(upload_id.clone())
.build()
.map_err(upload_error)?;
let mut pending = PendingUpload {
storage: Arc::clone(&storage),
session: session.clone(),
input: Some(abort),
};
let result = async {
let mut parts = Vec::new();
let mut total = 0u64;
while !part.is_empty() {
let number = next_part_number(parts.len())?;
let length = i64::try_from(part.len()).map_err(upload_error)?;
total = total
.checked_add(u64::try_from(part.len()).map_err(upload_error)?)
.ok_or_else(|| upload_error("FTPS upload size overflow"))?;
authorize(session, S3Action::UploadPart, bucket, key).await?;
let input = UploadPartInput::builder()
.bucket(bucket.to_owned())
.key(key.to_owned())
.upload_id(upload_id.clone())
.part_number(number)
.content_length(Some(length))
.body(Some(body(part)))
.build()
.map_err(upload_error)?;
let output = storage
.upload_part(input, session.credentials())
.await
.map_err(upload_error)?;
let etag = output.e_tag.ok_or_else(|| upload_error("UploadPart returned no ETag"))?;
parts.push(CompletedPart {
e_tag: Some(etag),
part_number: Some(number),
..Default::default()
});
part = read_part(&mut reader).await?;
}
authorize(session, S3Action::CompleteMultipartUpload, bucket, key).await?;
let input = CompleteMultipartUploadInput::builder()
.bucket(bucket.to_owned())
.key(key.to_owned())
.upload_id(upload_id)
.multipart_upload(Some(CompletedMultipartUpload { parts: Some(parts) }))
.build()
.map_err(upload_error)?;
storage
.complete_multipart_upload(input, session.credentials())
.await
.map_err(upload_error)?;
// No await between a successful commit and disarming cancellation cleanup.
pending.input = None;
Ok(total)
}
.await;
if result.is_err() {
pending.abort().await;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
common::{
dummy_storage::{DummyBackend, DummyError},
gateway::{is_operation_supported, with_test_auth_override},
session::{Protocol, test_session},
},
ftps::{driver::FtpsDriver, server::FtpsUser},
};
use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::{io::ReadBuf, sync::Notify};
use unftp_core::storage::StorageBackend as _;
fn user() -> FtpsUser {
FtpsUser {
username: "upload-test".into(),
name: None,
session_context: test_session(Protocol::Ftps),
}
}
fn multipart_backend() -> DummyBackend {
let backend = DummyBackend::new();
backend.queue_create_multipart_upload_ok("upload-1");
backend.queue_complete_multipart_upload_ok();
backend
}
// Refuse to produce part N+1 before part N has reached the backend. The old
// read-to-EOF implementation fails this without allocating a huge fixture.
struct PacedReader {
backend: DummyBackend,
position: usize,
length: usize,
fail_at_end: bool,
}
impl AsyncRead for PacedReader {
fn poll_read(mut self: Pin<&mut Self>, _: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if self.position / PART_SIZE > self.backend.upload_part_calls().len() {
return Poll::Ready(Err(std::io::Error::other("read ahead of uploaded part")));
}
if self.position == self.length && self.fail_at_end {
return Poll::Ready(Err(std::io::Error::other("interrupted source")));
}
let count = buf.remaining().min(self.length - self.position).min(8191);
let value = u8::try_from(self.position / PART_SIZE).expect("small test part number");
// Do not cross a part boundary in a single read, so each part has a distinct byte.
let count = count.min(PART_SIZE - self.position % PART_SIZE);
buf.put_slice(&vec![value; count]);
self.position += count;
Poll::Ready(Ok(()))
}
}
#[test]
fn ftps_part_numbers_stop_at_s3_limit() {
assert_eq!(next_part_number(0).expect("first part"), 1);
assert_eq!(next_part_number(9_999).expect("last part"), 10_000);
assert!(next_part_number(10_000).is_err());
assert!(next_part_number(usize::MAX).is_err());
}
#[tokio::test]
async fn ftps_part_buffer_does_not_grow_to_detect_eof() {
let mut reader = tokio::io::repeat(0).take(u64::try_from(PART_SIZE).expect("size"));
let part = read_part(&mut reader).await.expect("read full part");
assert_eq!(part.len(), PART_SIZE);
assert_eq!(part.capacity(), PART_SIZE);
assert!(read_part(&mut reader).await.expect("EOF").is_empty());
}
#[tokio::test]
async fn ftps_small_and_empty_uploads_preserve_bytes() {
for payload in [Vec::new(), b"small upload\x00\xff".to_vec()] {
let backend = DummyBackend::new();
backend.capture_upload_bodies();
let driver = FtpsDriver::new(backend.clone());
let result = with_test_auth_override(
|_, _, _| true,
driver.put(&user(), std::io::Cursor::new(payload.clone()), "/bucket/key", 0),
)
.await
.expect("STOR");
assert_eq!(result, u64::try_from(payload.len()).expect("size"));
assert_eq!(backend.upload_bodies(), vec![payload]);
assert!(backend.create_multipart_calls().is_empty());
}
}
#[tokio::test]
async fn ftps_upload_streams_before_eof_and_preserves_part_bytes() {
for length in [PART_SIZE - 1, PART_SIZE, PART_SIZE + 1, 2 * PART_SIZE, 2 * PART_SIZE + 7] {
let backend = multipart_backend();
backend.capture_upload_bodies();
for n in 1..=3 {
backend.queue_upload_part_ok(format!("etag-{n}"));
}
let driver = FtpsDriver::new(backend.clone());
let reader = PacedReader {
backend: backend.clone(),
position: 0,
length,
fail_at_end: false,
};
let size = with_test_auth_override(|_, _, _| true, driver.put(&user(), reader, "/bucket/key", 0))
.await
.expect("bounded STOR");
assert_eq!(size, u64::try_from(length).expect("size"));
let bodies = backend.upload_bodies();
assert_eq!(bodies.iter().map(Vec::len).sum::<usize>(), length);
for (index, part) in bodies.iter().enumerate() {
assert!(part.len() <= PART_SIZE);
assert!(part.iter().all(|b| *b == u8::try_from(index).expect("index")));
}
if length >= PART_SIZE {
assert_eq!(backend.complete_multipart_calls()[0].part_count, length.div_ceil(PART_SIZE));
for (index, call) in backend.upload_part_calls().iter().enumerate() {
assert_eq!(call.part_number, i32::try_from(index + 1).expect("part"));
assert_eq!(call.content_length, Some(i64::try_from(bodies[index].len()).expect("length")));
}
}
assert!(backend.abort_multipart_calls().is_empty());
}
}
#[tokio::test]
async fn ftps_upload_aborts_on_source_part_or_complete_failure() {
for failure in ["source", "part", "etag", "complete"] {
let backend = DummyBackend::new();
backend.queue_create_multipart_upload_ok("upload-1");
match failure {
"part" => backend.queue_upload_part_err(DummyError::Injected("part failed".into())),
"etag" => backend.queue_upload_part_ok_without_etag(),
_ => backend.queue_upload_part_ok("etag-1"),
}
backend.queue_complete_multipart_upload_err(DummyError::Injected("complete failed".into()));
let driver = FtpsDriver::new(backend.clone());
let reader = PacedReader {
backend: backend.clone(),
position: 0,
length: PART_SIZE,
fail_at_end: failure == "source",
};
let result = with_test_auth_override(|_, _, _| true, driver.put(&user(), reader, "/bucket/key", 0)).await;
assert!(result.is_err(), "{failure} must not report a committed object");
assert_eq!(backend.abort_multipart_calls().len(), 1, "{failure}");
assert_eq!(backend.abort_multipart_calls()[0].upload_id, "upload-1");
assert_eq!(backend.complete_multipart_calls().len(), usize::from(failure == "complete"));
}
}
#[tokio::test]
async fn ftps_upload_enforces_authorization_at_each_mutation() {
for denied in [
S3Action::PutObject,
S3Action::CreateMultipartUpload,
S3Action::UploadPart,
S3Action::CompleteMultipartUpload,
] {
assert!(is_operation_supported(Protocol::Ftps, &denied));
let backend = multipart_backend();
backend.queue_upload_part_ok("etag-1");
let driver = FtpsDriver::new(backend.clone());
let reader = tokio::io::repeat(0).take(u64::try_from(PART_SIZE).expect("size"));
let denied_action = denied.clone();
let result = with_test_auth_override(
move |action, _, _| action != &denied_action,
driver.put(&user(), reader, "/bucket/key", 0),
)
.await;
assert!(result.is_err());
match denied {
S3Action::PutObject | S3Action::CreateMultipartUpload => assert!(backend.create_multipart_calls().is_empty()),
S3Action::UploadPart => assert!(backend.upload_part_calls().is_empty()),
_ => assert!(backend.complete_multipart_calls().is_empty()),
}
}
}
#[tokio::test]
async fn ftps_upload_cleanup_does_not_bypass_abort_permission() {
let backend = multipart_backend();
backend.queue_upload_part_err(DummyError::Injected("part failed".into()));
let driver = FtpsDriver::new(backend.clone());
let reader = tokio::io::repeat(0).take(u64::try_from(PART_SIZE).expect("size"));
let result = with_test_auth_override(
|action, _, _| action != &S3Action::AbortMultipartUpload,
driver.put(&user(), reader, "/bucket/key", 0),
)
.await;
assert!(result.is_err());
assert!(backend.abort_multipart_calls().is_empty());
}
#[tokio::test]
async fn ftps_cancelled_upload_aborts_pending_parts() {
let backend = multipart_backend();
let entered = Arc::new(Notify::new());
backend.stall_upload_part(Arc::clone(&entered));
let driver = FtpsDriver::new(backend.clone());
let user = user();
with_test_auth_override(|_, _, _| true, async {
let reader = tokio::io::repeat(0).take(u64::try_from(PART_SIZE * 2).expect("size"));
let mut put = Box::pin(driver.put(&user, reader, "/bucket/key", 0));
tokio::select! {
_ = entered.notified() => {},
result = &mut put => panic!("upload should stall: {result:?}"),
}
drop(put);
tokio::time::timeout(Duration::from_secs(5), async {
while backend.abort_multipart_calls().is_empty() {
tokio::task::yield_now().await;
}
})
.await
.expect("cancellation cleanup");
})
.await;
assert_eq!(backend.abort_multipart_calls().len(), 1);
assert!(backend.complete_multipart_calls().is_empty());
}
// Yield on each read so the memory probe keeps all upload buffers live at once.
struct YieldingReader {
remaining: u64,
yielded: bool,
}
impl AsyncRead for YieldingReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if self.remaining == 0 {
return Poll::Ready(Ok(()));
}
if !self.yielded {
self.yielded = true;
cx.waker().wake_by_ref();
return Poll::Pending;
}
self.yielded = false;
let count = buf
.remaining()
.min(1024 * 1024)
.min(usize::try_from(self.remaining).unwrap_or(usize::MAX));
buf.initialize_unfilled_to(count).fill(0x5a);
buf.advance(count);
self.remaining -= u64::try_from(count).expect("read size");
Poll::Ready(Ok(()))
}
}
#[tokio::test]
#[ignore = "manual synthetic concurrent-upload memory measurement"]
async fn ftps_large_upload_memory_probe() {
let bytes: u64 = std::env::var("RUSTFS_FTPS_TEST_BYTES")
.unwrap_or_else(|_| "67108864".into())
.parse()
.expect("byte count");
let concurrency: usize = std::env::var("RUSTFS_FTPS_TEST_CONCURRENCY")
.unwrap_or_else(|_| "10".into())
.parse()
.expect("concurrency");
assert!(bytes > 0 && concurrency > 0);
let operations = (0..concurrency).map(|_| async {
let backend = multipart_backend();
for n in 0..bytes.div_ceil(u64::try_from(PART_SIZE).expect("part size")) {
backend.queue_upload_part_ok(format!("part-{n}"));
}
let driver = FtpsDriver::new(backend);
let reader = YieldingReader {
remaining: bytes,
yielded: false,
};
driver.put(&user(), reader, "/bucket/key", 0).await
});
let results = with_test_auth_override(|_, _, _| true, futures_util::future::join_all(operations)).await;
for result in results {
assert_eq!(result.expect("upload"), bytes);
}
println!("synthetic_upload_bytes={bytes} concurrent_uploads={concurrency}");
}
}
}
+112
View File
@@ -57,6 +57,7 @@ const EXCESSIVE_EMPTY_CHUNKS_ERROR: &str = "HTTP body returned too many empty ch
pub const INTERNODE_DISK_ERROR_HEADER: &str = "x-rustfs-disk-error";
pub const INTERNODE_FILE_NOT_FOUND: &str = "file-not-found";
pub const INTERNODE_VOLUME_NOT_FOUND: &str = "volume-not-found";
pub const INTERNODE_FILE_CORRUPT: &str = "file-corrupt";
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum InternodeHttpErrorKind {
@@ -185,6 +186,7 @@ pub struct InternodeHttpError {
enum RemoteDiskErrorKind {
FileNotFound,
VolumeNotFound,
FileCorrupt,
}
impl std::fmt::Debug for InternodeHttpError {
@@ -215,6 +217,10 @@ impl InternodeHttpError {
self.remote_disk_error == Some(RemoteDiskErrorKind::VolumeNotFound)
}
pub fn is_remote_file_corrupt(&self) -> bool {
self.remote_disk_error == Some(RemoteDiskErrorKind::FileCorrupt)
}
fn new(kind: InternodeHttpErrorKind, context: InternodeHttpRequestContext) -> Self {
Self {
kind,
@@ -328,6 +334,20 @@ pub fn new_test_remote_volume_not_found_http_io_error() -> io::Error {
.into_io_error()
}
#[doc(hidden)]
pub fn new_test_remote_file_corrupt_http_io_error() -> io::Error {
InternodeHttpError::with_remote_disk_error(
InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
InternodeHttpRequestContext {
method: "GET".to_string(),
target: READ_FILE_STREAM_PATH.to_string(),
operation: Some(INTERNODE_OPERATION_READ_FILE_STREAM),
},
RemoteDiskErrorKind::FileCorrupt,
)
.into_io_error()
}
fn add_root_certificates_from_der(builder: reqwest::ClientBuilder, certs_der: &[Vec<u8>]) -> reqwest::ClientBuilder {
let mut b = builder;
for der in certs_der {
@@ -850,6 +870,7 @@ fn classify_http_response(
let remote_disk_error = match headers.get(INTERNODE_DISK_ERROR_HEADER).and_then(|value| value.to_str().ok()) {
Some(INTERNODE_FILE_NOT_FOUND) => Some(RemoteDiskErrorKind::FileNotFound),
Some(INTERNODE_VOLUME_NOT_FOUND) => Some(RemoteDiskErrorKind::VolumeNotFound),
Some(INTERNODE_FILE_CORRUPT) => Some(RemoteDiskErrorKind::FileCorrupt),
_ => None,
};
ClassifiedHttpResponse { kind, remote_disk_error }
@@ -2047,6 +2068,7 @@ mod tests {
assert_eq!(INTERNODE_DISK_ERROR_HEADER, "x-rustfs-disk-error");
assert_eq!(INTERNODE_FILE_NOT_FOUND, "file-not-found");
assert_eq!(INTERNODE_VOLUME_NOT_FOUND, "volume-not-found");
assert_eq!(INTERNODE_FILE_CORRUPT, "file-corrupt");
}
#[test]
@@ -2090,6 +2112,48 @@ mod tests {
assert!(wrong_operation.remote_disk_error.is_none());
}
#[test]
fn classify_http_response_scopes_corrupt_disk_error_to_read_failures() {
let mut headers = HeaderMap::new();
headers.insert(INTERNODE_DISK_ERROR_HEADER, INTERNODE_FILE_CORRUPT.parse().expect("valid error token"));
let classified = classify_http_response(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
&headers,
Some(INTERNODE_OPERATION_READ_FILE_STREAM),
);
assert_eq!(classified.remote_disk_error, Some(RemoteDiskErrorKind::FileCorrupt));
for status in [reqwest::StatusCode::NOT_FOUND, reqwest::StatusCode::SERVICE_UNAVAILABLE] {
assert!(
classify_http_response(status, &headers, Some(INTERNODE_OPERATION_READ_FILE_STREAM))
.remote_disk_error
.is_none()
);
}
for operation in [
None,
Some(INTERNODE_OPERATION_WALK_DIR),
Some(INTERNODE_OPERATION_PUT_FILE_STREAM),
] {
assert!(
classify_http_response(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &headers, operation)
.remote_disk_error
.is_none()
);
}
headers.insert(INTERNODE_DISK_ERROR_HEADER, "unknown-disk-error".parse().expect("valid unknown token"));
for headers in [&headers, &HeaderMap::new()] {
assert!(
classify_http_response(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
headers,
Some(INTERNODE_OPERATION_READ_FILE_STREAM),
)
.remote_disk_error
.is_none()
);
}
}
#[derive(Clone, Default)]
struct TestState {
head_count: Arc<AtomicUsize>,
@@ -2158,6 +2222,16 @@ mod tests {
let addr = listener.local_addr().expect("listener local address should be available");
let app = Router::new()
.route("/stream", get(get_stream).head(reject_head).put(accept_put))
.route(
READ_FILE_STREAM_PATH,
get(|| async {
(
StatusCode::INTERNAL_SERVER_ERROR,
[(INTERNODE_DISK_ERROR_HEADER, INTERNODE_FILE_CORRUPT)],
"read file err file corrupt",
)
}),
)
.route(WALK_DIR_PATH, get(get_stream))
.route("/reject-put", get(get_stream).put(reject_put))
.route("/stall", get(get_stalling_stream))
@@ -2172,6 +2246,44 @@ mod tests {
Some((format!("http://{addr}/stream"), handle))
}
#[tokio::test]
async fn http_readers_preserve_remote_file_corruption() {
let (url, server) = start_test_server(TestState::default())
.await
.expect("corruption regression server must bind");
let url = format!("{}{READ_FILE_STREAM_PATH}", url.strip_suffix("/stream").expect("test server URL suffix"));
let byte_error = HttpReader::new(url.clone(), Method::GET, HeaderMap::new(), None)
.await
.err()
.expect("byte reader must reject corrupt shard response");
let chunk_error = HttpChunkReader::new_with_stall_timeout(url, Method::GET, HeaderMap::new(), None, None)
.await
.err()
.expect("chunk reader must reject corrupt shard response");
for error in [byte_error, chunk_error] {
let source = error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
.expect("HTTP error must retain typed internode source");
assert!(source.is_remote_file_corrupt());
assert!(!source.is_remote_file_not_found());
assert!(!source.is_remote_volume_not_found());
assert_eq!(
source.kind(),
InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
let cloned = clone_internode_http_io_error(&error).expect("typed transport error must be cloneable");
assert!(
cloned
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
.expect("clone must retain typed internode source")
.is_remote_file_corrupt()
);
}
server.abort();
}
struct BlockedH2Server {
url: String,
shutdown: tokio::sync::oneshot::Sender<()>,
+33 -6
View File
@@ -83,13 +83,11 @@ impl ProviderVersionCapabilities {
if tier_type.eq_ignore_ascii_case("s3")
|| tier_type.eq_ignore_ascii_case("rustfs")
|| tier_type.eq_ignore_ascii_case("minio")
|| tier_type.eq_ignore_ascii_case("r2")
|| tier_type.eq_ignore_ascii_case("wasabi")
{
let list_object_versions = tier_type.eq_ignore_ascii_case("s3")
|| tier_type.eq_ignore_ascii_case("rustfs")
|| tier_type.eq_ignore_ascii_case("minio")
|| tier_type.eq_ignore_ascii_case("r2");
|| tier_type.eq_ignore_ascii_case("minio");
Self {
raw_version_header: Some(X_AMZ_VERSION_ID),
bucket_versioning_state: list_object_versions,
@@ -101,6 +99,17 @@ impl ProviderVersionCapabilities {
},
exact_get_delete: true,
}
} else if tier_type.eq_ignore_ascii_case("r2") {
// R2 exposes an x-amz-version-id on some PUT responses, but it does
// not provide routable S3 object versions. GET/DELETE must therefore
// use the unversioned object path and must not call version APIs.
Self {
raw_version_header: None,
bucket_versioning_state: false,
list_object_versions: false,
conditional_create: ConditionalCreateCapability::IfNoneMatchStar,
exact_get_delete: false,
}
} else if tier_type.eq_ignore_ascii_case("aliyun") {
Self {
raw_version_header: Some(X_OSS_VERSION_ID),
@@ -210,8 +219,6 @@ mod tests {
("RustFS", "x-amz-version-id"),
("minio", "x-amz-version-id"),
("MinIO", "x-amz-version-id"),
("r2", "x-amz-version-id"),
("R2", "x-amz-version-id"),
("wasabi", "x-amz-version-id"),
("Wasabi", "x-amz-version-id"),
("aliyun", "x-oss-version-id"),
@@ -255,6 +262,26 @@ mod tests {
);
}
#[test]
fn r2_ignores_non_routable_put_version_header() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-version-id", HeaderValue::from_static("r2-response-token"));
let capabilities = ProviderVersionCapabilities::for_tier_type("r2");
assert_eq!(
capabilities
.raw_version_id(&headers)
.expect("R2 header parsing should succeed"),
None
);
assert_eq!(
capabilities
.remote_version(&headers, BucketVersioningState::Disabled)
.expect("R2 uses unversioned routing"),
RemoteVersion::Disabled
);
}
#[test]
fn provider_version_missing_header_is_unknown_until_bucket_state_is_known() {
let headers = HeaderMap::new();
@@ -280,7 +307,7 @@ mod tests {
("s3", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
("rustfs", true, true, ConditionalCreateCapability::Unsupported, true),
("minio", true, true, ConditionalCreateCapability::Unsupported, true),
("r2", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
("r2", false, false, ConditionalCreateCapability::IfNoneMatchStar, false),
("wasabi", false, false, ConditionalCreateCapability::Unsupported, true),
("aliyun", false, false, ConditionalCreateCapability::Unsupported, true),
("tencent", false, false, ConditionalCreateCapability::Unsupported, true),
+1 -1
View File
@@ -34,7 +34,7 @@ metrics = { workspace = true }
rmp-serde = { workspace = true }
rustfs-heal-contracts = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["sync"] }
tokio = { workspace = true, features = ["sync","rt"] }
[dev-dependencies]
serde_json = { workspace = true }
+73 -14
View File
@@ -910,11 +910,26 @@ impl DataUsageCache {
if self.info.leader_epoch > leader_epoch {
return DataUsageCachePrepareOutcome::RejectedNewerLeader;
}
// A checkpoint is a durable traversal frontier, not a publication
// result. A newer leader may adopt it only when the frontier is
// independently fenced by the bucket identity and has a validated
// cursor/coverage receipt. Caches without that proof still take the
// normal rebuild path; this keeps an old, incomplete writer from
// authorizing a new leader to skip namespace coverage.
let cross_epoch_checkpoint = self.info.leader_epoch < leader_epoch
&& self.info.next_cycle == next_cycle
&& self.info.scan_identity == Some(identity)
&& (self.validated_scan_frontier().is_some()
|| self.validated_raw_enumeration_cursor().is_some()
|| self.validated_raw_enumeration_page_index().is_some());
let handoff_frontier = cross_epoch_checkpoint
.then(|| self.validated_scan_frontier().map(str::to_owned))
.flatten();
let reusable = identity.is_valid()
&& name != DATA_USAGE_ROOT
&& self.info.name == name
&& self.info.source == Some(source)
&& self.info.leader_epoch == leader_epoch
&& (self.info.leader_epoch == leader_epoch || cross_epoch_checkpoint)
&& self.info.cache_key_format == DATA_USAGE_CACHE_KEY_FORMAT
&& self.info.scan_identity == Some(identity)
&& self.info.tier_registry_generation == Some(identity.tier_registry_generation)
@@ -974,6 +989,15 @@ impl DataUsageCache {
self.info.tier_registry_generation = Some(identity.tier_registry_generation);
self.info.scan_identity = Some(identity);
self.info.snapshot_complete = false;
if let Some(frontier) = handoff_frontier
&& let Ok(digest) = self.coverage_prefix_digest(&frontier)
&& let Some(receipt) = self.info.scan_coverage_receipt.as_mut()
{
// Migrate a legacy epoch-bound receipt while the old epoch is
// still available for validation. New receipts deliberately do
// not bind to the process-local leader epoch.
receipt.digest = digest;
}
if let Some(progress) = &mut self.info.scan_progress {
progress.requested_plan = scan_plan_digest;
} else {
@@ -998,19 +1022,53 @@ impl DataUsageCache {
}
fn coverage_prefix_digest(&self, through: &str) -> Result<[u8; 32], serde_json::Error> {
self.coverage_prefix_digest_with_epoch(through, None)
}
fn legacy_coverage_prefix_digest(&self, through: &str) -> Result<[u8; 32], serde_json::Error> {
self.coverage_prefix_digest_with_epoch(through, Some(self.info.leader_epoch))
}
fn coverage_prefix_digest_with_epoch(
&self,
through: &str,
legacy_leader_epoch: Option<u64>,
) -> Result<[u8; 32], serde_json::Error> {
let mut writer = CheckpointDigestWriter(Sha256::new());
serde_json::to_writer(
&mut writer,
&(
&self.info.name,
self.info.scan_identity,
self.info.source,
self.info.leader_epoch,
self.info.cache_key_format,
self.info.scan_progress.map(|progress| progress.started_plan),
through,
),
)?;
if let Some(leader_epoch) = legacy_leader_epoch {
// Keep the old tuple available for one-way migration of caches
// written before leader handoff recovery was introduced.
serde_json::to_writer(
&mut writer,
&(
&self.info.name,
self.info.scan_identity,
self.info.source,
leader_epoch,
self.info.cache_key_format,
self.info.scan_progress.map(|progress| progress.started_plan),
through,
),
)?;
} else {
// The receipt must survive a scanner leader handoff. The bucket
// identity and publication epoch below fence the namespace; the
// process-local leader epoch is intentionally excluded because it
// changes during a safe handoff. The domain version also keeps
// old epoch-bound receipts from colliding with this digest.
serde_json::to_writer(
&mut writer,
&(
2u8,
&self.info.name,
self.info.scan_identity,
self.info.source,
self.info.cache_key_format,
self.info.scan_progress.map(|progress| progress.started_plan),
through,
),
)?;
}
let mut prefix = self
.cache
.iter()
@@ -1049,7 +1107,8 @@ impl DataUsageCache {
.strip_prefix(&self.info.name)
.is_some_and(|suffix| suffix.starts_with('/'))
&& self.find(&receipt.through).is_some()
&& self.coverage_prefix_digest(&receipt.through).ok() == Some(receipt.digest))
&& (self.coverage_prefix_digest(&receipt.through).ok() == Some(receipt.digest)
|| self.legacy_coverage_prefix_digest(&receipt.through).ok() == Some(receipt.digest)))
.then_some(receipt.through.as_str())
}
@@ -135,6 +135,15 @@ impl DataUsageCache {
Ok(DataUsageCacheRevisions { main, backup })
}
pub(crate) async fn read_revisions<S: ScannerObjectIO>(store: Arc<S>, name: &str) -> StorageResult<DataUsageCacheRevisions> {
let main_path = path_join_buf(&[BUCKET_META_PREFIX, name]);
let backup_name = format!("{name}.bkp");
let backup_path = path_join_buf(&[BUCKET_META_PREFIX, &backup_name]);
let main = read_config_revision(store.clone(), &main_path).await?;
let backup = Some(read_config_revision(store, &backup_path).await?);
Ok(DataUsageCacheRevisions { main, backup })
}
async fn load_cache<S: ScannerObjectIO>(store: Arc<S>, name: &str) -> StorageResult<DataUsageCacheLoadResult> {
let mut last_retryable = None;

Some files were not shown because too many files have changed in this diff Show More