Compare commits

..

102 Commits

Author SHA1 Message Date
hector 9b4b366209 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).
2026-09-09 21:30:37 +08:00
houseme 43436ad5a7 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>
2026-09-09 20:01:42 +08:00
Zhengchao An 0c7b6188b2 fix: reuse admin error boundary for legacy tier reconciliation (#7587) 2026-09-09 19:57:34 +08:00
houseme 3ffc3704af test(scanner): add release evidence ingestion gates (#7588)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 19:56:43 +08:00
houseme de3ac27a6a chore(release): merge main into release
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 19:33:59 +08:00
唐小鸭 27d66c159f fix(kms): prevent transient health failures from latching status (#7578)
Keep backend health checks from overwriting the running service lifecycle state so subsequent admin checks and probe-based readiness can recover without a restart.

Add a regression test that fails on the original implementation after backend recovery and verifies the service instance and version remain unchanged.

Validation: 39 focused resilience, lifecycle, concurrency, and service manager tests passed; one existing live AWS test remained ignored. cargo fmt --all --check and git diff --check passed.

Thanks to @stevapple for reporting the issue and providing a detailed diagnosis and reproduction.

Fixes #7554
2026-09-09 11:05:26 +00:00
cxymds 2d0e82e9f7 feat(ilm): inspect legacy transition version state (#7581) 2026-09-09 19:01:01 +08:00
cxymds c92fabea3a fix(ecstore): avoid Windows batch-delete cleanup sharing conflicts (#7579)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-09 19:00:39 +08:00
cxymds b3b0d892bb fix(metrics): prevent duplicate drive reporting across nodes (#7582)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-09 19:00:20 +08:00
houseme d9ae1654b6 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>
2026-09-09 18:40:43 +08:00
hector 15949180c9 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.
2026-09-09 18:33:16 +08:00
houseme f65305e97f 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>
2026-09-09 18:31:46 +08:00
houseme 28ac3ab4e7 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>
2026-09-09 18:30:08 +08:00
houseme cf41b1d3cf 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>
2026-09-09 18:12:06 +08:00
houseme ca876930b3 test(scanner): bind profile artifact provenance (#7577) 2026-09-09 17:01:32 +08:00
Zhengchao An c05b3ee01e 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>
2026-09-09 16:43:51 +08:00
cxymds 1076e2fb4f fix(health): report storage quorum in node readiness (#7566) 2026-09-09 16:41:11 +08:00
cxymds 51e6733a9c 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>
2026-09-09 16:40:43 +08:00
hector e546ae9c62 fix(nightly): push assets with plain git — the build fleet has no gh CLI (#7572)
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.
2026-09-09 16:39:59 +08:00
houseme ed2b2cdd19 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>
2026-09-09 16:39:46 +08:00
JaySon 35b5cfcf8d docs: fix invalid docker-buildx.sh usage example in README and README_ZH (#7570)
docs: fix invalid docker-buildx.sh usage in README and README_ZH

Replace the docker-buildx.sh --build-arg RELEASE=latest example, which
the script never accepted as a CLI flag (--build-arg is only used
internally for docker buildx build), with the supported invocations:

- bare ./docker-buildx.sh for the default local build
- ./docker-buildx.sh -p linux/amd64 for a single-platform local build

Also update the surrounding comments to reflect single-platform local
builds and add the multi-arch example comment accordingly.
2026-09-09 16:39:25 +08:00
cxymds 084477e079 fix(ci): align S3 KMS fixtures and default key expectations (#7573) 2026-09-09 16:39:07 +08:00
Zhengchao An bb1b5dea16 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
2026-09-09 16:32:17 +08:00
houseme 1c87413788 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>
2026-09-09 16:22:26 +08:00
houseme e1fee0569a 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>
2026-09-09 16:22:15 +08:00
cxymds a6e5bdcbd2 docs(obs): correct metric export interval and explain alert timing (#7571) 2026-09-09 15:26:58 +08:00
hector 923f51cf12 chore(release): sync configurable nightly build workflow from main (#7567)
feat(nightly): make the build branch configurable (NIGHTLY_BRANCH var + dispatch input) (#7557)
2026-09-09 15:26:44 +08:00
cxymds d7c2fc7587 fix(ecstore): apply read quorum to bucket validation (#7556)
* fix(ecstore): apply read quorum to bucket validation

* fix(e2e): remove needless borrow in quorum test
2026-09-09 14:50:28 +08:00
Zhengchao An 7db206466b test(ci): reserve runner capacity for bounded rollback probe (#7560) 2026-09-09 14:24:57 +08:00
hector e549252ac6 feat(nightly): make the build branch configurable (NIGHTLY_BRANCH var + dispatch input) (#7557) 2026-09-09 14:24:44 +08:00
Zhengchao An 17807b05fb fix(ecstore): preserve internode error context when cloning (#7548) 2026-09-09 14:24:23 +08:00
cxymds e16274ab14 fix(s3): preserve delete marker read error headers (#7558) 2026-09-09 14:21:11 +08:00
houseme 5eecd657e7 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>
2026-09-09 14:20:16 +08:00
houseme 929d906131 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>
2026-09-09 14:19:26 +08:00
houseme 6b802a91eb 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>
2026-09-09 14:19:09 +08:00
cxymds 14b3cffe8f Merge branch 'main' into release
* main:
  fix(rebalance): align activation locks and preserve retryable causes (#7551)
2026-09-09 12:32:55 +08:00
houseme c62384e58d 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>
2026-09-09 12:08:06 +08:00
cxymds 789f1832a4 fix(rebalance): align activation locks and preserve retryable causes (#7551) 2026-09-09 03:25:05 +00:00
houseme 3f30f6c139 test(heal): reduce W13 evidence helper arguments
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:26:59 +08:00
houseme 584a52ce3d test(scanner): fix segment proof fixtures
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:22:13 +08:00
houseme 76e025a50c 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>
2026-09-09 10:08:59 +08:00
houseme b40eb193df 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>
2026-09-09 10:08:49 +08:00
houseme 023c987397 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>
2026-09-09 10:00:06 +08:00
houseme a4b265bf77 feat(scanner): enable segment reuse activation gate (#7546)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 09:09:17 +08:00
houseme c176220a34 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>
2026-09-09 08:34:55 +08:00
houseme 82ac2dff05 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>
2026-09-09 08:34:50 +08:00
Zhengchao An e24eae9eaa fix: address release acceptance regressions (#7541) 2026-09-09 08:33:59 +08:00
houseme 8617f2701b 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>
2026-09-09 08:26:13 +08:00
houseme 8d5592f83e 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>
2026-09-09 08:24:02 +08:00
houseme b5c9229e77 chore: integrate main CI gates into release
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 08:02:35 +08:00
houseme a974e50b1d test(scanner): wait for EC8+4 heal control readiness (#7540) 2026-09-09 05:17:13 +08:00
houseme 081910e825 feat(scanner): persist segment invalidation proof metadata (#7539) 2026-09-09 05:17:03 +08:00
Zhengchao An a722fa80d5 fix(ci): require every selected validation lane before merge (#7529) 2026-09-09 05:16:48 +08:00
Zhengchao An 9ecb500cbf test(odm): wait for pull counters before status assertions (#7533) 2026-09-08 17:35:43 +00:00
houseme 7f7e4fe40b 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>
2026-09-09 01:11:05 +08:00
houseme 684e6f313a 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>
2026-09-09 01:10:48 +08:00
houseme b3a02f305e feat(scanner): surface cold segment reuse oracle (#7536)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:48:45 +08:00
houseme 0ea9c19569 feat(scanner): carry segment activation preflight evidence (#7535)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:25:46 +08:00
houseme ae662da256 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>
2026-09-09 00:25:37 +08:00
houseme b5d33a1f4e 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>
2026-09-09 00:21:39 +08:00
houseme d288496752 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>
2026-09-09 00:13:56 +08:00
houseme 40baedba4f Merge branches 'release', 'release' and 'main' of github.com:rustfs/rustfs into release
* 'release' of github.com:rustfs/rustfs:
  test(scanner): emit G09 release bundle gate evidence (#7530)
  test(e2e): add G09 upgrade evidence runner (#7525)
  fix(scanner): respect cargo target dir in G09 runner (#7528)
  test(scanner): preflight G09 evidence disk space (#7527)
  fix(scanner): replay recovery intents while disabled (#7521)
  test(scanner): add G09 upgrade evidence runner (#7524)
  test(scanner): assemble release evidence bundles (#7526)
  feat(scanner): implement V2 evidence preflights (#7523)
  test(scanner): add G09 upgrade evidence runner (#7522)

* 'release' of github.com:rustfs/rustfs:
  test(scanner): emit G09 release bundle gate evidence (#7530)
  test(e2e): add G09 upgrade evidence runner (#7525)
  fix(scanner): respect cargo target dir in G09 runner (#7528)
  test(scanner): preflight G09 evidence disk space (#7527)
  fix(scanner): replay recovery intents while disabled (#7521)
  test(scanner): add G09 upgrade evidence runner (#7524)
  test(scanner): assemble release evidence bundles (#7526)
  feat(scanner): implement V2 evidence preflights (#7523)
  test(scanner): add G09 upgrade evidence runner (#7522)

* 'main' of github.com:rustfs/rustfs:
  fix(targets): reject trailing batch items (#7508)
  fix(ci): bind performance runs to selected inputs (#7512)
  fix(replication): close the GA blocker set from backlog#2366 (#7503)
2026-09-09 00:10:33 +08:00
houseme 0c3fd48c22 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>
2026-09-09 00:06:59 +08:00
houseme 3e8b0e7c83 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>
2026-09-09 00:05:36 +08:00
houseme dd2af9ff88 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>
2026-09-08 23:58:20 +08:00
houseme 5db0e14d04 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>
2026-09-08 23:58:08 +08:00
houseme 9429225cf4 fix(scanner): replay recovery intents while disabled (#7521)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:50:13 +08:00
houseme 857584b3c2 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>
2026-09-08 23:50:02 +08:00
houseme cfda9b451c test(scanner): assemble release evidence bundles (#7526)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:49:49 +08:00
houseme 354e49de4c 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>
2026-09-08 23:39:05 +08:00
houseme 8e987ce0a6 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>
2026-09-08 23:38:54 +08:00
cui fliter 8462b3492b fix(targets): reject trailing batch items (#7508) 2026-09-08 15:32:24 +00:00
houseme 929f836e40 test(heal): persist MRF rollback mirror boundary (#7520)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:22:41 +08:00
houseme c7dec044eb 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>
2026-09-08 23:18:36 +08:00
Zhengchao An ac44f8968e fix(ci): bind performance runs to selected inputs (#7512) 2026-09-08 23:16:54 +08:00
houseme 14a59f7770 test(scanner): require bounded retry window evidence (#7517)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:10:02 +08:00
houseme 084e9c4b82 test(scanner): require MRF cleanup delete ENOSPC evidence (#7518)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:59:32 +08:00
唐小鸭 46907c05cf 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.
2026-09-08 14:58:41 +00:00
houseme 921cc7ad94 fix(scanner): confirm recovery intent accept readback (#7516)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:53:21 +08:00
houseme bfa8df00e7 test(scanner): add release bundle dry-run fixture (#7515)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:53:04 +08:00
houseme 33a8469e27 test(scanner): require heal retry stats evidence (#7514)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:52:34 +08:00
houseme 128080ceb9 test(storage): cover MRF cleanup delete ENOSPC anchor (#7513)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:52:12 +08:00
唐小鸭 ae9fe62fb1 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.
2026-09-08 22:37:53 +08:00
overtrue 929a9f27c4 chore(release): merge main into release
Merge main at 73957d0faf into release. Resolve the multipart test conflict with the current main fixture and remove its superseded ReadPlan test export.
2026-09-08 22:28:29 +08:00
houseme 15376a7fa6 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>
2026-09-08 22:25:04 +08:00
houseme 5d789006dd test(heal): cover MRF disk-full commit anchors (#7509)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:21:31 +08:00
houseme f4bd53a63c 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>
2026-09-08 22:13:00 +08:00
houseme 21fee7f7ff 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>
2026-09-08 22:12:44 +08:00
houseme 23ed00fc85 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>
2026-09-08 22:00:18 +08:00
houseme f49ffa2fec test(scanner): require MRF crash matrix cases (#7505)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:55:37 +08:00
houseme ee5f287324 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>
2026-09-08 21:49:08 +08:00
houseme 158c613d0e 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>
2026-09-08 21:48:42 +08:00
houseme a950f914ca 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>
2026-09-08 21:48:24 +08:00
houseme 1de9b40a30 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>
2026-09-08 21:20:42 +08:00
houseme 831468b2e9 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>
2026-09-08 21:20:20 +08:00
houseme 600d037b25 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>
2026-09-08 21:00:18 +08:00
houseme 3fa2b334be 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>
2026-09-08 20:48:33 +08:00
houseme 1b549d5907 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>
2026-09-08 20:48:24 +08:00
houseme 11c4ce96eb 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>
2026-09-08 20:42:31 +08:00
houseme 4508a0985d 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>
2026-09-08 20:42:22 +08:00
houseme ed1b9f25d6 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>
2026-09-08 20:41:56 +08:00
houseme 274c2bf402 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>
2026-09-08 20:41:31 +08:00
258 changed files with 32671 additions and 4213 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2 sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07 sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=f0d15f2d1183be319d9d977d20b48bb05a29a424c266eed910c67aaa6f1ff955 sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
sha256-linux=340aa702576ebed5266b7c47e591f267a11178fa04bf3828d64f53fe92eb0907 sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=12d30fff5ed48fe95bbfb310dd507048f81782b2954e00eb72435708b1133f9c sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
sha256-linux=e917b2fdb303d01e6008ac4f1836c7b268698afe8383bd2aa4e697061df85cf6 sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
+1
View File
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
test-wiring-check: ## Check tests stay registered and selected by their intended runners test-wiring-check: ## Check tests stay registered and selected by their intended runners
@echo "🧪 Checking test wiring..." @echo "🧪 Checking test wiring..."
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py $(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
.PHONY: log-analyzer-rules-check .PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
+8
View File
@@ -32,6 +32,13 @@ script-tests: ## Run shell script tests
./scripts/test_hotpath_warp_ab_gate.sh ./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh ./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh ./scripts/test_scanner_validation_harness.sh
./scripts/test_scanner_heal_checkpoint_crash_evidence.sh
./scripts/test_scanner_heal_g14_multiset_evidence.sh
./scripts/test_scanner_heal_scheduler_pressure_evidence.sh
./scripts/test_scanner_heal_status_outcome_evidence.sh
./scripts/test_scanner_heal_maintenance_evidence.sh
./scripts/test_scanner_heal_w13_mrf_evidence.sh
./scripts/test_scanner_heal_w16_recovery_evidence.sh
./scripts/test_exact_1mib_handoff_abba.sh ./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh ./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh ./scripts/test_manual_transition_runbooks.sh
@@ -39,6 +46,7 @@ script-tests: ## Run shell script tests
./scripts/test_python_bin.sh ./scripts/test_python_bin.sh
./scripts/check_embedded_secrets.sh --self-test ./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py $(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
+6
View File
@@ -197,6 +197,12 @@ test-group = 'e2e-cluster-nightly'
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))' filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
test-group = 'e2e-vault' test-group = 'e2e-vault'
# This four-disk, 65-member rollback probe already drives up to 32 concurrent
# durable deletions. Reserve this nextest run's capacity for its progress oracle.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(=store::init::tests::dispatch_manifest_rollback_bounded_concurrency_reaches_tail_behind_slow_member)'
threads-required = "num-test-threads"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`) # ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+102 -9
View File
@@ -78,6 +78,43 @@
"erasure": {"data_blocks": 8, "parity_blocks": 4}, "erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12, "erasure_set_drive_count": 12,
"scope": "Target process killed during partial background rebuild on a single 3x4 EC8+4 set; real unclean-shutdown marker, exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool." "scope": "Target process killed during partial background rebuild on a single 3x4 EC8+4 set; real unclean-shutdown marker, exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
},
"background-target-restart-ec8-4-multi-set": {
"gate": "G14",
"task": "W21",
"lane": "e2e-nightly",
"suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart",
"oracle": "background-target-restart-ec8-4-multi-set.json",
"evidence": "process-restart",
"unclean_shutdown_marker": false,
"min_objects": 9,
"max_objects": 65,
"topology": {"nodes": 3, "drives_per_node": 8},
"erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12,
"sets": 2,
"pools": 1,
"scope": "Target process restart during partial background rebuild on a 3x8 EC8+4 layout with two erasure sets in one pool; exact unversioned S3 bodies and replacement-drive shards; not power loss or multi-pool."
},
"background-target-crash-ec8-4-multi-pool": {
"gate": "G14",
"task": "W21",
"lane": "e2e-nightly",
"suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_pool_after_background_target_crash",
"oracle": "background-target-crash-ec8-4-multi-pool.json",
"evidence": "process-crash-restart",
"unclean_shutdown_marker": true,
"min_objects": 9,
"max_objects": 65,
"topology": {"nodes": 3, "drives_per_node": 12},
"erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12,
"sets": 3,
"pools": 3,
"outage_target_manifest_required": false,
"scope": "Target process crash during partial background rebuild on three single-node EC8+4 pools; exact baseline S3 bodies and replacement-drive shards, with outage object verified through S3 but not forced onto the replaced target drive."
} }
}, },
"release_lanes": { "release_lanes": {
@@ -172,7 +209,11 @@
"lane": "status-and-outcome", "lane": "status-and-outcome",
"status": "pending", "status": "pending",
"description": "Per-object outcomes and bounded terminal retention", "description": "Per-object outcomes and bounded terminal retention",
"requires": ["per-object outcome oracle", "terminal retention bounds"] "requires": ["per-object outcome oracle", "terminal retention bounds"],
"evidence_fields": [
"per_object_outcome_oracle",
"terminal_retention_bounds"
]
}, },
{ {
"gate": "G06", "gate": "G06",
@@ -180,7 +221,12 @@
"lane": "status-and-outcome", "lane": "status-and-outcome",
"status": "pending", "status": "pending",
"description": "Concurrent status, legacy clients and truncation", "description": "Concurrent status, legacy clients and truncation",
"requires": ["concurrent status evidence", "legacy client compatibility", "truncation behavior"] "requires": ["concurrent status evidence", "legacy client compatibility", "truncation behavior"],
"evidence_fields": [
"concurrent_status_evidence",
"legacy_client_compatibility",
"truncation_behavior"
]
}, },
{ {
"gate": "G07", "gate": "G07",
@@ -188,7 +234,11 @@
"lane": "mrf-responsibility", "lane": "mrf-responsibility",
"status": "pending", "status": "pending",
"description": "Durable MRF responsibility at every commit boundary", "description": "Durable MRF responsibility at every commit boundary",
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"] "requires": ["MRF responsibility oracle", "commit-boundary crash matrix"],
"evidence_fields": [
"mrf_responsibility_oracle",
"commit_boundary_crash_matrix"
]
}, },
{ {
"gate": "G08", "gate": "G08",
@@ -196,7 +246,12 @@
"lane": "mrf-responsibility", "lane": "mrf-responsibility",
"status": "pending", "status": "pending",
"description": "MRF capacity, disk-full and replica-loss matrix", "description": "MRF capacity, disk-full and replica-loss matrix",
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"] "requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"],
"evidence_fields": [
"mrf_capacity_evidence",
"disk_full_matrix",
"replica_loss_matrix"
]
}, },
{ {
"gate": "G09", "gate": "G09",
@@ -225,7 +280,16 @@
"lane": "maintenance-producers", "lane": "maintenance-producers",
"status": "pending", "status": "pending",
"description": "Maintenance and complete producer coverage", "description": "Maintenance and complete producer coverage",
"requires": ["maintenance producer matrix", "complete producer inventory"] "requires": [
"maintenance producer matrix",
"complete producer inventory",
"segment activation preflight"
],
"evidence_fields": [
"maintenance_producer_matrix",
"complete_producer_inventory",
"segment_activation_preflight"
]
}, },
{ {
"gate": "G12", "gate": "G12",
@@ -249,7 +313,20 @@
"lane": "ec8-4-multiset", "lane": "ec8-4-multiset",
"status": "pending", "status": "pending",
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage", "description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
"requires": ["same-window field evidence", "3x4 EC8+4 evidence", "multi-set evidence", "multi-pool evidence"] "requires": [
"same-window field evidence",
"3x4 EC8+4 evidence",
"multi-set evidence",
"multi-pool evidence",
"distributed segment invalidation evidence"
],
"evidence_fields": [
"same_window_field_evidence",
"ec8_4_evidence",
"multi_set_evidence",
"multi_pool_evidence",
"distributed_segment_invalidation_evidence"
]
}, },
{ {
"gate": "P1", "gate": "P1",
@@ -265,7 +342,11 @@
"lane": "scheduler-pressure", "lane": "scheduler-pressure",
"status": "pending", "status": "pending",
"description": "Measured post-stop convergence and cold segment reuse", "description": "Measured post-stop convergence and cold segment reuse",
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"] "requires": ["post-stop convergence measurement", "cold segment reuse measurement"],
"evidence_fields": [
"post_stop_convergence_measurement",
"cold_segment_reuse_measurement"
]
}, },
{ {
"gate": "P3", "gate": "P3",
@@ -281,7 +362,13 @@
"lane": "mrf-responsibility", "lane": "mrf-responsibility",
"status": "pending", "status": "pending",
"description": "Measured MRF scale and replay cost with retained responsibility", "description": "Measured MRF scale and replay cost with retained responsibility",
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence"] "requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
"evidence_fields": [
"mrf_scale_measurement",
"mrf_replay_cost_measurement",
"retained_responsibility_evidence",
"mrf_cleanup_gc_soak_evidence"
]
}, },
{ {
"gate": "R-E", "gate": "R-E",
@@ -297,7 +384,13 @@
"lane": "status-and-outcome", "lane": "status-and-outcome",
"status": "pending", "status": "pending",
"description": "Manager-to-event-to-ledger exact disposition, including grace", "description": "Manager-to-event-to-ledger exact disposition, including grace",
"requires": ["manager disposition evidence", "event disposition evidence", "ledger disposition evidence", "grace handling"] "requires": ["manager disposition evidence", "event disposition evidence", "ledger disposition evidence", "grace handling"],
"evidence_fields": [
"manager_disposition_evidence",
"event_disposition_evidence",
"ledger_disposition_evidence",
"grace_handling"
]
}, },
{ {
"gate": "R-L", "gate": "R-L",
@@ -43,6 +43,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger # `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification. # should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
@@ -89,6 +90,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger # `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification. # should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
@@ -135,6 +137,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger # `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification. # should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
@@ -181,6 +184,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger # `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
# should show richer nested spans during request-path verification. # should show richer nested spans during request-path verification.
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
@@ -26,6 +26,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false} - RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false} - RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
@@ -72,6 +73,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false} - RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false} - RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
@@ -118,6 +120,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false} - RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false} - RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
@@ -164,6 +167,7 @@ services:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318} - RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info} - RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false} - RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false} - RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
@@ -201,6 +201,7 @@ services:
- RUSTFS_ADDRESS=:9000 - RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true - RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318 - RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=debug - RUSTFS_OBS_LOGGER_LEVEL=debug
platform: linux/amd64 platform: linux/amd64
ports: ports:
@@ -220,6 +221,7 @@ services:
- RUSTFS_ADDRESS=:9000 - RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true - RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318 - RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=debug - RUSTFS_OBS_LOGGER_LEVEL=debug
platform: linux/amd64 platform: linux/amd64
ports: ports:
@@ -239,6 +241,7 @@ services:
- RUSTFS_ADDRESS=:9000 - RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true - RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318 - RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=debug - RUSTFS_OBS_LOGGER_LEVEL=debug
platform: linux/amd64 platform: linux/amd64
ports: ports:
@@ -258,6 +261,7 @@ services:
- RUSTFS_ADDRESS=:9000 - RUSTFS_ADDRESS=:9000
- RUSTFS_CONSOLE_ENABLE=true - RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318 - RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_LOGGER_LEVEL=debug - RUSTFS_OBS_LOGGER_LEVEL=debug
platform: linux/amd64 platform: linux/amd64
ports: ports:
+4
View File
@@ -47,6 +47,10 @@ Three pre-built Grafana dashboards are included for monitoring RustFS GET perfor
| **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend | | **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend |
| **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns | | **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns |
### Storage Metrics
Storage panels require `prometheus-rules/rustfs-storage.yml` and the cluster resource attribute. See the [storage metrics guide](../../docs/operations/storage-metrics.md) for ownership, observer selection, freshness, and rolling upgrades.
### Prometheus Alert Rules ### Prometheus Alert Rules
The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-configured alerting rules: The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-configured alerting rules:
@@ -32,6 +32,7 @@ services:
- RUSTFS_SECRET_KEY=rustfsadmin - RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_OBS_LOGGER_LEVEL=info - RUSTFS_OBS_LOGGER_LEVEL=info
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318 - RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040 - RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
volumes: volumes:
- rustfs-data:/data/rustfs - rustfs-data:/data/rustfs
@@ -223,14 +223,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_buckets_total{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_buckets_total\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "__auto", "legendFormat": "__auto",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
], ],
"title": "Total Buckets", "title": "Total Buckets",
"type": "stat" "type": "stat",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -289,14 +290,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_objects_total{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_objects_total\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "__auto", "legendFormat": "__auto",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
], ],
"title": "Total Objects", "title": "Total Objects",
"type": "stat" "type": "stat",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -427,7 +429,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_capacity_used_bytes{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_used_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "Used", "legendFormat": "Used",
"range": true, "range": true,
"refId": "A" "refId": "A"
@@ -438,7 +440,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_capacity_raw_total_bytes{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_raw_total_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"hide": false, "hide": false,
"legendFormat": "Total", "legendFormat": "Total",
"range": true, "range": true,
@@ -450,7 +452,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_capacity_used_bytes{job=~\"$job\"}) / sum(rustfs_cluster_capacity_raw_total_bytes{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_used_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"} / ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_raw_total_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"hide": false, "hide": false,
"instant": false, "instant": false,
"legendFormat": "Percent", "legendFormat": "Percent",
@@ -459,7 +461,8 @@
} }
], ],
"title": "Capacity", "title": "Capacity",
"type": "stat" "type": "stat",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -525,7 +528,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_capacity_stale_drives{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_stale_drives\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "Stale Drives", "legendFormat": "Stale Drives",
"range": true, "range": true,
"refId": "A" "refId": "A"
@@ -536,14 +539,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(rustfs_cluster_capacity_missing_drives{job=~\"$job\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_missing_drives\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "Missing Drives", "legendFormat": "Missing Drives",
"range": true, "range": true,
"refId": "B" "refId": "B"
} }
], ],
"title": "Capacity Observation", "title": "Capacity Observation",
"type": "stat" "type": "stat",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -1989,8 +1993,8 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum by (drive) (rustfs_system_drive_used_bytes{job=~\"$job\", drive=~\"$drive\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
"legendFormat": "{{drive}} (bytes)", "legendFormat": "{{server}} | {{drive}} (bytes)",
"range": true, "range": true,
"refId": "A" "refId": "A"
}, },
@@ -2000,16 +2004,17 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum by (drive) (rustfs_system_drive_used_bytes{job=~\"$job\", drive=~\"$drive\"}) / sum by (drive)(rustfs_system_drive_total_bytes{job=~\"$job\", drive=~\"$drive\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"} / ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_system_drive_total_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
"hide": false, "hide": false,
"instant": false, "instant": false,
"legendFormat": "{{drive}} (percent)", "legendFormat": "{{server}} | {{drive}} (percent)",
"range": true, "range": true,
"refId": "B" "refId": "B"
} }
], ],
"title": "System Drive Usage", "title": "System Drive Usage",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -2097,14 +2102,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_age_seconds{job=~\"$job\", drive=~\"$drive\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_age_seconds\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
"legendFormat": "{{drive}}", "legendFormat": "{{server}} | {{drive}}",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
], ],
"title": "Drive Capacity Observation Age", "title": "Drive Capacity Observation Age",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -2190,8 +2196,8 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_state{job=~\"$job\", drive=~\"$drive\", state=\"stale\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_state\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",state=\"stale\"}",
"legendFormat": "{{drive}} stale", "legendFormat": "{{server}} | {{drive}} stale",
"range": true, "range": true,
"refId": "A" "refId": "A"
}, },
@@ -2201,14 +2207,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_state{job=~\"$job\", drive=~\"$drive\", state=\"missing\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_state\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",state=\"missing\"}",
"legendFormat": "{{drive}} missing", "legendFormat": "{{server}} | {{drive}} missing",
"range": true, "range": true,
"refId": "B" "refId": "B"
} }
], ],
"title": "Drive Capacity Observation State", "title": "Drive Capacity Observation State",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -4551,7 +4558,7 @@
"index": 0, "index": 0,
"text": "INACTIVE" "text": "INACTIVE"
}, },
"to": 1e-9 "to": 1e-09
}, },
"type": "range" "type": "range"
} }
@@ -6563,7 +6570,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "rustfs_cluster_health_drives_online_count{job=~\"$job\"}", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_online_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "online - {{job}}", "legendFormat": "online - {{job}}",
"range": true, "range": true,
"refId": "A" "refId": "A"
@@ -6574,7 +6581,7 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "rustfs_cluster_health_drives_offline_count{job=~\"$job\"}", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_offline_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "offline - {{job}}", "legendFormat": "offline - {{job}}",
"range": true, "range": true,
"refId": "B" "refId": "B"
@@ -6585,14 +6592,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "rustfs_cluster_health_drives_count{job=~\"$job\"}", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "total - {{job}}", "legendFormat": "total - {{job}}",
"range": true, "range": true,
"refId": "C" "refId": "C"
} }
], ],
"title": "Cluster Drive Health Counts", "title": "Cluster Drive Health Counts",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"collapsed": false, "collapsed": false,
@@ -8554,14 +8562,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "{__name__=~\"rustfs_system_drive_.*\",job=~\"$job\",drive=~\"$drive\"}", "expr": "rustfs:storage:current{source_metric=~\"rustfs_system_drive_.*\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
"legendFormat": "{{__name__}} | {{drive}}", "legendFormat": "{{server}} | {{source_metric}} | {{drive}}",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
], ],
"title": "System Drive (All)", "title": "System Drive (All)",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -8942,14 +8951,15 @@
"uid": "${datasource}" "uid": "${datasource}"
}, },
"editorMode": "code", "editorMode": "code",
"expr": "{__name__=~\"rustfs_cluster_erasure_set_.*\",job=~\"$job\"}", "expr": "rustfs:storage:current{source_metric=~\"rustfs_cluster_erasure_set_.*\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
"legendFormat": "{{__name__}}", "legendFormat": "{{source_metric}}",
"range": true, "range": true,
"refId": "A" "refId": "A"
} }
], ],
"title": "Cluster Erasure Set (All)", "title": "Cluster Erasure Set (All)",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -11716,7 +11726,7 @@
"editorMode": "code", "editorMode": "code",
"range": true, "range": true,
"refId": "A", "refId": "A",
"expr": "max by (server, drive, pool_index, set_index, drive_index, state) (rustfs_system_drive_runtime_state{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_drive_runtime_state\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\",drive=~\"$drive\"}",
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}" "legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}"
}, },
{ {
@@ -11727,12 +11737,13 @@
"editorMode": "code", "editorMode": "code",
"range": true, "range": true,
"refId": "B", "refId": "B",
"expr": "max by (server, drive, pool_index, set_index, drive_index) (rustfs_system_drive_offline_duration_seconds{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})", "expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_drive_offline_duration_seconds\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\",drive=~\"$drive\"}",
"legendFormat": "{{server}} | {{drive}} | offline seconds" "legendFormat": "{{server}} | {{drive}} | offline seconds"
} }
], ],
"title": "Drive Runtime State and Offline Duration", "title": "Observed Cluster Drive State and Offline Duration",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -11825,12 +11836,13 @@
"editorMode": "code", "editorMode": "code",
"range": true, "range": true,
"refId": "A", "refId": "A",
"expr": "sum by (server, drive, pool_index, set_index, drive_index, api) (rate(rustfs_system_drive_api_calls_total{job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]))", "expr": "rate(rustfs_system_drive_api_calls_total{collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]) and ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}",
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}" "legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}"
} }
], ],
"title": "Drive API Calls by Operation", "title": "Drive API Calls by Operation",
"type": "timeseries" "type": "timeseries",
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
}, },
{ {
"datasource": { "datasource": {
@@ -12376,6 +12388,50 @@
"sort": 1, "sort": 1,
"type": "query" "type": "query"
}, },
{
"current": {},
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"definition": "label_values(rustfs:storage_snapshot:fresh, rustfs_cluster_id)",
"includeAll": false,
"label": "Storage cluster",
"multi": false,
"name": "storage_cluster",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(rustfs:storage_snapshot:fresh, rustfs_cluster_id)",
"refId": "PrometheusVariableQueryEditor-storage_cluster"
},
"refresh": 2,
"regex": "",
"sort": 1,
"type": "query"
},
{
"current": {},
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"definition": "query_result(rustfs:storage_snapshot:fresh{collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\"})",
"includeAll": false,
"label": "Cluster observer",
"multi": false,
"name": "storage_observer",
"options": [],
"query": {
"qryType": 3,
"query": "query_result(rustfs:storage_snapshot:fresh{collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\"})",
"refId": "PrometheusVariableQueryEditor-storage_observer"
},
"refresh": 2,
"regex": "/observer=\"([^\"]+)\"/",
"sort": 1,
"type": "query"
},
{ {
"allValue": ".*", "allValue": ".*",
"current": { "current": {
@@ -12386,7 +12442,7 @@
"type": "prometheus", "type": "prometheus",
"uid": "${datasource}" "uid": "${datasource}"
}, },
"definition": "label_values(rustfs_system_drive_api_calls_total,api)", "definition": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, api)",
"includeAll": true, "includeAll": true,
"label": "Drive API", "label": "Drive API",
"multi": true, "multi": true,
@@ -12394,7 +12450,7 @@
"options": [], "options": [],
"query": { "query": {
"qryType": 1, "qryType": 1,
"query": "label_values(rustfs_system_drive_api_calls_total,api)", "query": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, api)",
"refId": "PrometheusVariableQueryEditor-drive_api" "refId": "PrometheusVariableQueryEditor-drive_api"
}, },
"refresh": 2, "refresh": 2,
@@ -12455,7 +12511,7 @@
"text": "All", "text": "All",
"value": "$__all" "value": "$__all"
}, },
"definition": "label_values(rustfs_system_drive_used_bytes,drive)", "definition": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, drive)",
"includeAll": true, "includeAll": true,
"label": "Drive", "label": "Drive",
"multi": true, "multi": true,
@@ -12463,7 +12519,7 @@
"options": [], "options": [],
"query": { "query": {
"qryType": 1, "qryType": 1,
"query": "label_values(rustfs_system_drive_used_bytes,drive)", "query": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, drive)",
"refId": "PrometheusVariableQueryEditor-VariableQuery" "refId": "PrometheusVariableQueryEditor-VariableQuery"
}, },
"refresh": 2, "refresh": 2,
@@ -0,0 +1,575 @@
# Preserve OTLP timestamps in the Collector. Apply timestamp() directly to
# each raw selector, before label rewriting; functions such as label_replace()
# replace the evaluation timestamp and would make a cached value look new.
# Compare publication times at Prometheus millisecond precision so points
# published and exported within the same millisecond are not withheld.
groups:
- name: rustfs-storage-snapshots
interval: 15s
rules:
- record: rustfs:storage_snapshot:fresh
expr: |
rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}
and ((time() - rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}) <= rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"})
and ((time() - rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}) >= 0)
and (rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"} > 0)
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_buckets_total
expr: |
rustfs_cluster_buckets_total{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_buckets_total{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_capacity_free_bytes
expr: |
rustfs_cluster_capacity_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_capacity_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_capacity_missing_drives
expr: |
rustfs_cluster_capacity_missing_drives{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_capacity_missing_drives{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_capacity_raw_total_bytes
expr: |
rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_capacity_stale_drives
expr: |
rustfs_cluster_capacity_stale_drives{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_capacity_stale_drives{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_capacity_usable_total_bytes
expr: |
rustfs_cluster_capacity_usable_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_capacity_usable_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_capacity_used_bytes
expr: |
rustfs_cluster_capacity_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_capacity_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_capacity_observation_age_seconds
expr: |
rustfs_cluster_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_capacity_observation_state
expr: |
rustfs_cluster_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_free_bytes
expr: |
rustfs_cluster_drive_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_offline_duration_seconds
expr: |
rustfs_cluster_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_present
expr: |
rustfs_cluster_drive_present{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_present{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_runtime_state
expr: |
rustfs_cluster_drive_runtime_state{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_runtime_state{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_total_bytes
expr: |
rustfs_cluster_drive_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_drive_used_bytes
expr: |
rustfs_cluster_drive_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_drive_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_data_shards
expr: |
rustfs_cluster_erasure_set_data_shards{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_data_shards{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_healing_drives_count
expr: |
rustfs_cluster_erasure_set_healing_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_healing_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_health
expr: |
rustfs_cluster_erasure_set_health{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_health{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_online_drives_count
expr: |
rustfs_cluster_erasure_set_online_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_online_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_overall_health
expr: |
rustfs_cluster_erasure_set_overall_health{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_overall_health{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_overall_write_quorum
expr: |
rustfs_cluster_erasure_set_overall_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_overall_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_parity
expr: |
rustfs_cluster_erasure_set_parity{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_parity{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_read_health
expr: |
rustfs_cluster_erasure_set_read_health{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_read_health{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_read_quorum
expr: |
rustfs_cluster_erasure_set_read_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_read_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_read_tolerance
expr: |
rustfs_cluster_erasure_set_read_tolerance{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_read_tolerance{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_size
expr: |
rustfs_cluster_erasure_set_size{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_size{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_write_health
expr: |
rustfs_cluster_erasure_set_write_health{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_write_health{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_write_quorum
expr: |
rustfs_cluster_erasure_set_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_erasure_set_write_tolerance
expr: |
rustfs_cluster_erasure_set_write_tolerance{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_erasure_set_write_tolerance{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_health_drives_count
expr: |
rustfs_cluster_health_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_health_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_health_drives_offline_count
expr: |
rustfs_cluster_health_drives_offline_count{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_health_drives_offline_count{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_health_drives_online_count
expr: |
rustfs_cluster_health_drives_online_count{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_health_drives_online_count{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_cluster_objects_total
expr: |
rustfs_cluster_objects_total{rustfs_cluster_id!="",collection_scope="cluster"}
and (timestamp(rustfs_cluster_objects_total{rustfs_cluster_id!="",collection_scope="cluster"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_node_disk_free_bytes
expr: |
rustfs_node_disk_free_bytes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_node_disk_free_bytes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_node_disk_total_bytes
expr: |
rustfs_node_disk_total_bytes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_node_disk_total_bytes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_node_disk_used_bytes
expr: |
rustfs_node_disk_used_bytes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_node_disk_used_bytes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_api_calls_total
expr: |
rustfs_system_drive_api_calls_total{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_api_calls_total{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_api_latency_by_api_micros
expr: |
rustfs_system_drive_api_latency_by_api_micros{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_api_latency_by_api_micros{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_api_latency_micros
expr: |
rustfs_system_drive_api_latency_micros{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_api_latency_micros{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_availability_errors_total
expr: |
rustfs_system_drive_availability_errors_total{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_availability_errors_total{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_capacity_observation_age_seconds
expr: |
rustfs_system_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_capacity_observation_state
expr: |
rustfs_system_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_count
expr: |
rustfs_system_drive_count{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_count{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_deletes_total
expr: |
rustfs_system_drive_deletes_total{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_deletes_total{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_free_bytes
expr: |
rustfs_system_drive_free_bytes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_free_bytes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_free_inodes
expr: |
rustfs_system_drive_free_inodes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_free_inodes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_healing
expr: |
rustfs_system_drive_healing{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_healing{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_health
expr: |
rustfs_system_drive_health{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_health{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_info
expr: |
rustfs_system_drive_info{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_info{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_io_errors_total
expr: |
rustfs_system_drive_io_errors_total{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_io_errors_total{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_offline_count
expr: |
rustfs_system_drive_offline_count{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_offline_count{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_offline_duration_seconds
expr: |
rustfs_system_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_online_count
expr: |
rustfs_system_drive_online_count{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_online_count{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_perc_util
expr: |
rustfs_system_drive_perc_util{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_perc_util{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_present
expr: |
rustfs_system_drive_present{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_present{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_reads_await
expr: |
rustfs_system_drive_reads_await{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_reads_await{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_reads_kb_per_sec
expr: |
rustfs_system_drive_reads_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_reads_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_reads_per_sec
expr: |
rustfs_system_drive_reads_per_sec{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_reads_per_sec{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_runtime_state
expr: |
rustfs_system_drive_runtime_state{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_runtime_state{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_scanning
expr: |
rustfs_system_drive_scanning{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_scanning{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_timeout_errors_total
expr: |
rustfs_system_drive_timeout_errors_total{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_timeout_errors_total{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_total_bytes
expr: |
rustfs_system_drive_total_bytes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_total_bytes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_total_inodes
expr: |
rustfs_system_drive_total_inodes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_total_inodes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_used_bytes
expr: |
rustfs_system_drive_used_bytes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_used_bytes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_used_inodes
expr: |
rustfs_system_drive_used_inodes{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_used_inodes{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_waiting_io
expr: |
rustfs_system_drive_waiting_io{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_waiting_io{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_writes_await
expr: |
rustfs_system_drive_writes_await{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_writes_await{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_writes_kb_per_sec
expr: |
rustfs_system_drive_writes_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_writes_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_writes_per_sec
expr: |
rustfs_system_drive_writes_per_sec{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_writes_per_sec{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
- record: rustfs:storage:current
labels:
source_metric: rustfs_system_drive_writes_total
expr: |
rustfs_system_drive_writes_total{rustfs_cluster_id!="",collection_scope="local"}
and (timestamp(rustfs_system_drive_writes_total{rustfs_cluster_id!="",collection_scope="local"})
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
@@ -0,0 +1,189 @@
rule_files:
- ../prometheus-rules/rustfs-storage.yml
evaluation_interval: 15s
tests:
- name: owners, pools, clusters and observer views remain distinct in mixed versions
interval: 1m
input_series:
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",pool_index="0",set_index="0",drive_index="0"}
values: 100x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",pool_index="0",set_index="0",drive_index="1"}
values: 100x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector",server="n2",drive="/data",pool_index="1",set_index="0",drive_index="0"}
values: 100x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector",server="n3",drive="/data",pool_index="1",set_index="0",drive_index="1"}
values: 100x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",pool_index="0",set_index="0",drive_index="0"}
values: 1000x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",pool_index="0",set_index="0",drive_index="1"}
values: 1000x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector",server="n2",drive="/data",pool_index="1",set_index="0",drive_index="0"}
values: 1000x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector",server="n3",drive="/data",pool_index="1",set_index="0",drive_index="1"}
values: 1000x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
values: 400x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
values: 300x8
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="old",server="n0",drive="/data",job="rustfs",instance="collector"}
values: 999x8
promql_expr_test:
- expr: sum by (rustfs_cluster_id) (rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes",collection_scope="local"})
eval_time: 2m
exp_samples:
- labels: '{rustfs_cluster_id="a"}'
value: 400
- labels: '{rustfs_cluster_id="b"}'
value: 4000
- expr: count by (rustfs_cluster_id,pool_index) (rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes"})
eval_time: 2m
exp_samples:
- labels: '{rustfs_cluster_id="a",pool_index="0"}'
value: 2
- labels: '{rustfs_cluster_id="a",pool_index="1"}'
value: 2
- labels: '{rustfs_cluster_id="b",pool_index="0"}'
value: 2
- labels: '{rustfs_cluster_id="b",pool_index="1"}'
value: 2
- expr: sum(rustfs:storage:current{source_metric="rustfs_cluster_capacity_raw_total_bytes",rustfs_cluster_id="a",observer="n0"})
eval_time: 2m
exp_samples:
- labels: '{}'
value: 400
- expr: sum(rustfs:storage:current{source_metric="rustfs_cluster_capacity_raw_total_bytes",rustfs_cluster_id="a",observer="n1"})
eval_time: 2m
exp_samples:
- labels: '{}'
value: 300
- name: removed values and identities cannot rejoin new snapshots; stalled sources expire
interval: 1m
input_series:
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 0 60 _ _ _ _ _ _ _
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_info{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="old"}
values: 1 1 _ _ _ _ _ _ _
- series: rustfs_system_drive_info{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="new"}
values: _ _ 1 1 1 1 1 1 1
- series: rustfs_system_drive_waiting_io{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data"}
values: 7 7 _ _ _ _ _ _ _
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data"}
values: 100 100 _ _ _ _ _ _ _
promql_expr_test:
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_info",disk_id="old"}
eval_time: 2m
exp_samples: []
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_waiting_io"}
eval_time: 2m
exp_samples: []
- expr: count(rustfs:storage:current{source_metric="rustfs_system_drive_info",disk_id="new"})
eval_time: 2m
exp_samples:
- labels: '{}'
value: 1
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes"}
eval_time: 5m
exp_samples: []
- expr: rustfs:storage_snapshot:fresh{observer="n1"}
eval_time: 5m
exp_samples: []
- name: counters reset independently; filter versions before rate and sum rates across owners
interval: 1m
input_series:
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="n0-disk",api="read_all"}
values: 0 60 120 30 90 150 210
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 0+60x8
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
values: 180x8
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",disk_id="n1-disk",api="read_all"}
values: 0+120x6
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",server="n0",drive="/data",observer="old"}
values: 0+999x6
promql_expr_test:
- expr: sum(resets(rustfs_system_drive_api_calls_total{collection_scope="local"}[5m]))
eval_time: 5m
exp_samples:
- labels: '{}'
value: 1
- expr: sum(rate(rustfs_system_drive_api_calls_total{collection_scope="local"}[2m]) and ignoring(source_metric) rustfs:storage:current{source_metric="rustfs_system_drive_api_calls_total"})
eval_time: 5m
exp_samples:
- labels: '{}'
value: 3
- name: completed slow collection has no validity budget
interval: 1m
input_series:
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="slow",collection_scope="cluster",job="rustfs",instance="collector"}
values: 0+60x3
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="slow",collection_scope="cluster",job="rustfs",instance="collector"}
values: '0x3'
promql_expr_test:
- expr: rustfs:storage_snapshot:fresh{observer="slow"}
eval_time: 2m
exp_samples: []
- name: submillisecond publication and millisecond OTLP samples share a cutoff
interval: 1m
input_series:
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector"}
values: 0.0009+60x3
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector"}
values: 180x3
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector",server="submillisecond",drive="/data"}
values: 100x3
promql_expr_test:
- expr: count(rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes",observer="submillisecond"})
eval_time: 2m15s
exp_samples:
- labels: '{}'
value: 1
-4
View File
@@ -111,10 +111,6 @@ runs:
shell: bash shell: bash
run: ./scripts/check_no_planning_docs.sh run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
shell: bash
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition - name: Check io_uring lane --lib precondition
shell: bash shell: bash
run: ./scripts/check_uring_lane_lib_only.sh run: ./scripts/check_uring_lane_lib_only.sh
+4
View File
@@ -55,6 +55,10 @@ secret_key = ${S3_SECRET_KEY}
## replace with key id obtained when secret is created, or delete if KMS not tested ## replace with key id obtained when secret is created, or delete if KMS not tested
#kms_keyid = 01234567-89ab-cdef-0123-456789abcdef #kms_keyid = 01234567-89ab-cdef-0123-456789abcdef
#kms_keyid2 = fedcba98-7654-3210-fedc-ba9876543210
## Expected service default for SSE-KMS requests without a key id; empty means none
#kms_default_keyid =
## Storage classes ## Storage classes
#storage_classes = "LUKEWARM, FROZEN" #storage_classes = "LUKEWARM, FROZEN"
+2 -2
View File
@@ -16,7 +16,7 @@ name: Security Audit
on: on:
push: push:
branches: [ main, release ] branches: [ main ]
paths: paths:
- '**/Cargo.toml' - '**/Cargo.toml'
- '**/Cargo.lock' - '**/Cargo.lock'
@@ -32,7 +32,7 @@ on:
- 'scripts/security/check_workflow_pins.sh' - 'scripts/security/check_workflow_pins.sh'
pull_request: pull_request:
types: [ opened, synchronize, reopened, closed ] types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ] branches: [ main ]
paths: paths:
- '**/Cargo.toml' - '**/Cargo.toml'
- '**/Cargo.lock' - '**/Cargo.lock'
-79
View File
@@ -1,79 +0,0 @@
# Copyright 2026 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Reports the existing required checks for paths excluded by ci.yml.
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
# action to keep validation coverage aligned. Keep this paths list in sync with
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
name: Continuous Integration (docs only)
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main, release ]
paths:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
permissions:
contents: read
jobs:
quick-checks:
name: Quick Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
# Docs-only PRs skip the full code CI, but they are exactly where a
# planning-type document could be slipped in (git add -f bypasses
# .gitignore). Run the guard here so the required "Test and Lint" check
# stays meaningful for docs-only changes.
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Satisfy required check for docs-only changes
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
+77 -85
View File
@@ -37,25 +37,6 @@ on:
pull_request: pull_request:
types: [ opened, synchronize, reopened, closed ] types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ] branches: [ main, release ]
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
# reports the required "Test and Lint" check for PRs skipped here.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
merge_group: merge_group:
types: [ checks_requested ] types: [ checks_requested ]
schedule: schedule:
@@ -88,6 +69,32 @@ jobs:
- name: Explain cancellation run - name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group." run: echo "PR closed; this run only cancels older runs in the same concurrency group."
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 2
persist-credentials: false
- name: Select scope using the base revision's policy
id: scope
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
else
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
typos: typos:
name: Typos name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -100,7 +107,7 @@ jobs:
- name: Typos check with custom config file - name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
# Fail early with compile-free checks shared with docs-only CI. # Fail early with compile-free checks for every pull request.
quick-checks: quick-checks:
name: Quick Checks name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -116,9 +123,9 @@ jobs:
uses: ./.github/actions/quick-checks uses: ./.github/actions/quick-checks
test-and-lint: test-and-lint:
name: Test and Lint name: Workspace Test and Lint
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 90 timeout-minutes: 90
env: env:
@@ -289,45 +296,6 @@ jobs:
- name: Run rebalance/decommission migration proofs - name: Run rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh run: ./scripts/check_migration_gate_count.sh
# Record the reason before this job completes as FAILURE. A separate
# dependent job cancels sibling lanes only after GitHub has preserved this
# required check's failure verdict.
- name: Annotate early-stop reason
if: >-
failure() && github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
run: |
{
echo "## CI early-stop"
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
} >> "$GITHUB_STEP_SUMMARY"
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
# CANCELLED and hid the actionable failure in the PR checks UI.
cancel-after-test-and-lint-failure:
name: Cancel siblings after Test and Lint failure
if: >-
failure() && needs.test-and-lint.result == 'failure'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
needs: [ test-and-lint ]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Cancel remaining jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -fsS -X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests # Dedicated serial lane for the ILM / lifecycle integration tests. These tests
# drive the object layer through process-global singletons (the GLOBAL_ENV # drive the object layer through process-global singletons (the GLOBAL_ENV
# ECStore, the global tier-config manager, background-expiry workers) and bind # ECStore, the global tier-config manager, background-expiry workers) and bind
@@ -340,8 +308,8 @@ jobs:
# See rustfs/backlog#1148 (ilm-1) and #1155. # See rustfs/backlog#1148 (ilm-1) and #1155.
test-ilm-integration-serial: test-ilm-integration-serial:
name: ILM Integration (serial) name: ILM Integration (serial)
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 90 timeout-minutes: 90
env: env:
@@ -408,8 +376,8 @@ jobs:
test-and-lint-rio-v2: test-and-lint-rio-v2:
name: Test and Lint (rio-v2) name: Test and Lint (rio-v2)
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 90 timeout-minutes: 90
env: env:
@@ -449,8 +417,8 @@ jobs:
connect-short-credential-boundary: connect-short-credential-boundary:
name: Connect Short Credential Boundary name: Connect Short Credential Boundary
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 60 timeout-minutes: 60
env: env:
@@ -507,8 +475,8 @@ jobs:
test-and-lint-protocols: test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})" name: "Test and Lint (${{ matrix.features.name }})"
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 90 timeout-minutes: 90
strategy: strategy:
@@ -561,8 +529,8 @@ jobs:
build-rustfs-debug-binary: build-rustfs-debug-binary:
name: Build RustFS Debug Binary name: Build RustFS Debug Binary
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4 runs-on: sm-standard-4
timeout-minutes: 30 timeout-minutes: 30
env: env:
@@ -684,8 +652,8 @@ jobs:
# job had neither, so each closed/merged PR really ran the whole io_uring # job had neither, so each closed/merged PR really ran the whole io_uring
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 / # suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
# 30662728539) and kept the cancellation run in progress for minutes. # 30662728539) and kept the cancellation run in progress for minutes.
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks ] needs: [ quick-checks, classify-changes ]
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike # GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
# a container, applies no seccomp filter that would block io_uring_setup — so # a container, applies no seccomp filter that would block io_uring_setup — so
# the probe succeeds and the tests exercise the real UringBackend/FdCache/ # the probe succeeds and the tests exercise the real UringBackend/FdCache/
@@ -1002,22 +970,11 @@ jobs:
# debug binary; each test spawns its own rustfs server on a random port. # debug binary; each test spawns its own rustfs server on a random port.
- name: Run e2e full suite - name: Run e2e full suite
env: env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-full-logs
RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG: rustfs::heal=debug,rustfs_heal=debug,rustfs_ecstore::disk=debug,rustfs_lock=debug,rustfs=error
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
run: cargo nextest run --profile e2e-full -p e2e_test run: cargo nextest run --profile e2e-full -p e2e_test
- name: Upload e2e full diagnostics
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-full-diagnostics-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-e2e-full-logs/
if-no-files-found: warn
retention-days: 7
- name: Upload junit - name: Upload junit
if: always() if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -1224,9 +1181,44 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 3
required-checks:
name: Test and Lint
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs:
- classify-changes
- typos
- quick-checks
- test-and-lint
- test-ilm-integration-serial
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
- e2e-tests
- s3-implemented-tests
- s3-lifecycle-behavior-tests
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Require the expected result of every CI lane
env:
CI_NEEDS: ${{ toJSON(needs) }}
shell: bash
run: python3 scripts/ci_gate.py verify
alert-on-failure: alert-on-failure:
name: Alert on scheduled failure name: Alert on scheduled failure
needs: needs:
- classify-changes
- connect-short-credential-boundary
- required-checks
- typos - typos
- quick-checks - quick-checks
- test-and-lint - test-and-lint
+2 -2
View File
@@ -19,7 +19,7 @@
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is # case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs, # `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
# nightly runs, and manual dispatches all execute the same fail-closed suite. # nightly runs, and manual dispatches all execute the same fail-closed suite.
# Upgrade cases use an independent 1.0.0-rc.2 pin defined below. # Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
# #
# Isolated pool filesystems: expand/decommission/rebalance cases require # Isolated pool filesystems: expand/decommission/rebalance cases require
# independent `statfs` capacity. This job runs on GitHub-hosted # independent `statfs` capacity. This job runs on GitHub-hosted
@@ -87,7 +87,7 @@ jobs:
NO_PROXY: 127.0.0.1,localhost NO_PROXY: 127.0.0.1,localhost
HTTP_PROXY: "" HTTP_PROXY: ""
HTTPS_PROXY: "" HTTPS_PROXY: ""
# Independent 1.0.0-rc.2 source pin for distributed::upgrade_test. # Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2 UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7 UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
+11
View File
@@ -129,11 +129,22 @@ jobs:
: > target/debug/rustfs.features : > target/debug/rustfs.features
- name: Run upgrade compatibility test - name: Run upgrade compatibility test
env:
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
run: | run: |
cargo test --locked -p e2e_test \ cargo test --locked -p e2e_test \
"upgrade_compatibility_test::${{ matrix.test }}" \ "upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture -- --ignored --exact --nocapture
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
if-no-files-found: ignore
retention-days: 14
- name: Upload server logs - name: Upload server logs
if: always() if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+122 -5
View File
@@ -19,17 +19,27 @@ on:
- cron: "7 0 * * *" - cron: "7 0 * * *"
timezone: "Asia/Shanghai" timezone: "Asia/Shanghai"
workflow_dispatch: workflow_dispatch:
inputs:
branch:
description: 'Branch/ref to build and publish as the nightly (empty = scheduled source, see NIGHTLY_BUILD_REF)'
required: false
default: ''
permissions: permissions:
contents: read contents: read
# Scheduled builds follow the NIGHTLY_BRANCH repo variable so the channel can
# be pointed at e.g. `release` for the GA cycle and back to `main` afterwards
# without touching this file. Manual runs take the `branch` input, falling
# back to the branch the run was dispatched from.
concurrency: concurrency:
group: nightly-gnu-build-main-${{ github.event_name }} group: nightly-gnu-build-${{ github.event_name }}-${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }} cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
NIGHTLY_BUILD_REF: ${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
jobs: jobs:
build: build:
@@ -43,6 +53,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ env.NIGHTLY_BUILD_REF }}
- name: Setup Rust environment - name: Setup Rust environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
@@ -152,13 +163,104 @@ jobs:
fakeroot dpkg-deb --build "${PKG_DIR}" fakeroot dpkg-deb --build "${PKG_DIR}"
ls -lh "${DEB_FILE}" ls -lh "${DEB_FILE}"
echo "deb_date=${DEB_DATE}" >> "${GITHUB_OUTPUT}"
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}" echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
# Same packaging scheme as .github/workflows/package.yml (fpm), but from
# the locally built nightly binary instead of a release artifact, with a
# date-based version that mirrors the DEB.
- name: Build RPM package
id: rpm
shell: bash
env:
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
run: |
set -euo pipefail
if ! command -v fpm >/dev/null 2>&1; then
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
${SUDO} apt-get update -qq && ${SUDO} apt-get install -y -qq ruby ruby-dev build-essential rpm >/dev/null
${SUDO} gem install fpm --no-document >/dev/null
fi
RPM_FILE="rustfs-nightly-${DEB_DATE}.rpm"
RPM_VERSION="0"
RPM_RELEASE="0.nightly.${DEB_DATE//-/.}"
echo "Building RPM: ${RPM_FILE} (version ${RPM_VERSION}-${RPM_RELEASE})"
# fpm wants the config file to exist before packaging.
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://rustfs.com/docs/ for more information
# RUSTFS_VOLUMES=""
# RUSTFS_ROOT_USER=""
# RUSTFS_ROOT_PASSWORD=""
ENVEOF
fpm -s dir -t rpm \
--name rustfs \
--version "$RPM_VERSION" \
--iteration "$RPM_RELEASE" \
--architecture x86_64 \
--package "$RPM_FILE" \
--depends "glibc >= 2.31" \
--maintainer "RustFS Team <support@rustfs.com>" \
--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
) \
--config-files /etc/default/rustfs \
"rustfs-nightly-${DEB_DATE}/usr/bin/rustfs=/usr/bin/rustfs" \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
LICENSE=/usr/share/doc/rustfs/LICENSE \
README.md=/usr/share/doc/rustfs/README.md
[[ -f "$RPM_FILE" ]] || { echo "RPM build failed"; exit 1; }
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$RPM_FILE"
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
- name: Upload DEB artifact - name: Upload DEB artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: ${{ steps.deb.outputs.deb_file }} name: ${{ steps.deb.outputs.deb_file }}
path: ${{ steps.deb.outputs.deb_file }} path: ${{ steps.deb.outputs.deb_file }}
- name: Upload RPM artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.rpm.outputs.rpm_file }}
path: ${{ steps.rpm.outputs.rpm_file }}
if-no-files-found: error if-no-files-found: error
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml) # Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
@@ -187,11 +289,10 @@ jobs:
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY" export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto" export AWS_DEFAULT_REGION="auto"
# The candidate manifest must describe the tree that was actually
# built. With a ref override (NIGHTLY_BRANCH / dispatch input) that
# is not necessarily GITHUB_SHA, so always advertise HEAD.
SOURCE_SHA="$(git rev-parse HEAD)" SOURCE_SHA="$(git rev-parse HEAD)"
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
echo "Checkout SHA does not match the nightly build run" >&2
exit 1
fi
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)" DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb" CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}" CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
@@ -247,6 +348,20 @@ jobs:
path: ${{ steps.publish.outputs.candidate_file }} path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error if-no-files-found: error
# Publish the deb/rpm pair to the auto-testing repo's `assets` branch so
# engineers can download and install the nightly directly. The branch is
# a single-commit orphan rewritten on every build, which keeps the repo
# small while the latest files stay reachable at stable raw URLs.
# Publish the deb/rpm pair as assets of the rolling `nightly` release on
# rustfs/auto-testing (see scripts/release/publish_nightly_assets.sh).
- name: Publish packages to auto-testing release assets
env:
ASSETS_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
BUILD_REF: ${{ env.NIGHTLY_BUILD_REF }}
run: bash scripts/release/publish_nightly_assets.sh
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774). # Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
# #
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and # RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
@@ -284,6 +399,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ env.NIGHTLY_BUILD_REF }}
- name: Setup Rust environment - name: Setup Rust environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
@@ -372,6 +488,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
persist-credentials: false persist-credentials: false
ref: ${{ env.NIGHTLY_BUILD_REF }}
- name: Setup Rust environment - name: Setup Rust environment
uses: ./.github/actions/setup uses: ./.github/actions/setup
@@ -82,6 +82,12 @@ jobs:
performance-test: performance-test:
runs-on: pf-testing runs-on: pf-testing
timeout-minutes: 900 timeout-minutes: 900
env:
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
# Run on manual dispatch, or when the nightly build completed successfully. # Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed. # Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
@@ -158,19 +164,15 @@ jobs:
- name: Run benchmark (GET/PUT/MIXED) - name: Run benchmark (GET/PUT/MIXED)
id: benchmark id: benchmark
run: | run: |
# Empty on automatic (workflow_run) runs -> full 30 rounds.
# Manual dispatch can restrict method(s)/size(s).
export WARP_METHODS="${{ inputs.test_method }}"
export WARP_SIZES="${{ inputs.object_size }}"
./auto-testing/rustfs_performance_test.sh \ ./auto-testing/rustfs_performance_test.sh \
--step 5 -y \ --step 5 -y \
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
--log-file "${LOG_FILE}" --log-file "${LOG_FILE}"
- name: Analyze results - name: Analyze results
if: ${{ steps.benchmark.conclusion == 'success' }} if: ${{ steps.benchmark.conclusion == 'success' }}
run: | run: |
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}" ./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Collect RustFS version info - name: Collect RustFS version info
+2 -2
View File
@@ -16,7 +16,7 @@ name: Windows Filesystem Tests
on: on:
push: push:
branches: [ main ] branches: [ main, release ]
paths: paths:
- "crates/ecstore/src/disk/**" - "crates/ecstore/src/disk/**"
- "crates/ecstore/src/store/init_format.rs" - "crates/ecstore/src/store/init_format.rs"
@@ -26,7 +26,7 @@ on:
- ".github/actions/setup/**" - ".github/actions/setup/**"
- ".github/workflows/windows-filesystem.yml" - ".github/workflows/windows-filesystem.yml"
pull_request: pull_request:
branches: [ main ] branches: [ main, release ]
paths: paths:
- "crates/ecstore/src/disk/**" - "crates/ecstore/src/disk/**"
- "crates/ecstore/src/store/init_format.rs" - "crates/ecstore/src/store/init_format.rs"
+1
View File
@@ -52,6 +52,7 @@ docs
__pycache__/ __pycache__/
!docs/ !docs/
docs/* docs/*
!docs/README.md
!docs/architecture/ !docs/architecture/
!docs/architecture/** !docs/architecture/**
!docs/operations/ !docs/operations/
+30
View File
@@ -7,12 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Replication
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
### Security ### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged. - **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed ### Fixed
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375. - **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`. - **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
- **KMS failures on the S3 data path carry an actionable status**: only "key not found" and a backend outage were classified; every other KMS failure — a disabled or pending-deletion key, a denied KMS grant, an encryption-context mismatch, an unsupported algorithm, a credential or timeout failure, a capability the backend does not have — collapsed onto `500 InternalError`. SDKs therefore applied exponential backoff to configuration errors that no retry can fix, and monitoring filed every one of them as a server fault. Unusable-key and request-side failures now return `400`, a denied grant `403`, transient backend failures `503` — including a key store the backend could not read, so an outage stays distinguishable from a missing key all the way to the client — and a missing backend capability `501`. Damaged or unreadable key material still returns `500`, which is what it is.
- **SSE-C on buckets with default encryption**: a `PutObject` carrying a valid SSE-C header triple on a bucket that has default encryption configured no longer fails with `400 InvalidArgument` ("The SSE-C and managed server-side encryption headers cannot be used together"). PUT and the POST-object/extract path resolved the bucket default with a hard-coded "no explicit SSE-C" flag, so the default was layered onto the request and then tripped the request's own mutual-exclusion check; an SSE-C request now suppresses the bucket default on all three write paths, matching COPY and AWS S3. Every bucket with default encryption previously refused SSE-C single PUTs outright, while `CreateMultipartUpload` on the same bucket succeeded.
- **Explicit SSE-S3 on SSE-KMS-default buckets**: `x-amz-server-side-encryption: AES256` against a bucket whose default is `aws:kms` no longer fails with `400 InvalidArgument`. The bucket default's KMS key id was inherited independently of the effective algorithm, producing a self-contradictory `AES256` + key-id pair; the key id is now inherited only when the effective algorithm is `aws:kms`. `PutBucketEncryption` fills in a default key id automatically, so this affected nearly every SSE-KMS-default bucket.
- **Restore of encrypted or compressed multipart objects (silent data corruption)**: restoring a multipart object from a remote tier addressed the tier in *plaintext* coordinates while the copy-back reads the *stored* representation. Every 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 replaced the object's bytes. Restore now accumulates stored part sizes, passes the stored length to the hash reader alongside the plaintext length, and validates against the stored size. Objects restored by an affected release must be re-restored from the tier or recovered from a backup — this release does not detect or repair them retroactively.
- **Restore no longer drifts the object ETag**: the copy-back digests stored (encrypted or compressed) bytes, so the recomputed MD5 is not the object's public ETag. Single-part and multipart restores now preserve the original object ETag, and each restored part keeps its own recorded part ETag.
- **ILM archive no longer forwards encryption metadata to the tier**: transition requests carried the object's SSE headers and the RustFS-wrapped data key as request headers. Any S3 target rejected an SSE-C archive outright (`400`, no key supplied), 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 using the same predicate the replication path uses; the local `xl.meta` keeps all of it, so read-through and restore are unaffected.
- **KMS reload is no longer a no-op on a node whose KMS 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 (for example Vault briefly unreachable during a rolling restart) keeps that configuration and sits in `Error`, so the documented recovery call returned "reloaded successfully" while leaving the node down — and did the same on every peer through the reload broadcast. Reload now short-circuits only for a service that is actually running, and otherwise reconfigures, which starts the service.
- **AWS KMS capability reporting**: the AWS backend no longer advertises `versioning` support through `GET /rustfs/admin/v3/kms/status`. AWS KMS key versions are not enumerable through this backend, as the backend documentation already stated.
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385. - **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set. - **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801. - **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
@@ -54,6 +72,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary - Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
### Changed ### Changed
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
- **Per-key SSE-KMS authorization** (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY`, default `false`). With it on, anonymous callers hold no KMS grants, so **a public bucket serving SSE-KMS objects is an incompatible combination** and those reads return `AccessDenied`.
- Envelope context binding as KMS AAD (`ENV_KMS_ENVELOPE_AAD`, off by default; a node that predates the field cannot open bound envelopes).
- Vault custom CA and mutual TLS; object-level DEK rewrap plus a batch rekey admin API; a backend-locality runtime signal on `kms/status`.
- Single-pass decryption for encrypted GET, and encrypted single-part closed-range seek — the latter is now **on by default** (`RUSTFS_ENCRYPTED_RANGE_SEEK`, default `true`; the switch remains as a kill switch).
- **Vault static tokens are now tracked and renewed**: with `Token` authentication RustFS hard-coded "this token has no lease", so the renewal task never started and no remaining-TTL gauge was published. `vault token create` grants a 768-hour TTL by default, which turned a healthy-looking cluster into one where every KMS call returned 403 about a month later, with no self-healing short of a restart or reconfigure. RustFS now calls `auth/token/lookup-self` at login and adopts what Vault reports: a non-expiring token behaves exactly as before, an expiring renewable one is renewed at half TTL like the other auth methods, and an expiring non-renewable one logs `vault_static_token_not_renewable` and publishes its remaining TTL. The probe never fails the login: a token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous no-lease behaviour, so no deployment that works today stops working.
- **SSE-C over a plaintext transport is reported**: AWS S3 and MinIO refuse an SSE-C request that did not arrive over TLS, because the customer key travels in a request header. RustFS accepted them on any transport and still does by default — flipping to a rejection inside a release window would break plaintext staging and test deployments. Each such request now increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process, and `RUSTFS_SSE_C_REQUIRE_TLS=true` opts into the AWS `400` now. The default is expected to flip in a later release; confirm the counter reads zero first. The verdict is per connection: a TLS listener satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts.
- **Local KMS backend on a distributed deployment says what actually breaks**: the backend keeps key material and its Argon2id salt on each node's own disk, so two nodes derive different keys from the same `master_key` and an object encrypted on one node cannot be decrypted on another — intermittent 500s behind a load balancer. Configuring it while the deployment is distributed now logs `kms_node_local_backend_in_distributed_deployment` and appends that consequence to the `kms/configure` response, instead of only the generic "development only" positioning warning. It remains a warning, not a gate.
- **SSE-KMS is refused when no KMS is running (breaking)**: a write requesting `x-amz-server-side-encryption: aws:kms` on a node with no KMS service no longer succeeds. Earlier releases wrapped the data key with the node-local `RUSTFS_SSE_S3_MASTER_KEY` while still writing `aws:kms` and the requested key id into the object metadata — metadata that claimed a KMS protection the object never had, under a key that was never consulted. Such a request now returns `400 InvalidRequest` when KMS was never configured and `503` when a configured service is not running; the refusal is evaluated after the per-key authorization gate, so an unauthorized caller still receives `403 AccessDenied`. **Upgrade note:** a deployment that relied on this write succeeding will start receiving 4xx/503. Either configure a KMS, or request `AES256` and keep the documented SSE-S3 local-master-key fallback, which is unchanged. Objects already written this way remain readable.
- **Legacy ciphertext nonce layouts are now locked per segment**: while decrypting a v1 segment, the reader locks onto whichever of the three historical nonce layouts decoded the segment's first non-zero-index frame and rejects any later frame that needs a different one. Because a frame encrypted at block index zero authenticates under the pre-`1.0.0-alpha.91` reused-part-nonce layout at any position, an attacker able to rewrite the underlying shards could previously replay it and have the forged plaintext returned with `200`. New `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK` (default `true`) drops that third layout entirely when set to `false`, which closes the residual case of a stream built purely from repeats of frame zero. Turn it off only after migrating pre-alpha.91 encrypted objects (rewrite in place with CopyObject); see [KMS backend security properties](docs/operations/kms-backend-security.md) for what the v1 frame layout does and does not authenticate.
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer) - **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy. - **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation) - **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
Generated
+2
View File
@@ -9928,6 +9928,7 @@ dependencies = [
"async-trait", "async-trait",
"base64-simd", "base64-simd",
"bytes", "bytes",
"chrono",
"crc-fast", "crc-fast",
"futures", "futures",
"hotpath", "hotpath",
@@ -10578,6 +10579,7 @@ dependencies = [
"serde_json", "serde_json",
"sha1 0.11.0", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
"temp-env",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tokio-test", "tokio-test",
+4 -1
View File
@@ -211,7 +211,10 @@ For developers who want to build RustFS Docker images from source with multi-arc
```bash ```bash
# Build multi-architecture images locally # Build multi-architecture images locally
./docker-buildx.sh --build-arg RELEASE=latest ./docker-buildx.sh
# Build a single-platform image locally
./docker-buildx.sh -p linux/amd64
# Build and push to registry # Build and push to registry
./docker-buildx.sh --push ./docker-buildx.sh --push
+29 -9
View File
@@ -46,14 +46,31 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。 - **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
- **简单易用**:设计简洁,易于部署和管理。 - **简单易用**:设计简洁,易于部署和管理。
| 功能 | 状态 | 功能 | 状态 | 状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
| :----------------- | :------ | :---------------------- | :-------- |
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 | | 功能 | 状态 | 功能 | 状态 |
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 | | :-------------------------- | :------ | :----------------------- | :------ |
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 | | **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 | | **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 | | **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 | | **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
| **RustFS KMS** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
| **生命周期管理 (ILM)** | ✅ 可用 | **站点复制** | ✅ 可用 |
| **ILM 分层 (远端 S3)** | ✅ 可用 | **存储桶配额** | ✅ 可用 |
| **S3 Select** | ✅ 可用 | **事件通知** | ✅ 可用 |
| **S3 Tables (Iceberg REST)**| 🧪 预览 | **审计日志** | ✅ 可用 |
| **IAM / 策略** | ✅ 可用 | **日志与可观测性** | ✅ 可用 |
| **OIDC / SSO** | ✅ 可用 | **Web 控制台** | ✅ 可用 |
| **Keystone 认证** | ✅ 可用 | **K8s Helm Chart** | ✅ 可用 |
| **Swift API** | ✅ 可用 | **FTPS / WebDAV** | ✅ 可用 |
| **多租户** | ✅ 可用 | **SFTP** | ✅ 可用 |
| **MinIO 磁盘格式兼容** | 🧪 预览 | | |
说明:
- **服务端加密**:支持 SSE-C、SSE-S3 与 SSE-KMS。SSE-KMS 必须先配置 KMS 服务;未配置 KMS 时请求 `aws:kms` 会被拒绝,不会降级到本地主密钥。
- **RustFS KMS**:生产环境支持 VaultKV2 / Transit)与 AWS KMS 后端;`Local``Static` 后端仅供开发与测试使用,详见 [KMS 后端安全属性](docs/operations/kms-backend-security.md)。
## RustFS vs MinIO 性能对比 ## RustFS vs MinIO 性能对比
@@ -150,7 +167,10 @@ docker compose -f docker-compose-simple.yml up -d
```bash ```bash
# 在本地构建多架构镜像 # 在本地构建多架构镜像
./docker-buildx.sh --build-arg RELEASE=latest ./docker-buildx.sh
# 在本地构建单平台镜像
./docker-buildx.sh -p linux/amd64
# 构建并推送到仓库 # 构建并推送到仓库
./docker-buildx.sh --push ./docker-buildx.sh --push
+55
View File
@@ -593,6 +593,20 @@ pub struct DataUsageSnapshotIdentity {
pub scanner_epoch: Option<u64>, pub scanner_epoch: Option<u64>,
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSegmentInvalidationProof {
#[serde(default)]
pub process_epoch: String,
#[serde(default)]
pub generation_start: u64,
#[serde(default)]
pub generation_end: u64,
#[serde(default)]
pub producer_identity_coverage_complete: bool,
#[serde(default)]
pub cold_zero_walk_oracle: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSnapshotSetState { pub struct DataUsageSnapshotSetState {
pub pool_index: u64, pub pool_index: u64,
@@ -607,6 +621,8 @@ pub struct DataUsageSnapshotSetState {
pub complete: bool, pub complete: bool,
#[serde(default)] #[serde(default)]
pub tombstone: bool, pub tombstone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
} }
impl DataUsageInfo { impl DataUsageInfo {
@@ -3073,6 +3089,7 @@ mod tests {
scan_plan_digest: Some([1; 32]), scan_plan_digest: Some([1; 32]),
complete: false, complete: false,
tombstone: false, tombstone: false,
segment_invalidation_proof: None,
}]; }];
assert!(observed_data_usage_is_newer(&partial, &authoritative)); assert!(observed_data_usage_is_newer(&partial, &authoritative));
} }
@@ -3095,6 +3112,7 @@ mod tests {
scan_plan_digest: Some([1; 32]), scan_plan_digest: Some([1; 32]),
complete: true, complete: true,
tombstone: false, tombstone: false,
segment_invalidation_proof: None,
}, },
DataUsageSnapshotSetState { DataUsageSnapshotSetState {
pool_index: 1, pool_index: 1,
@@ -3104,6 +3122,7 @@ mod tests {
scan_plan_digest: Some([2; 32]), scan_plan_digest: Some([2; 32]),
complete: false, complete: false,
tombstone: false, tombstone: false,
segment_invalidation_proof: None,
}, },
], ],
..Default::default() ..Default::default()
@@ -3113,6 +3132,42 @@ mod tests {
assert!(partial.is_valid_partial_snapshot()); assert!(partial.is_valid_partial_snapshot());
} }
#[test]
fn set_state_segment_invalidation_proof_is_additive() {
#[derive(Deserialize)]
struct LegacySetState {
pool_index: u64,
set_index: u64,
complete: bool,
}
let proof = DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 3,
generation_end: 5,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
};
let state = DataUsageSnapshotSetState {
pool_index: 1,
set_index: 2,
scanner_cycle: Some(9),
scanner_epoch: Some(4),
scan_plan_digest: Some([7; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: Some(proof.clone()),
};
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
assert_eq!(legacy.pool_index, 1);
assert_eq!(legacy.set_index, 2);
assert!(legacy.complete);
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
}
#[test] #[test]
fn completeness_marker_requires_a_snapshot_timestamp() { fn completeness_marker_requires_a_snapshot_timestamp() {
let untimestamped = DataUsageInfo { let untimestamped = DataUsageInfo {
+2
View File
@@ -30,6 +30,8 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup | | **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory | | **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
The external-tool `storage_metric_ownership_test` validates the OTLP/Collector/Prometheus path, including a rolling upgrade and node failures. See the [storage metrics guide](../../docs/operations/storage-metrics.md) for its required binaries and focused command.
## How to run ## How to run
All commands assume repo root. `cargo test` triggers an on-demand build of the All commands assume repo root. `cargo test` triggers an on-demand build of the
+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
} }
} }
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned()); let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty()); let dirty = git(&root, &["status", "--porcelain", "--untracked-files=no"]).is_none_or(|status| !status.is_empty());
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned()); let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
let mut features = std::env::vars() let mut features = std::env::vars()
.filter_map(|(key, _)| { .filter_map(|(key, _)| {
-54
View File
@@ -44,7 +44,6 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::error::Error; use std::error::Error;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::time::{Duration, Instant, sleep};
use tracing::info; use tracing::info;
use uuid::Uuid; use uuid::Uuid;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -375,33 +374,6 @@ pub(crate) fn census_object_version_on_disk(
}) })
} }
/// Wait for the background PUT tail to commit every physical part on one disk.
/// Invalid metadata remains an immediate error instead of a retryable absence.
pub(crate) async fn wait_for_complete_physical_shard_on_disk(
disk: &Path,
bucket: &str,
key: &str,
version_id: Option<&str>,
timeout: Duration,
) -> ChaosResult<VersionShardCensus> {
let deadline = Instant::now() + timeout;
loop {
let census = census_object_version_on_disk(disk, bucket, key, version_id)?;
if census.is_complete() && !census.expected_part_numbers.is_empty() {
return Ok(census);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"physical shard for {bucket}/{key}@{version_id:?} on {} did not become complete within {timeout:?}: {census:?}",
disk.display()
)
.into());
}
sleep(remaining.min(Duration::from_millis(50))).await;
}
}
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the /// `POST` a signed (SigV4, service `s3`) admin request without relying on the
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal /// external `awscurl` binary. Mirrors the admin heal calls used by the heal
/// regression suite. /// regression suite.
@@ -479,30 +451,4 @@ mod tests {
assert!(expected.matches_manifest(&expected)); assert!(expected.matches_manifest(&expected));
assert!(!changed.matches_manifest(&expected)); assert!(!changed.matches_manifest(&expected));
} }
#[tokio::test]
async fn physical_shard_readiness_fails_closed_with_last_census() {
let disk = tempfile::tempdir().expect("temporary disk");
let error = wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "missing", None, Duration::ZERO)
.await
.expect_err("missing physical shards must fail the baseline gate");
assert!(error.to_string().contains("has_xl_meta: false"));
assert!(error.to_string().contains("bucket/missing"));
}
#[tokio::test]
async fn physical_shard_readiness_does_not_retry_invalid_metadata() {
let disk = tempfile::tempdir().expect("temporary disk");
let object = disk.path().join("bucket").join("corrupt");
std::fs::create_dir_all(&object).expect("object directory");
std::fs::write(object.join("xl.meta"), b"invalid metadata").expect("corrupt metadata fixture");
let error = tokio::time::timeout(
Duration::from_secs(1),
wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "corrupt", None, Duration::from_secs(30)),
)
.await
.expect("corrupt metadata must fail immediately")
.expect_err("invalid metadata must not be accepted as a complete baseline");
assert!(!error.to_string().contains("did not become complete"));
}
} }
@@ -268,6 +268,7 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
let rule = CorsRule::builder() let rule = CorsRule::builder()
.allowed_methods("GET") .allowed_methods("GET")
.allowed_origins("https://example.com") .allowed_origins("https://example.com")
.allowed_headers("*")
.build()?; .build()?;
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?; let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
@@ -288,6 +289,60 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
assert_eq!(rules[0].allowed_methods(), ["GET"]); assert_eq!(rules[0].allowed_methods(), ["GET"]);
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]); assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
let http = reqwest::Client::builder().no_proxy().build()?;
let url = format!("http://{}/{}", cluster.nodes[1].address, BUCKET_METADATA_RELOAD_BUCKET);
let without_headers = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.send()
.await?;
assert!(without_headers.status().is_success());
assert!(!without_headers.headers().contains_key("access-control-allow-headers"));
assert!(
without_headers
.headers()
.get("vary")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
"a cached header-free preflight must not suppress a later requested header grant"
);
let preflight = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "X-Another-Header, x-could-be-anything")
.send()
.await?;
assert!(preflight.status().is_success(), "peer preflight should succeed: {preflight:?}");
assert_eq!(
preflight
.headers()
.get("access-control-allow-headers")
.and_then(|value| value.to_str().ok()),
Some("x-another-header,x-could-be-anything"),
"a wildcard rule must return only the headers requested by this preflight"
);
assert!(
preflight
.headers()
.get("vary")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
"preflight caches must distinguish the requested header list"
);
let denied = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://disallowed.example.com")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "x-another-header")
.send()
.await?;
assert!(
!denied.headers().contains_key("access-control-allow-headers"),
"a rejected origin must not receive the requested header grant"
);
writer writer
.delete_bucket_cors() .delete_bucket_cors()
.bucket(BUCKET_METADATA_RELOAD_BUCKET) .bucket(BUCKET_METADATA_RELOAD_BUCKET)
+33 -27
View File
@@ -34,7 +34,7 @@ use serde_json;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs as stdfs; use std::fs as stdfs;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::net::{Ipv6Addr, SocketAddr}; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Once; use std::sync::Once;
@@ -188,6 +188,15 @@ fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Sen
Ok(()) Ok(())
} }
fn no_available_test_port_error(config: TestPortAllocatorConfig, attempts: u16, last_error: Option<&std::io::Error>) -> String {
let max_inclusive = config.max_exclusive() - 1;
let detail = last_error.map(|err| format!("; last bind error: {err}")).unwrap_or_default();
format!(
"no available E2E test port found in {}..={} after {} attempts{}",
config.min, max_inclusive, attempts, detail
)
}
pub(crate) fn capture_command_logs( pub(crate) fn capture_command_logs(
command: &mut Command, command: &mut Command,
log_path: Option<&str>, log_path: Option<&str>,
@@ -694,19 +703,23 @@ impl RustFSTestEnvironment {
let _guard = PortAllocatorGuard::acquire().await?; let _guard = PortAllocatorGuard::acquire().await?;
let config = test_port_allocator_config()?; let config = test_port_allocator_config()?;
let mut next_port = read_next_test_port(config); let mut next_port = read_next_test_port(config);
let mut last_error = None;
for _ in 0..config.range { for _ in 0..config.range {
let port = next_port; let port = next_port;
next_port = advance_test_port(next_port, config); next_port = advance_test_port(next_port, config);
write_next_test_port(next_port)?; write_next_test_port(next_port)?;
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) { match TcpListener::bind(("127.0.0.1", port)) {
drop(listener); Ok(listener) => {
return Ok(port); drop(listener);
return Ok(port);
}
Err(err) => last_error = Some(err),
} }
} }
Err("no available E2E test port found".into()) Err(no_available_test_port_error(config, config.range, last_error.as_ref()).into())
} }
/// Kill any existing RustFS processes /// Kill any existing RustFS processes
@@ -1468,9 +1481,7 @@ impl RustFSTestClusterEnvironment {
return Err(format!("a volume proxy is already configured for node {node_idx}").into()); return Err(format!("a volume proxy is already configured for node {node_idx}").into());
} }
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?; let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
// Endpoint locality requires the server port to match. IPv6 loopback let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
// keeps that port while leaving the direct IPv4 S3 listener available.
let proxy = crate::fault_proxy::FaultProxy::start_on((Ipv6Addr::LOCALHOST, target.port()).into(), target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr()); self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy) Ok(proxy)
} }
@@ -2203,6 +2214,19 @@ mod tests {
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err()); assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
} }
#[test]
fn e2e_port_allocator_reports_attempt_window_and_last_bind_error() {
let config = TestPortAllocatorConfig { min: 41000, range: 3 };
let error = std::io::Error::from(ErrorKind::PermissionDenied);
let message = no_available_test_port_error(config, config.range, Some(&error));
assert!(message.contains("41000..=41002"));
assert!(message.contains("after 3 attempts"));
assert!(message.contains("last bind error"));
assert!(message.contains("permission denied"));
}
#[test] #[test]
fn resolves_rustfs_binary_in_configured_cargo_target_directory() { fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
let workspace = Path::new("workspace"); let workspace = Path::new("workspace");
@@ -2375,7 +2399,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn volume_proxy_rewrites_cluster_volume_endpoint() { async fn volume_proxy_rewrites_cluster_volume_endpoint() {
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2)) let mut env = RustFSTestClusterEnvironment::new(1)
.await .await
.expect("cluster environment should allocate a node"); .expect("cluster environment should allocate a node");
let direct = env.nodes[0].address.clone(); let direct = env.nodes[0].address.clone();
@@ -2390,24 +2414,6 @@ mod tests {
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}"); assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
proxy.shutdown().await; proxy.shutdown().await;
for node in &env.nodes {
let local_port = node.address.parse::<SocketAddr>().expect("node address").port();
let local_paths = volumes
.split_whitespace()
.filter_map(|volume| {
let endpoint = reqwest::Url::parse(volume).expect("volume endpoint");
rustfs_utils::is_local_host(
endpoint.host().expect("volume endpoint host"),
endpoint.port().expect("volume endpoint port"),
local_port,
)
.expect("endpoint locality")
.then(|| endpoint.path().to_string())
})
.collect::<Vec<_>>();
assert_eq!(local_paths, node.data_dirs, "the proxy must preserve local disk ownership");
}
} }
#[test] #[test]
@@ -962,27 +962,6 @@ pub(crate) async fn wait_for_rebalance_active(
} }
} }
pub(crate) async fn wait_for_rebalance_running_with_progress(
cluster: &RustFSTestClusterEnvironment,
expected_id: &str,
timeout: Duration,
) -> TestResult {
let deadline = Instant::now() + timeout;
loop {
let status = rebalance_status_json(cluster).await?;
if rebalance_running_with_progress(&status, expected_id)? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}"
)
.into());
}
sleep(Duration::from_millis(100)).await;
}
}
pub(crate) async fn wait_for_rebalance_complete( pub(crate) async fn wait_for_rebalance_complete(
cluster: &RustFSTestClusterEnvironment, cluster: &RustFSTestClusterEnvironment,
expected_id: &str, expected_id: &str,
+227 -64
View File
@@ -12,22 +12,30 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until}; use super::harness::{
use crate::chaos::{ DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post, wait_for_complete_physical_shard_on_disk, wait_until,
}; };
use crate::common::{init_logging, rustfs_binary_path}; use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run}; use crate::common::init_logging;
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use tokio::time::{Instant, sleep};
const EC84_NODE_COUNT: usize = 3; const EC84_NODE_COUNT: usize = 3;
const EC84_DRIVES_PER_NODE: usize = 4; const EC84_DRIVES_PER_NODE: usize = 4;
const EC84_DATA_BLOCKS: usize = 8; const EC84_DATA_BLOCKS: usize = 8;
const EC84_PARITY_BLOCKS: usize = 4; const EC84_PARITY_BLOCKS: usize = 4;
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
const EC84_HEAL_CONTROL_RETRY_DELAY: Duration = Duration::from_millis(250);
#[derive(Clone)] #[derive(Clone)]
struct ExpectedShard { struct ExpectedShard {
@@ -36,6 +44,91 @@ struct ExpectedShard {
baseline: VersionShardCensus, baseline: VersionShardCensus,
} }
struct ScannerHealEvidenceContext {
directory: PathBuf,
run: Value,
}
struct ScannerHealEvidencePayload<'a> {
dist: &'a DistCluster,
bucket: &'a str,
expected: &'a [ExpectedShard],
outage_key: &'a str,
outage_body: &'a [u8],
replaced_drive: &'a Path,
pid_before: u32,
pid_after: u32,
node_listings: Vec<Vec<String>>,
}
fn file_sha256(path: &Path) -> TestResult<String> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn compiled_test_identity() -> Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
let mut current = value;
for segment in path.split('.') {
current = current
.get(segment)
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
}
current
.as_str()
.filter(|text| !text.is_empty())
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
}
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
if built[key] != run["test_build"][key] {
return Err(format!("compiled test identity differs for {key}").into());
}
}
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
return Err("server binary must match the run receipt".into());
}
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
return Err("test executable must match the run receipt".into());
}
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(ScannerHealEvidenceContext { directory, run }))
}
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult { fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) { if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into()); return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
@@ -52,6 +145,65 @@ fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
Ok(()) Ok(())
} }
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
let verifier = payload.dist.client(0)?;
let mut objects = Vec::new();
for item in payload.expected {
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
objects.push(serde_json::json!({
"key": item.key,
"version_id": null,
"expected_bytes": item.body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(&item.body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": item.baseline,
"physical": physical,
}));
}
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
objects.push(serde_json::json!({
"key": payload.outage_key,
"version_id": null,
"expected_bytes": payload.outage_body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(payload.outage_body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": null,
"physical": physical,
}));
let evidence = serde_json::json!({
"schema": 1,
"case": EC84_TARGET_DRIVE_RESTART_CASE,
"evidence": "process-restart",
"run_id": string_field(&context.run, "run_id")?,
"source_revision": string_field(&context.run, "source_revision")?,
"test_build": compiled_test_identity(),
"binary_sha256": string_field(&context.run, "binary.sha256")?,
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
"pid_before": payload.pid_before,
"pid_after": payload.pid_after,
"unclean_shutdown_marker": false,
"objects": objects,
"node_listings": payload.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult { fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
for key in keys { for key in keys {
let census = census_object_version_on_disk(drive, bucket, key, None)?; let census = census_object_version_on_disk(drive, bucket, key, None)?;
@@ -62,6 +214,29 @@ fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> T
Ok(()) Ok(())
} }
fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
let message = error.to_string();
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
}
async fn start_ec84_root_heal_when_control_ready(
heal_url: &str,
heal_body: &str,
access_key: &str,
secret_key: &str,
) -> TestResult {
let deadline = Instant::now() + EC84_HEAL_CONTROL_READY_TIMEOUT;
loop {
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
Ok(_) => return Ok(()),
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && Instant::now() < deadline => {
sleep(EC84_HEAL_CONTROL_RETRY_DELAY).await;
}
Err(error) => return Err(error),
}
}
}
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> { async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
let mut expected = Vec::new(); let mut expected = Vec::new();
for index in 0..4 { for index in 0..4 {
@@ -90,19 +265,7 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
#[tokio::test] #[tokio::test]
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult { async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
init_logging(); init_logging();
let server_binary = rustfs_binary_path(); let evidence_context = scanner_heal_evidence_context()?;
let evidence_run = restart_evidence_run(
&server_binary,
ScannerHealEvidenceCase {
id: "ec84-target-drive-restart",
oracle: "ec84-target-drive-restart.json",
evidence: "process-restart",
unclean_shutdown_marker: false,
topology: EvidenceTopology::new(3, 4),
storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some("12"),
},
)?;
let mut dist = DistCluster::start_with_env( let mut dist = DistCluster::start_with_env(
DistLayout::ThreeByFourEc84, DistLayout::ThreeByFourEc84,
&[ &[
@@ -126,24 +289,18 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]); let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
for item in &mut expected { for item in &mut expected {
item.baseline = item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
wait_for_complete_physical_shard_on_disk(&replaced_drive, &bucket, &item.key, None, Duration::from_secs(10)).await?;
assert_ec84_geometry(&item.baseline, &item.key)?; assert_ec84_geometry(&item.baseline, &item.key)?;
} }
let format_path = replaced_drive.join(".rustfs.sys").join("format.json"); let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
let format_json = std::fs::read(&format_path)?; let format_json = std::fs::read(&format_path)?;
let pid_before = dist.cluster.nodes[replaced_node] let target_pid_before = dist.cluster.nodes[replaced_node]
.process .process
.as_ref() .as_ref()
.ok_or("target process is absent")? .ok_or("target process is absent before graceful restart")?
.id(); .id();
dist.cluster.stop_node_gracefully(replaced_node).await?; dist.cluster.stop_node_gracefully(replaced_node).await?;
let unclean_shutdown_marker = Path::new(&dist.cluster.nodes[replaced_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown")
.is_file();
assert!(!unclean_shutdown_marker, "graceful target shutdown must remove its unclean marker");
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display())); let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?; std::fs::rename(&replaced_drive, &retired_drive)?;
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?; std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
@@ -165,10 +322,15 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.await?; .await?;
dist.cluster.start_node(replaced_node).await?; dist.cluster.start_node(replaced_node).await?;
let target_pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent after restart")?
.id();
let heal_body = let heal_body =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#; r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url); let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?; start_ec84_root_heal_when_control_ready(&heal_url, heal_body, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
wait_until( wait_until(
Duration::from_secs(120), Duration::from_secs(120),
@@ -205,46 +367,47 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.filter_map(|object| object.key().map(str::to_owned)) .filter_map(|object| object.key().map(str::to_owned))
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal"); assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
let mut keys = observed.into_iter().collect::<Vec<_>>(); let mut observed = observed.into_iter().collect::<Vec<_>>();
keys.sort(); observed.sort();
node_listings.push(keys); node_listings.push(observed);
} }
if let Some(context) = evidence_context {
if let Some(evidence_run) = evidence_run { write_scanner_heal_evidence(
let target_client = dist.client(replaced_node)?; context,
let mut objects = Vec::with_capacity(inventory.len()); ScannerHealEvidencePayload {
for (key, body) in &inventory { dist: &dist,
let response = target_client.get_object().bucket(&bucket).key(key).send().await?; bucket: &bucket,
let actual = response.body.collect().await?.into_bytes(); expected: &expected,
assert_eq!(actual.as_ref(), body.as_slice(), "object body changed for {key}"); outage_key,
let physical = census_object_version_on_disk(&replaced_drive, &bucket, key, None)?; outage_body: &outage_body,
assert_ec84_geometry(&physical, key)?; replaced_drive: &replaced_drive,
let baseline = expected.iter().find(|item| item.key == *key).map(|item| &item.baseline); pid_before: target_pid_before,
objects.push(serde_json::json!({ pid_after: target_pid_after,
"key": key, "version_id": null,
"expected_bytes": body.len(), "actual_bytes": actual.len(),
"expected_sha256": sha256_hex(body), "actual_sha256": sha256_hex(&actual),
"expected_physical": baseline, "physical": physical,
}));
}
let pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("restarted target is absent")?
.id();
evidence_run.write(
&server_binary,
RestartObservation {
nodes: dist.cluster.nodes.len(),
drives_per_node: dist.cluster.topology.drives_per_node,
pid_before,
pid_after,
unclean_shutdown_marker,
objects,
node_listings, node_listings,
}, },
)?; )
.await?;
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cluster_heal_coordination_retry_is_exact() {
let retryable: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 500 Internal Server Error cluster heal coordination unavailable".into();
assert!(is_cluster_heal_coordination_unavailable(retryable.as_ref()));
let other_internal: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 500 Internal Server Error unrelated".into();
assert!(!is_cluster_heal_coordination_unavailable(other_internal.as_ref()));
let wrong_status: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
}
}
@@ -14,9 +14,9 @@
use super::harness::{ use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress, DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json, decommission_status_json, put_inventory_retrying, rebalance_active, rebalance_status_json, retrying_get_equals, retrying_put,
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress, wait_for_decommission_running_with_progress, wait_for_rebalance_active, wait_for_rebalance_complete,
}; };
use crate::common::init_logging; use crate::common::init_logging;
use std::time::Duration; use std::time::Duration;
@@ -67,7 +67,10 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
assert_inventory(&live, &bucket, &inventory).await?; assert_inventory(&live, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?; let rebalance_id = start_rebalance(&dist.cluster).await?;
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?; // The status API reads persisted progress, whose first periodic save is
// after 30 seconds. A shorter run can remain at zero until completion.
// Require Started around the S3 operations and nonzero progress at completion.
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
retrying_put( retrying_put(
&live, &live,
&bucket, &bucket,
@@ -84,11 +87,26 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
Duration::from_secs(30), Duration::from_secs(30),
) )
.await?; .await?;
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed
.contents()
.iter()
.any(|object| object.key() == Some("during-rebalance.bin")),
"list during rebalance missed the newly written key"
);
let status = rebalance_status_json(&dist.cluster).await?; let status = rebalance_status_json(&dist.cluster).await?;
if !rebalance_running_with_progress(&status, &rebalance_id)? { if !rebalance_active(&status, &rebalance_id)? {
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into()); return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
} }
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?; wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?; let after = dist.client(1)?;
assert_inventory(&after, &bucket, &inventory).await?;
for (key, body) in [
("during-decommission.bin", b"written-while-decommissioning".as_slice()),
("during-rebalance.bin", b"written-while-rebalancing".as_slice()),
] {
retrying_get_equals(&after, &bucket, key, body, Duration::from_secs(30)).await?;
}
Ok(()) Ok(())
} }
@@ -498,9 +498,7 @@ mod tests {
.stderr(log) .stderr(log)
.spawn()?, .spawn()?,
); );
// macOS evaluates each fresh binary copy before its capability hook can run. let status = tokio::time::timeout(Duration::from_secs(10), async {
let probe_timeout = if cfg!(target_os = "macos") { 60 } else { 10 };
let status = tokio::time::timeout(Duration::from_secs(probe_timeout), async {
loop { loop {
if let Some(status) = child.0.try_wait()? { if let Some(status) = child.0.try_wait()? {
return Ok::<_, std::io::Error>(status); return Ok::<_, std::io::Error>(status);
+1 -6
View File
@@ -114,12 +114,7 @@ impl FaultProxy {
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted /// Bind a listener on `127.0.0.1:0` and start forwarding accepted
/// connections to `target`. Starts in [`FaultMode::Pass`]. /// connections to `target`. Starts in [`FaultMode::Pass`].
pub async fn start(target: SocketAddr) -> io::Result<Self> { pub async fn start(target: SocketAddr) -> io::Result<Self> {
Self::start_on((Ipv4Addr::LOCALHOST, 0).into(), target).await let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
}
/// Bind the selected address and forward accepted connections to `target`.
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind(address).await?;
let listen_addr = listener.local_addr()?; let listen_addr = listener.local_addr()?;
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass); let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
@@ -401,11 +401,11 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?; let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch"); assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
baseline_degraded.insert(multipart_key.to_string(), mp_view); baseline_degraded.insert(multipart_key.to_string(), mp_view);
// Stop disk writers before restoring the complete layout reused by Phase B. // Restore the disk so Phase B restarts from a clean, complete disk set.
harness.kill_server();
harness.bring_disk_online(0)?; harness.bring_disk_online(0)?;
// ---- Phase B: codec streaming (gates opened) ---- // ---- Phase B: codec streaming (gates opened) ----
harness.kill_server();
for (k, v) in codec_env() { for (k, v) in codec_env() {
harness.set_env(k, v); harness.set_env(k, v);
} }
@@ -497,8 +497,6 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?; let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch"); assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
codec_degraded.insert(multipart_key.to_string(), mp_view); codec_degraded.insert(multipart_key.to_string(), mp_view);
// All server reads are complete; stop disk writers before restoring disk0.
harness.kill_server();
harness.bring_disk_online(0)?; harness.bring_disk_online(0)?;
// A/B under parity reconstruction: codec == legacy, byte-for-byte and // A/B under parity reconstruction: codec == legacy, byte-for-byte and
@@ -16,20 +16,18 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::chaos::{ use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post,
wait_for_complete_physical_shard_on_disk,
};
use crate::common::{ use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
rustfs_binary_path, init_logging, rustfs_binary_path,
}; };
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use crate::storage_api::RUSTFS_META_BUCKET; use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use http::Method; use http::Method;
use sha2::{Digest, Sha256};
use std::collections::HashSet; use std::collections::HashSet;
use std::error::Error; use std::error::Error;
use std::io::{Read, Write};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -41,6 +39,88 @@ mod tests {
const POOL_METADATA_OBJECT: &str = "pool.bin"; const POOL_METADATA_OBJECT: &str = "pool.bin";
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
struct ScannerHealEvidenceCase {
id: &'static str,
oracle: &'static str,
evidence: &'static str,
unclean_shutdown_marker: bool,
topology: EvidenceTopology,
storage_class_standard: Option<&'static str>,
erasure_set_drive_count: Option<usize>,
outage_target_manifest_required: bool,
}
#[derive(Clone, Copy)]
struct EvidenceTopology {
nodes: usize,
drives_per_node: usize,
layout: EvidenceTopologyLayout,
}
#[derive(Clone, Copy)]
enum EvidenceTopologyLayout {
SinglePool,
PerNodePools,
}
impl EvidenceTopology {
const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self {
nodes,
drives_per_node,
layout: EvidenceTopologyLayout::SinglePool,
}
}
const fn per_node_pools(nodes: usize, drives_per_node: usize) -> Self {
Self {
nodes,
drives_per_node,
layout: EvidenceTopologyLayout::PerNodePools,
}
}
fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
fn cluster_topology(self) -> ClusterTopology {
match self.layout {
EvidenceTopologyLayout::SinglePool => ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node),
EvidenceTopologyLayout::PerNodePools => {
ClusterTopology::per_node_pools(self.drives_per_node, (0..self.nodes).map(|node| vec![node]).collect())
}
}
}
fn pool_count(self) -> usize {
match self.layout {
EvidenceTopologyLayout::SinglePool => 1,
EvidenceTopologyLayout::PerNodePools => self.nodes,
}
}
fn set_count(self, erasure_set_drive_count: Option<usize>) -> usize {
self.total_drives() / erasure_set_drive_count.unwrap_or_else(|| self.total_drives())
}
}
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-restart", id: "background-target-restart",
oracle: "background-target-restart.json", oracle: "background-target-restart.json",
@@ -49,6 +129,7 @@ mod tests {
topology: EvidenceTopology::new(4, 1), topology: EvidenceTopology::new(4, 1),
storage_class_standard: None, storage_class_standard: None,
erasure_set_drive_count: None, erasure_set_drive_count: None,
outage_target_manifest_required: true,
}; };
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
@@ -59,6 +140,7 @@ mod tests {
topology: EvidenceTopology::new(4, 1), topology: EvidenceTopology::new(4, 1),
storage_class_standard: None, storage_class_standard: None,
erasure_set_drive_count: None, erasure_set_drive_count: None,
outage_target_manifest_required: true,
}; };
const BACKGROUND_TARGET_RESTART_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { const BACKGROUND_TARGET_RESTART_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
@@ -68,7 +150,8 @@ mod tests {
unclean_shutdown_marker: false, unclean_shutdown_marker: false,
topology: EvidenceTopology::new(3, 4), topology: EvidenceTopology::new(3, 4),
storage_class_standard: Some("EC:4"), storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some("12"), erasure_set_drive_count: Some(12),
outage_target_manifest_required: true,
}; };
const BACKGROUND_TARGET_CRASH_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { const BACKGROUND_TARGET_CRASH_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
@@ -78,9 +161,107 @@ mod tests {
unclean_shutdown_marker: true, unclean_shutdown_marker: true,
topology: EvidenceTopology::new(3, 4), topology: EvidenceTopology::new(3, 4),
storage_class_standard: Some("EC:4"), storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some("12"), erasure_set_drive_count: Some(12),
outage_target_manifest_required: true,
}; };
const BACKGROUND_TARGET_RESTART_EC84_MULTI_SET_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-restart-ec8-4-multi-set",
oracle: "background-target-restart-ec8-4-multi-set.json",
evidence: "process-restart",
unclean_shutdown_marker: false,
topology: EvidenceTopology::new(3, 8),
storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some(12),
outage_target_manifest_required: true,
};
const BACKGROUND_TARGET_CRASH_EC84_MULTI_POOL_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-crash-ec8-4-multi-pool",
oracle: "background-target-crash-ec8-4-multi-pool.json",
evidence: "process-crash-restart",
unclean_shutdown_marker: true,
topology: EvidenceTopology::per_node_pools(3, 12),
storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some(12),
outage_target_manifest_required: false,
};
struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
struct TcpPortBlackhole { struct TcpPortBlackhole {
port: u16, port: u16,
comment: String, comment: String,
@@ -385,79 +566,6 @@ mod tests {
) )
} }
async fn wait_for_admin_cluster_start_log(
log_path: &Path,
client_token: &str,
deadline: Instant,
) -> Result<(), Box<dyn Error + Send + Sync>> {
loop {
let coordinator_log = std::fs::read_to_string(log_path)?;
if coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
})
{
return Ok(());
}
if Instant::now() >= deadline {
return Err(
format!("node 0 must have started the exact admin task before interruption: task_id={client_token}").into(),
);
}
sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn test_admin_cluster_start_log_waits_for_exact_delayed_event() -> Result<(), Box<dyn Error + Send + Sync>> {
use std::io::Write;
let mut log = tempfile::NamedTempFile::new()?;
for (task_id, heal_type, state) in [
("other-task", "cluster", "started"),
("admin-task", "object", "started"),
("admin-task", "cluster", "completed"),
] {
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": task_id, "heal_type": heal_type, "state": state})
)?;
}
let log_path = log.path().to_path_buf();
let started = wait_for_admin_cluster_start_log(&log_path, "admin-task", Instant::now() + Duration::from_secs(1));
tokio::pin!(started);
// Poll the reader before publishing the start event, without depending
// on scheduling or a fixed writer delay to reproduce log visibility.
tokio::select! {
biased;
result = &mut started => panic!("unrelated events must leave the exact start pending: {result:?}"),
_ = std::future::ready(()) => {}
}
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": "admin-task", "heal_type": "cluster", "state": "started"})
)?;
started.await?;
Ok(())
}
#[tokio::test]
async fn test_admin_cluster_start_log_respects_existing_deadline() -> Result<(), Box<dyn Error + Send + Sync>> {
let log = tempfile::NamedTempFile::new()?;
let error = wait_for_admin_cluster_start_log(log.path(), "admin-task", Instant::now())
.await
.expect_err("missing exact start must fail at the supplied deadline");
assert!(error.to_string().contains("task_id=admin-task"), "{error}");
Ok(())
}
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool { fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"]; let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true) status["clusterStatusComplete"] == serde_json::Value::Bool(true)
@@ -894,10 +1002,7 @@ mod tests {
cluster: &RustFSTestClusterEnvironment, cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64, previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> { ) -> Result<u64, Box<dyn Error + Send + Sync>> {
let started = Instant::now(); let deadline = Instant::now() + Duration::from_secs(60);
let mut deadline = started + Duration::from_secs(60);
let catch_up_deadline = deadline + Duration::from_secs(300);
let mut catch_up_wait_observed = false;
loop { loop {
let mut latest_cycle_end = 0; let mut latest_cycle_end = 0;
let mut versions_observed = false; let mut versions_observed = false;
@@ -925,61 +1030,24 @@ mod tests {
let versions_scanned = metrics["versions_scanned"] let versions_scanned = metrics["versions_scanned"]
.as_u64() .as_u64()
.ok_or("scanner status is missing its version-coverage counter")?; .ok_or("scanner status is missing its version-coverage counter")?;
let cycle_result = metrics["last_cycle_result"] latest_cycle_end = latest_cycle_end.max(cycle_end);
.as_str()
.ok_or("scanner status is missing its cycle result")?;
if cycle_result == "success" {
latest_cycle_end = latest_cycle_end.max(cycle_end);
}
versions_observed |= versions_scanned > 0; versions_observed |= versions_scanned > 0;
let backlog = &status["pause_backlog"];
if !catch_up_wait_observed
&& backlog["persistence_state"].as_str() == Some("healthy")
&& backlog["durable"].as_bool() == Some(true)
&& backlog["phase"].as_str() == Some("catching_up")
&& backlog["rate_limited"].as_bool() == Some(true)
&& backlog["retry_exhausted"].as_bool() == Some(false)
{
let next_attempt = backlog["next_attempt_at_unix_secs"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its next attempt")?;
let interval = backlog["thresholds"]["catch_up_min_interval_seconds"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its catch-up interval")?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
let remaining = next_attempt.saturating_sub(now);
if remaining > 0 {
if interval > 300 || remaining > interval {
return Err(
format!("scanner catch-up schedule exceeds the bounded recovery budget: {backlog}").into()
);
}
// The durable catch-up interval overrides SCANNER_CYCLE=1.
// Honor one observed retry without restarting the deadline on every poll.
deadline = deadline
.max(Instant::now() + Duration::from_secs(remaining + 60))
.min(catch_up_deadline);
catch_up_wait_observed = true;
}
}
observations.push(format!( observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, backlog={}", "node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"], metrics["current_cycle"],
metrics["current_cycle_active"], metrics["current_cycle_active"],
metrics["leader_lock_state"], metrics["leader_lock_state"],
metrics["last_cycle_result"], metrics["last_cycle_result"],
backlog,
)); ));
} }
// Only a successful coordinator cycle counts as completion; deferred // The coordinator records cycle completion, but remote workers
// and superseded attempts also advance its end timestamp. Remote // record scanned versions. Both witnesses need not share a node.
// workers record version coverage, so the witnesses can span nodes.
if latest_cycle_end > previous_cycle_end && versions_observed { if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end); return Ok(latest_cycle_end);
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
return Err(format!( return Err(format!(
"enabled scanner did not complete a successful object-scanning cycle after {previous_cycle_end}: {observations:?}" "enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
) )
.into()); .into());
} }
@@ -999,7 +1067,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart() async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> { -> Result<(), Box<dyn Error + Send + Sync>> {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
) )
.await? .await?
@@ -1009,7 +1077,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash() async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> { -> Result<(), Box<dyn Error + Send + Sync>> {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
) )
.await? .await?
@@ -1019,7 +1087,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart() async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> { -> Result<(), Box<dyn Error + Send + Sync>> {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
) )
.await? .await?
@@ -1029,17 +1097,37 @@ mod tests {
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash() async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> { -> Result<(), Box<dyn Error + Send + Sync>> {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
) )
.await? .await?
} }
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(600),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84MultiSet),
)
.await?
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_pool_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(600),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84MultiPool),
)
.await?
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>> async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{ {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
) )
.await? .await?
@@ -1074,6 +1162,8 @@ mod tests {
BackgroundTargetCrash, BackgroundTargetCrash,
BackgroundTargetRestartEc84, BackgroundTargetRestartEc84,
BackgroundTargetCrashEc84, BackgroundTargetCrashEc84,
BackgroundTargetRestartEc84MultiSet,
BackgroundTargetCrashEc84MultiPool,
BackgroundCoordinatorRestart, BackgroundCoordinatorRestart,
TargetEndpointBlackhole, TargetEndpointBlackhole,
} }
@@ -1085,6 +1175,8 @@ mod tests {
InterruptionScenario::BackgroundTargetCrash => Some(BACKGROUND_TARGET_CRASH_EVIDENCE), InterruptionScenario::BackgroundTargetCrash => Some(BACKGROUND_TARGET_CRASH_EVIDENCE),
InterruptionScenario::BackgroundTargetRestartEc84 => Some(BACKGROUND_TARGET_RESTART_EC84_EVIDENCE), InterruptionScenario::BackgroundTargetRestartEc84 => Some(BACKGROUND_TARGET_RESTART_EC84_EVIDENCE),
InterruptionScenario::BackgroundTargetCrashEc84 => Some(BACKGROUND_TARGET_CRASH_EC84_EVIDENCE), InterruptionScenario::BackgroundTargetCrashEc84 => Some(BACKGROUND_TARGET_CRASH_EC84_EVIDENCE),
InterruptionScenario::BackgroundTargetRestartEc84MultiSet => Some(BACKGROUND_TARGET_RESTART_EC84_MULTI_SET_EVIDENCE),
InterruptionScenario::BackgroundTargetCrashEc84MultiPool => Some(BACKGROUND_TARGET_CRASH_EC84_MULTI_POOL_EVIDENCE),
_ => None, _ => None,
}; };
let evidence_run = match evidence_case { let evidence_run = match evidence_case {
@@ -1098,12 +1190,18 @@ mod tests {
InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"), InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"),
InterruptionScenario::BackgroundTargetRestartEc84 => (true, 1, "background_target_restart_ec8_4"), InterruptionScenario::BackgroundTargetRestartEc84 => (true, 1, "background_target_restart_ec8_4"),
InterruptionScenario::BackgroundTargetCrashEc84 => (true, 1, "background_target_crash_ec8_4"), InterruptionScenario::BackgroundTargetCrashEc84 => (true, 1, "background_target_crash_ec8_4"),
InterruptionScenario::BackgroundTargetRestartEc84MultiSet => (true, 1, "background_target_restart_ec8_4_multi_set"),
InterruptionScenario::BackgroundTargetCrashEc84MultiPool => (true, 1, "background_target_crash_ec8_4_multi_pool"),
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"), InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"), InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
}; };
let topology = evidence_case let topology = evidence_case
.map(|case| case.topology) .map(|case| case.topology)
.unwrap_or_else(|| EvidenceTopology::new(4, 1)); .unwrap_or_else(|| EvidenceTopology::new(4, 1));
let erasure_set_drive_count = evidence_case
.and_then(|case| case.erasure_set_drive_count)
.unwrap_or_else(|| topology.total_drives());
let outage_target_manifest_required = evidence_case.map(|case| case.outage_target_manifest_required).unwrap_or(true);
init_logging(); init_logging();
info!( info!(
event = "heal_interruption_started", event = "heal_interruption_started",
@@ -1122,8 +1220,10 @@ mod tests {
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", storage_class); cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", storage_class);
} }
if let Some(erasure_set_drive_count) = evidence_case.and_then(|case| case.erasure_set_drive_count) { if let Some(erasure_set_drive_count) = evidence_case.and_then(|case| case.erasure_set_drive_count) {
cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", erasure_set_drive_count); cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", erasure_set_drive_count.to_string());
} }
// Capture physical baselines after the PUT rename fanout has drained.
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
// Heal control uses the first lexicographically sorted grid host. // Heal control uses the first lexicographically sorted grid host.
// Keep that coordinator distinct from the remote target at index 1. // Keep that coordinator distinct from the remote target at index 1.
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url)); cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
@@ -1146,19 +1246,10 @@ mod tests {
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG") let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string()); .unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log); cluster.set_env("RUST_LOG", server_rust_log);
let log_dir = if let Some(directory) = std::env::var_os("RUSTFS_HEAL_CHAOS_LOG_DIR") { let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
PathBuf::from(directory)
} else if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
let cluster_name = Path::new(&cluster.temp_dir)
.file_name()
.ok_or("cluster directory has no name")?;
PathBuf::from(directory).join(cluster_name).join("heal")
} else {
PathBuf::from(&cluster.temp_dir).join("logs")
};
std::fs::create_dir_all(&log_dir)?; std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() { for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, log_dir.join(format!("node{node_index}.log")).to_string_lossy())?; cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
} }
cluster.start_with_binary(&server_binary).await?; cluster.start_with_binary(&server_binary).await?;
let clients = cluster.create_all_clients()?; let clients = cluster.create_all_clients()?;
@@ -1213,8 +1304,6 @@ mod tests {
attempt_count += 1; attempt_count += 1;
continue; continue;
} }
let shard_census =
wait_for_complete_physical_shard_on_disk(&replaced_disk, bucket, &key, None, Duration::from_secs(10)).await?;
assert!( assert!(
shard_census.is_complete(), shard_census.is_complete(),
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}" "node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
@@ -1273,6 +1362,9 @@ mod tests {
} }
for (drive_index, drive) in node.data_dirs.iter().enumerate() { for (drive_index, drive) in node.data_dirs.iter().enumerate() {
let census = census_object_version_on_disk(Path::new(drive), bucket, outage_key, None)?; let census = census_object_version_on_disk(Path::new(drive), bucket, outage_key, None)?;
if !census.has_xl_meta {
continue;
}
assert!( assert!(
census.is_complete(), census.is_complete(),
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}" "online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}"
@@ -1281,7 +1373,7 @@ mod tests {
format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}") format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}")
})?; })?;
assert!( assert!(
(1..=topology.total_drives()).contains(&erasure_index), (1..=erasure_set_drive_count).contains(&erasure_index),
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}" "online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}"
); );
assert!( assert!(
@@ -1290,19 +1382,26 @@ mod tests {
); );
} }
} }
assert_eq!( assert!(
outage_peer_erasure_indices.len(), !outage_peer_erasure_indices.is_empty() && outage_peer_erasure_indices.len() <= erasure_set_drive_count,
topology.total_drives().saturating_sub(cluster.nodes[1].data_dirs.len()), "outage-object must occupy one non-empty erasure set"
"every online drive must contribute one unique outage-object erasure index"
); );
let missing_outage_erasure_indices = (1..=topology.total_drives()) if outage_target_manifest_required {
let min_online_data_shards = erasure_set_drive_count.saturating_sub(4);
assert!(
outage_peer_erasure_indices.len() >= min_online_data_shards,
"online drives in the selected erasure set must retain at least the EC data quorum"
);
}
let missing_outage_erasure_indices = (1..=erasure_set_drive_count)
.filter(|index| !outage_peer_erasure_indices.contains(index)) .filter(|index| !outage_peer_erasure_indices.contains(index))
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
assert_eq!( if outage_target_manifest_required {
missing_outage_erasure_indices.len(), assert!(
cluster.nodes[1].data_dirs.len(), !missing_outage_erasure_indices.is_empty(),
"the stopped node must account for every missing outage-object erasure index" "the stopped target must account for at least one missing outage-object erasure index"
); );
}
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#; let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
if !background_enabled { if !background_enabled {
@@ -1401,9 +1500,6 @@ mod tests {
} }
sleep(Duration::from_millis(50)).await; sleep(Duration::from_millis(50)).await;
} }
// Task execution and its non-blocking log writer advance independently.
// Observe the exact start before taking the partial-rebuild snapshot.
wait_for_admin_cluster_start_log(&log_dir.join("node0.log"), client_token, partial_deadline).await?;
let (partial_count, partial_manifest) = loop { let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a // Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window. // full-corpus hash pass consume the interruption window.
@@ -1438,6 +1534,19 @@ mod tests {
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body) let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?; .map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?; let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"]; let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!( assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(), pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
@@ -1562,11 +1671,12 @@ mod tests {
"Restored target endpoint forwarding" "Restored target endpoint forwarding"
); );
} else { } else {
let graceful_restart = matches!( if matches!(
scenario, scenario,
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84 InterruptionScenario::BackgroundTargetRestart
); | InterruptionScenario::BackgroundTargetRestartEc84
if graceful_restart { | InterruptionScenario::BackgroundCoordinatorRestart
) {
cluster.stop_node_gracefully(interruption_node).await?; cluster.stop_node_gracefully(interruption_node).await?;
} else { } else {
cluster.stop_node(interruption_node)?; cluster.stop_node(interruption_node)?;
@@ -1588,7 +1698,12 @@ mod tests {
if background_enabled { if background_enabled {
let marker_exists = unclean_shutdown_marker.is_file(); let marker_exists = unclean_shutdown_marker.is_file();
unclean_shutdown_marker_observed = Some(marker_exists); unclean_shutdown_marker_observed = Some(marker_exists);
let expected_marker = !graceful_restart; let expected_marker = matches!(
scenario,
InterruptionScenario::BackgroundTargetCrash
| InterruptionScenario::BackgroundTargetCrashEc84
| InterruptionScenario::BackgroundTargetCrashEc84MultiPool
);
assert!( assert!(
marker_exists == expected_marker, marker_exists == expected_marker,
"background restart/crash lane observed unexpected unclean-shutdown marker state" "background restart/crash lane observed unexpected unclean-shutdown marker state"
@@ -1627,9 +1742,10 @@ mod tests {
.unwrap_or(180); .unwrap_or(180);
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs); let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
loop { loop {
if metadata_count(&replaced_disk, bucket, &expected_manifests) == expected_manifests.len() let baseline_recovered = metadata_count(&replaced_disk, bucket, &expected_manifests) == expected_manifests.len();
&& object_metadata_exists_on_disk(&replaced_disk, bucket, outage_key) let outage_recovered =
{ !outage_target_manifest_required || object_metadata_exists_on_disk(&replaced_disk, bucket, outage_key);
if baseline_recovered && outage_recovered {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?; let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?; let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata_matches = match &expected_pool_metadata { let pool_metadata_matches = match &expected_pool_metadata {
@@ -1639,24 +1755,15 @@ mod tests {
} }
None => true, None => true,
}; };
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches { if matching == expected_manifests.len()
&& (!outage_target_manifest_required || outage_census.is_complete())
&& pool_metadata_matches
{
break; break;
} }
} }
if Instant::now() >= heal_deadline { if Instant::now() >= heal_deadline {
let mut mismatched_manifests = Vec::new(); let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
for expected in &expected_manifests {
let actual = census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?;
if !actual.matches_manifest(&expected.shard_census) {
mismatched_manifests.push(serde_json::json!({
"key": expected.key,
"expected": expected.shard_census,
"actual": actual,
}));
}
}
let matching = expected_manifests.len() - mismatched_manifests.len();
let mismatched_manifests = serde_json::Value::Array(mismatched_manifests);
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?; let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata = let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?; census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
@@ -1679,7 +1786,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(), Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
}; };
return Err(format!( return Err(format!(
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, mismatched_manifests={mismatched_manifests}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}", "root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len() expected_manifests.len()
) )
.into()); .into());
@@ -1696,17 +1803,19 @@ mod tests {
); );
} }
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?; let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
assert!( if outage_target_manifest_required {
outage_census.is_complete(), assert!(
"outage object must have a complete target shard: {outage_census:?}" outage_census.is_complete(),
); "outage object must have a complete target shard: {outage_census:?}"
assert_eq!( );
outage_census assert_eq!(
.erasure_index outage_census
.filter(|index| missing_outage_erasure_indices.contains(index)), .erasure_index
outage_census.erasure_index, .filter(|index| missing_outage_erasure_indices.contains(index)),
"the outage object must be rebuilt into one of the stopped node's missing erasure slots" outage_census.erasure_index,
); "the outage object must be rebuilt into one of the stopped node's missing erasure slots"
);
}
if let Some(cycle_end) = scanner_cycle_floor { if let Some(cycle_end) = scanner_cycle_floor {
wait_for_scanner_cycle_after(&cluster, cycle_end).await?; wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
@@ -1772,49 +1881,60 @@ mod tests {
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?; let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let task_status: serde_json::Value = serde_json::from_str(&task_status_body) let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?; .map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
if interruption_node == 0 { if interruption_node == 0 {
// Admin tasks are process-local. Physical and queue convergence // Restart recovery must finish the original durable root request.
// above establish recovery; a lost task must not report success.
assert_eq!(
task_status["summary"].as_str(),
Some("notFound"),
"interrupted task status: {task_status}"
);
assert_eq!(
task_status["detail"].as_str(),
Some("heal task not found or expired"),
"interrupted admin task must be explicitly unavailable: {task_status}"
);
info!( info!(
event = "heal_interruption_recovered", event = "heal_interruption_recovered",
component = "e2e_test", component = "e2e_test",
subsystem = "heal", subsystem = "heal",
interruption_node, interruption_node,
interruption_kind, interruption_kind,
task_state = "not_found", task_state = "finished",
"Physical recovery completed after coordinator restart" "Original root heal completed after coordinator restart"
); );
return Ok(()); return Ok(());
} }
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
if let Some(evidence_context) = evidence_run { if let Some(evidence_context) = evidence_run {
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id(); let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
assert_ne!(target_pid, restarted_pid, "target must be a new process"); assert_ne!(target_pid, restarted_pid, "target must be a new process");
evidence_context.write( assert_eq!(
&server_binary, file_sha256(&server_binary)?,
RestartObservation { evidence_context.run.binary.sha256,
nodes: cluster.nodes.len(), "server build changed during restart"
drives_per_node: cluster.nodes[0].data_dirs.len(), );
pid_before: target_pid, let evidence = serde_json::json!({
pid_after: restarted_pid, "schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
unclean_shutdown_marker: unclean_shutdown_marker_observed.ok_or("missing shutdown marker observation")?, "run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
objects: evidence_objects, "test_build": compiled_test_identity(),
node_listings, "binary_sha256": evidence_context.run.binary.sha256,
}, "test_binary_sha256": evidence_context.run.test_binary.sha256,
)?; "topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
"erasure_set_drive_count": erasure_set_drive_count,
"sets": topology.set_count(evidence_context.case.erasure_set_drive_count),
"pools": topology.pool_count(),
"outage_target_manifest_required": outage_target_manifest_required,
"distributed_ec_invalidation": true,
"peer_count": cluster.nodes.len(),
"same_window_remote_proof": true,
"all_peers_bound_to_generation_window": true,
"pid_before": target_pid, "pid_after": restarted_pid,
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
"objects": evidence_objects, "node_listings": node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
} }
Ok(()) Ok(())
@@ -21,7 +21,7 @@
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks //! One S3 GET can select readers on multiple EC nodes, so the counter tracks
//! distributed reader selection rather than HTTP request count. //! distributed reader selection rather than HTTP request count.
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, signal_process}; use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
@@ -2207,33 +2207,6 @@ async fn four_node_manual_transition_job_status_survives_node_restart() -> TestR
Ok(()) Ok(())
} }
struct SuspendedTransitionTarget<'a> {
// Keep the owned child borrowed until it is resumed so its PID cannot be reused.
child: &'a std::process::Child,
suspended: bool,
}
impl<'a> SuspendedTransitionTarget<'a> {
fn suspend(child: &'a std::process::Child) -> TestResult<Self> {
signal_process(child.id(), "STOP")?;
Ok(Self { child, suspended: true })
}
fn resume(&mut self) -> TestResult {
signal_process(self.child.id(), "CONT")?;
self.suspended = false;
Ok(())
}
}
impl Drop for SuspendedTransitionTarget<'_> {
fn drop(&mut self) {
if self.suspended {
let _ = signal_process(self.child.id(), "CONT");
}
}
}
#[tokio::test] #[tokio::test]
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult { async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
init_logging(); init_logging();
@@ -2271,21 +2244,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
.send() .send()
.await?; .await?;
} }
// Lifecycle PUT starts its own backfill. Keep its first page on a separate put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
// node and stop it at queue backpressure before it reaches the tested prefix:
// one active worker, one queued item, then the first rejected item.
for index in 0u8..3 {
hot_client
.put_object()
.bucket(&bucket)
.key(format!("transition/automatic-admission/object-{index:02}.bin"))
.body(ByteStream::from(payload(KIB, index)))
.send()
.await?;
}
let mut suspended_cold = SuspendedTransitionTarget::suspend(cold.process.as_ref().ok_or("cold-tier process missing")?)?;
let lifecycle_client = hot.create_s3_client(2)?;
put_lifecycle_with_transition_retry(&lifecycle_client, &bucket, &tier_name).await?;
let (node0, node1) = tokio::join!( let (node0, node1) = tokio::join!(
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64), start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
@@ -2345,31 +2304,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
assert_eq!(status["job_id"].as_str(), Some(job_id)); assert_eq!(status["job_id"].as_str(), Some(job_id));
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint)); assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let status = read_manual_transition_job_status_endpoint(&hot, accepted.0, status_endpoint).await?;
assert_eq!(
status["status"].as_str(),
Some("running"),
"blocked cold tier must keep the admitted job running: {status}"
);
if status["report"]["skipped_queue_full"].as_u64().is_some_and(|count| count > 0) {
assert!(
status["report"]["enqueued"].as_u64().is_some_and(|count| count > 0),
"the job must own pending transitions while the cold tier is suspended: {status}"
);
break;
}
if Instant::now() >= deadline {
return Err(format!(
"manual transition job did not reach queue backpressure while the cold tier was suspended: {status}"
)
.into());
}
sleep(Duration::from_millis(50)).await;
}
suspended_cold.resume()?;
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?; let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
assert_eq!(terminal["job_id"].as_str(), Some(job_id)); assert_eq!(terminal["job_id"].as_str(), Some(job_id));
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
@@ -109,10 +109,10 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
.await; .await;
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes"); let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500)); assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(503));
assert_eq!( assert_eq!(
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code), unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InternalError") Some("ServiceUnavailable")
); );
let unavailable_absence = s3_client let unavailable_absence = s3_client
.get_object() .get_object()
+3 -3
View File
@@ -12,6 +12,9 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
#[cfg(test)]
mod storage_metric_ownership_test;
mod reliant; mod reliant;
mod storage_api; mod storage_api;
@@ -23,9 +26,6 @@ pub mod common;
#[cfg(test)] #[cfg(test)]
pub mod chaos; pub mod chaos;
#[cfg(test)]
mod scanner_heal_evidence;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8) // Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151). // and on-demand-migration source scenarios (backlog#2151).
#[cfg(test)] #[cfg(test)]
+6 -5
View File
@@ -15,6 +15,7 @@
//! Regression coverage for anonymous access on multipart control APIs. //! Regression coverage for anonymous access on multipart control APIs.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client}; use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use crate::kms::common::LocalKMSTestEnvironment;
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder}; use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError}; use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput; use aws_sdk_s3::operation::head_object::HeadObjectOutput;
@@ -1465,10 +1466,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging(); init_logging();
let mut env = RustFSTestEnvironment::new().await?; let mut kms_env = LocalKMSTestEnvironment::new().await?;
let master_key = local_sse_master_key_value(); let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())]) kms_env.wait_for_kms_ready().await?;
.await?; let env = &kms_env.base_env;
let bucket = "anon-post-default-sse-kms"; let bucket = "anon-post-default-sse-kms";
let object_key = "post-default-sse-kms-object.txt"; let object_key = "post-default-sse-kms-object.txt";
@@ -1484,7 +1485,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
.apply_server_side_encryption_by_default( .apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder() ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::AwsKms) .sse_algorithm(ServerSideEncryption::AwsKms)
.kms_master_key_id("test-key") .kms_master_key_id(default_key_id)
.build() .build()
.expect("default encryption rule should build"), .expect("default encryption rule should build"),
) )
@@ -17,6 +17,7 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError; use aws_sdk_s3::error::SdkError;
use bytes::Bytes; use bytes::Bytes;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Barrier; use tokio::sync::Barrier;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -25,6 +26,307 @@ const KEY: &str = "thumb/79/concurrent-overwrite.jpg";
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>; type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
async fn assert_degraded_cluster_publication_guard_errors_are_retryable() -> TestResult {
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
cluster.set_env("RUST_LOG", "warn");
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let clients: Vec<_> = cluster
.create_all_clients()?
.into_iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
for alive in (1..=4).rev() {
if alive < 4 {
cluster.stop_node(alive)?;
}
for (node, client) in clients.iter().take(alive).enumerate() {
let key = format!("publication-put-{alive}-{node}");
let put = client
.put_object()
.bucket(BUCKET)
.key(&key)
.body(Bytes::from_static(b"publication guard regression").into())
.send()
.await;
if alive >= 3 {
put?;
} else {
let err = put.expect_err("PUT must reject writes without a write quorum");
assert_eq!(
err.raw_response().map(|response| response.status().as_u16()),
Some(503),
"PUT with {alive} nodes alive, requested through node {node}: {err:?}"
);
assert_eq!(
err.as_service_error().and_then(|error| error.meta().code()),
Some("ServiceUnavailable"),
"PUT with {alive} nodes alive, requested through node {node}: {err:?}"
);
}
let multipart_key = format!("publication-multipart-{alive}-{node}");
let multipart = client
.create_multipart_upload()
.bucket(BUCKET)
.key(&multipart_key)
.send()
.await;
if alive >= 3 {
let upload = multipart?;
let upload_id = upload
.upload_id()
.expect("successful multipart initialization must return an upload ID");
client
.abort_multipart_upload()
.bucket(BUCKET)
.key(&multipart_key)
.upload_id(upload_id)
.send()
.await?;
} else {
let err = multipart.expect_err("multipart initialization must reject writes without a write quorum");
assert_eq!(
err.raw_response().map(|response| response.status().as_u16()),
Some(503),
"CreateMultipartUpload with {alive} nodes alive, requested through node {node}: {err:?}"
);
assert_eq!(
err.as_service_error().and_then(|error| error.meta().code()),
Some("ServiceUnavailable"),
"CreateMultipartUpload with {alive} nodes alive, requested through node {node}: {err:?}"
);
}
}
}
cluster.stop();
cluster.start().await?;
for client in &clients {
for alive in [1, 3, 4] {
for node in 0..alive {
let key = format!("publication-put-{alive}-{node}");
let get = client.get_object().bucket(BUCKET).key(key).send().await;
if alive >= 3 {
assert_eq!(
get?.body.collect().await?.into_bytes().as_ref(),
b"publication guard regression",
"acknowledged writes must survive restart"
);
} else {
let err = get.expect_err("a rejected publication guard must not publish an object");
assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchKey"));
}
}
}
let uploads = client.list_multipart_uploads().bucket(BUCKET).send().await?;
assert!(
uploads
.uploads()
.iter()
.all(|upload| upload.key() != Some("publication-multipart-1-0")),
"a rejected publication guard must not publish a multipart upload"
);
}
Ok(())
}
async fn assert_quorum_object_body(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
let body = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(body.as_ref(), expected, "quorum read returned incorrect contents for {key}");
Ok(())
}
async fn wait_for_quorum_read_admission(clients: &[Client], bucket: &str) -> TestResult {
// SIGKILL can orphan a granted lease. Wait for shared metadata-lock
// admission before asserting the stable quorum boundary; cold bodies
// remain unread throughout this readiness probe.
let deadline =
tokio::time::Instant::now() + rustfs_lock::fast_lock::DEFAULT_LOCK_TIMEOUT + std::time::Duration::from_secs(15);
loop {
let mut ready = true;
for client in clients {
for key in ["warm-small", "warm-large"] {
match client.head_object().bucket(bucket).key(key).send().await {
Ok(_) => {}
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => {
ready = false;
break;
}
Err(error) => return Err(error.into()),
}
}
if !ready {
break;
}
}
if ready {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("read quorum did not become available after lease convergence for {bucket}").into());
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
#[tokio::test]
async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResult {
crate::common::init_logging();
for (node_count, parity) in [(4, 2), (6, 3), (6, 2)] {
let read_quorum = node_count - parity;
let write_quorum = read_quorum + usize::from(read_quorum == parity);
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
// Wait for every seed fanout before removing any physical shard.
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
cluster.set_env("RUST_LOG", "warn,rustfs_lock=debug");
cluster.start().await?;
let clients = cluster
.create_all_clients()?
.into_iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect::<Vec<_>>();
let bucket = format!("read-quorum-{node_count}-{parity}");
clients[0].create_bucket().bucket(&bucket).send().await?;
let small = b"read quorum is derived from the erasure layout".to_vec();
let large = (0..1_048_576)
.map(|index| u8::try_from(index % 251).expect("bounded payload byte"))
.collect::<Vec<_>>();
for (key, body) in [
("warm-small", &small),
("warm-large", &large),
("cold-small", &small),
("cold-large", &large),
("below-quorum", &large),
] {
clients[node_count - 1]
.put_object()
.bucket(&bucket)
.key(key)
.body(Bytes::copy_from_slice(body).into())
.send()
.await?;
}
for node in &cluster.nodes {
for key in ["warm-small", "warm-large", "cold-small", "cold-large", "below-quorum"] {
let census =
crate::chaos::census_object_version_on_disk(std::path::Path::new(&node.data_dir), &bucket, key, None)?;
assert!(census.is_complete(), "seed shard must be complete before fault injection: {census:?}");
assert_eq!(census.data_blocks, Some(read_quorum));
assert_eq!(census.parity_blocks, Some(parity));
}
}
for client in &clients {
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
}
for offline_node in (read_quorum..node_count).rev() {
cluster.stop_node(offline_node)?;
wait_for_quorum_read_admission(&clients[..offline_node], &bucket).await?;
for client in clients.iter().take(offline_node) {
client.head_bucket().bucket(&bucket).send().await?;
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
}
}
// Exercise more than the five-second positive bucket-validation TTL.
// Every sample must succeed; polling must not hide a transient failure.
let validation_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(6);
loop {
for client in clients.iter().take(read_quorum) {
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
let listing = client.list_objects_v2().bucket(&bucket).send().await?;
for key in ["warm-small", "warm-large", "cold-small", "cold-large", "below-quorum"] {
assert!(listing.contents().iter().any(|entry| entry.key() == Some(key)), "listing omitted {key}");
}
}
if tokio::time::Instant::now() >= validation_deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
for client in clients.iter().take(read_quorum) {
assert_quorum_object_body(client, &bucket, "cold-small", &small).await?;
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
}
let write = clients[0]
.put_object()
.bucket(&bucket)
.key("quorum-write")
.body(Bytes::copy_from_slice(&small).into())
.send()
.await;
if read_quorum >= write_quorum {
write?;
} else {
let error = write.expect_err("a read quorum must not authorize a write that needs more votes");
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("ServiceUnavailable"));
}
cluster.stop_node(read_quorum - 1)?;
for client in clients.iter().take(read_quorum - 1) {
match client.get_object().bucket(&bucket).key("below-quorum").send().await {
Ok(response) => assert!(
response.body.collect().await.is_err(),
"fewer than {read_quorum} valid fragments must not reconstruct an uncached object"
),
Err(error) => assert_eq!(
error.as_service_error().and_then(|error| error.meta().code()),
Some("ServiceUnavailable"),
"a quorum loss must not be mistaken for a missing object"
),
}
}
for node in 0..read_quorum - 1 {
cluster.stop_node(node)?;
}
cluster.start().await?;
for client in &clients {
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
assert_quorum_object_body(client, &bucket, "below-quorum", &large).await?;
}
}
Ok(())
}
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> { async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
client client
.put_object() .put_object()
@@ -58,6 +360,9 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
// Keep the regression focused on false quorum-loss errors, not ordinary lock // Keep the regression focused on false quorum-loss errors, not ordinary lock
// wait exhaustion under a heavily contended same-key overwrite workload. // wait exhaustion under a heavily contended same-key overwrite workload.
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20"); cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
cluster.set_env("RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE", "false");
cluster.start().await?; cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?; cluster.create_test_bucket(BUCKET).await?;
@@ -116,6 +421,181 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
); );
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?; clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
assert_node_readiness_tracks_quorum(&mut cluster).await?;
Ok(())
}
async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
let clients: Vec<_> = cluster
.create_all_clients()?
.into_iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
let http = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(3))
.build()?;
let seed_key = "readiness-seed";
let seed_body = b"readiness quorum regression";
clients[0]
.put_object()
.bucket(BUCKET)
.key(seed_key)
.body(Bytes::from_static(seed_body).into())
.send()
.await?;
for (phase, survivors) in [4, 3, 2, 1, 4].into_iter().enumerate() {
if phase == 4 {
cluster.stop();
cluster.start().await?;
} else if survivors < 4 {
cluster.stop_node(survivors)?;
}
let write_ready = survivors >= 3;
let read_quorum = survivors >= 2;
let expected_status = if write_ready { 200 } else { 503 };
for (idx, client) in clients.iter().enumerate().take(survivors) {
let url = &cluster.nodes[idx].url;
let deadline = Instant::now() + Duration::from_secs(30);
// Poll health before issuing S3 I/O: idle remote disk handles must
// not remain evidence of quorum after their host becomes unreachable.
let payload = loop {
let response = http.get(format!("{url}/health/ready")).send().await?;
let status = response.status().as_u16();
let payload: serde_json::Value = response.json().await?;
if status == expected_status
&& payload["ready"] == write_ready
&& payload["details"]["storage"]["ready"] == write_ready
&& payload["details"]["storage"]["readQuorum"] == read_quorum
&& payload["details"]["storage"]["writeQuorum"] == write_ready
&& payload["details"]["poolMetadata"]["ready"] == true
&& payload["details"]["iam"]["ready"] == true
&& payload["details"]["lock"]["ready"] == write_ready
{
break payload;
}
assert!(Instant::now() < deadline, "node {idx}, survivors={survivors}: HTTP {status}, {payload}");
tokio::time::sleep(Duration::from_millis(200)).await;
};
assert_eq!(payload["details"]["storage"]["readinessScope"], "write_quorum_and_pool_metadata");
assert_eq!(payload["details"]["storage"]["source"], "local_runtime");
assert_eq!(
payload["details"]["storage"]["status"],
if write_ready { "connected" } else { "disconnected" }
);
if !write_ready {
assert!(
payload["degradedReasons"]
.as_array()
.expect("degraded reasons")
.iter()
.any(|reason| reason == "storage_and_lock_unavailable")
);
}
for path in ["/health/ready", "/minio/health/ready"] {
let head = http.head(format!("{url}{path}")).send().await?;
assert_eq!(head.status().as_u16(), expected_status, "HEAD {path}, survivors={survivors}");
assert!(head.bytes().await?.is_empty());
let response = http.get(format!("{url}{path}")).send().await?;
assert_eq!(response.status().as_u16(), expected_status);
let body: serde_json::Value = response.json().await?;
assert_eq!(body["details"]["storage"], payload["details"]["storage"]);
assert_eq!(body["details"]["poolMetadata"], payload["details"]["poolMetadata"]);
}
let live = http.get(format!("{url}/health/live")).send().await?;
assert_eq!(live.status().as_u16(), 200);
assert!(live.json::<serde_json::Value>().await?.get("details").is_none());
for (path, storage_ready, scope) in [
("/minio/health/cluster", write_ready, "write_quorum_and_pool_metadata"),
("/minio/health/cluster/read", read_quorum, "read_quorum"),
] {
let deadline = Instant::now() + Duration::from_secs(30);
// Cluster read/write reports have independent caches; allow
// each observation to expire before comparing stable states.
let body = loop {
let response = http.get(format!("{url}{path}")).send().await?;
let status = response.status().as_u16();
let body: serde_json::Value = response.json().await?;
if status == expected_status
&& body["details"]["storage"]["ready"] == storage_ready
&& body["details"]["lock"]["ready"] == write_ready
{
break body;
}
assert!(Instant::now() < deadline, "{path}, survivors={survivors}: HTTP {status}, {body}");
tokio::time::sleep(Duration::from_millis(200)).await;
};
assert_eq!(body["details"]["storage"]["readinessScope"], scope);
}
let put = client
.put_object()
.bucket(BUCKET)
.key(format!("readiness-phase-{phase}-node-{idx}"))
.body(Bytes::from_static(seed_body).into())
.send()
.await;
let put_status = if write_ready {
put.expect("a ready node must accept the PUT");
200
} else {
let error = put.expect_err("subquorum node must reject PUT");
assert!(
error.raw_response().is_some_and(|response| response.status().as_u16() >= 500),
"unexpected PUT failure: {error:?}"
);
error.raw_response().expect("PUT error response").status().as_u16()
};
let get = client.get_object().bucket(BUCKET).key(seed_key).send().await;
let get_status = match get {
Ok(object) => {
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), seed_body);
200
}
Err(error) => {
assert!(!write_ready, "GET must succeed on a ready cluster: {error:?}");
let status = error
.raw_response()
.expect("GET should have an HTTP response")
.status()
.as_u16();
assert!(status >= 500, "unexpected GET failure: {error:?}");
status
}
};
let list = client.list_objects_v2().bucket(BUCKET).send().await;
let list_status = match list {
Ok(result) => {
assert!(result.contents().iter().any(|object| object.key() == Some(seed_key)));
200
}
Err(error) => {
assert!(!write_ready, "listing must succeed on a ready cluster: {error:?}");
let status = error
.raw_response()
.expect("LIST should have an HTTP response")
.status()
.as_u16();
assert!(status >= 500, "unexpected listing failure: {error:?}");
status
}
};
eprintln!(
"readiness matrix: survivors={survivors}, node={idx}, ready={write_ready}, read_quorum={read_quorum}, PUT={put_status}, GET={get_status}, LIST={list_status}"
);
}
}
cluster.stop();
Ok(()) Ok(())
} }
@@ -125,6 +605,8 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
/// Before the fix, `map_namespace_lock_error` wrapped lock timeout/conflict errors as /// Before the fix, `map_namespace_lock_error` wrapped lock timeout/conflict errors as
/// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to /// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to
/// `S3ErrorCode::InternalError` (500) in the error mapping. /// `S3ErrorCode::InternalError` (500) in the error mapping.
/// Also checks PUT and multipart initialization when node failures prevent
/// acquiring a table publication guard.
#[tokio::test] #[tokio::test]
async fn test_concurrent_put_same_key_never_returns_500() -> TestResult { async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
crate::common::init_logging(); crate::common::init_logging();
@@ -227,5 +709,6 @@ async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
); );
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?; clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
Ok(()) cluster.stop();
assert_degraded_cluster_publication_guard_errors_are_retryable().await
} }
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
let miss = env.raw_get(bucket, miss_key).await?; let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body)); assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
} }
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?); let (listed, _, _) = tokio::try_join!(
env.wait_local_listed(bucket, hit_key, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
)?;
assert!(listed);
let status = env.status_json(bucket).await?; let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}"); assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
@@ -79,11 +79,6 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
.await .await
.map_err(|e| anyhow!("{}", e))?; .map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
// A signed S3 request establishes readiness before the first SFTP login.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?; let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
cmptst_01::run_medium_binary_round_trip(&sftp).await?; cmptst_01::run_medium_binary_round_trip(&sftp).await?;
@@ -106,6 +101,8 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
// reach the finalised object as x-amz-meta-* user metadata // reach the finalised object as x-amz-meta-* user metadata
// through the CreateMultipartUpload input field. The S3 client // through the CreateMultipartUpload input field. The S3 client
// connects to the same rustfs process this suite already drives. // connects to the same rustfs process this suite already drives.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?; cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
drop(sftp); drop(sftp);
+10 -7
View File
@@ -168,10 +168,6 @@ pub async fn test_sftp_core_operations() -> Result<()> {
.await .await
.map_err(|e| anyhow!("{}", e))?; .map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp().await?; let (session, sftp) = connect_sftp().await?;
// --- 1. Subsystem canary: SFTP session reachable after password auth --- // --- 1. Subsystem canary: SFTP session reachable after password auth ---
@@ -352,6 +348,16 @@ pub async fn test_sftp_core_operations() -> Result<()> {
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await; let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
info!("PASS: bad-password authentication rejected"); info!("PASS: bad-password authentication rejected");
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
// The rustfs binary spawned for this suite serves both SFTP on port
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
// initialising after TCP is listening, so list_buckets is polled
// until it succeeds before any cross-protocol assertion runs.
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
info!("PASS: S3 endpoint reachable from cross-protocol client");
// --- SFTP write, S3 read: SHA256 round-trip --- // --- SFTP write, S3 read: SHA256 round-trip ---
// SFTP creates the object, then assert_cross_protocol_sha_match // SFTP creates the object, then assert_cross_protocol_sha_match
// fetches it via both S3 GetObject and SFTP READ and compares // fetches it via both S3 GetObject and SFTP READ and compares
@@ -516,9 +522,6 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
.await .await
.map_err(|e| anyhow!("{}", e))?; .map_err(|e| anyhow!("{}", e))?;
let s3 = build_test_s3_client(&format!("http://{IDLE_S3_ADDRESS}"));
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?; let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
// Confirm the session is live before the wait so a failure in the // Confirm the session is live before the wait so a failure in the
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication> <ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{target_b_arn}</Bucket></Destination> <Destination><Bucket>{target_b_arn}</Bucket></Destination>
</Rule> </Rule>
<Rule>
<ID>matrix-and-tags</ID>
<Priority>135</Priority>
<Status>Enabled</Status>
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
</Rule>
<Rule> <Rule>
<ID>matrix-disabled</ID> <ID>matrix-disabled</ID>
<Priority>140</Priority> <Priority>140</Priority>
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
"matrix-prefix", "matrix-prefix",
"matrix-tag", "matrix-tag",
"matrix-disabled", "matrix-disabled",
"matrix-and-tags",
"matrix-priority-high", "matrix-priority-high",
"Priority>200", "Priority>200",
"<Status>Disabled</Status>", "<Status>Disabled</Status>",
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?; put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?; assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
// the required tags is not admitted. Matching any single tag would push
// data to a destination the rule never selected (backlog#2366 P1-1), and
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
// writes, so a single-tag rule passing is not evidence for this.
source_client
.put_object()
.bucket(source_bucket)
.key("and-tags/partial.txt")
.tagging("env=prod")
.body(ByteStream::from_static(b"one of two tags"))
.send()
.await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
source_client
.put_object()
.bucket(source_bucket)
.key("and-tags/full.txt")
.tagging("env=prod&tier=gold")
.body(ByteStream::from_static(b"both tags"))
.send()
.await?;
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
source_client source_client
.put_object() .put_object()
.bucket(source_bucket) .bucket(source_bucket)
@@ -1,181 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Build-bound evidence for scanner and heal restart tests.
use crate::common::ClusterTopology;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
pub(crate) struct ScannerHealEvidenceCase {
pub(crate) id: &'static str,
pub(crate) oracle: &'static str,
pub(crate) evidence: &'static str,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) topology: EvidenceTopology,
pub(crate) storage_class_standard: Option<&'static str>,
pub(crate) erasure_set_drive_count: Option<&'static str>,
}
#[derive(Clone, Copy)]
pub(crate) struct EvidenceTopology {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
}
impl EvidenceTopology {
pub(crate) const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self { nodes, drives_per_node }
}
pub(crate) fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
pub(crate) fn cluster_topology(self) -> ClusterTopology {
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
}
}
pub(crate) struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
pub(crate) fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
pub(crate) struct RestartObservation {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
pub(crate) pid_before: u32,
pub(crate) pid_after: u32,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) objects: Vec<serde_json::Value>,
pub(crate) node_listings: Vec<Vec<String>>,
}
impl RestartEvidenceContext {
pub(crate) fn write(self, binary: &Path, observed: RestartObservation) -> Result<(), Box<dyn Error + Send + Sync>> {
assert_ne!(observed.pid_before, observed.pid_after, "target must be a new process");
assert_eq!(file_sha256(binary)?, self.run.binary.sha256, "server build changed during restart");
let evidence = serde_json::json!({
"schema": 1, "case": self.case.id, "evidence": self.case.evidence,
"run_id": self.run.run_id, "source_revision": self.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": self.run.binary.sha256,
"test_binary_sha256": self.run.test_binary.sha256,
"topology": {"nodes": observed.nodes, "drives_per_node": observed.drives_per_node},
"pid_before": observed.pid_before, "pid_after": observed.pid_after,
"unclean_shutdown_marker": observed.unclean_shutdown_marker,
"objects": observed.objects, "node_listings": observed.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(self.directory.join(self.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
}
@@ -0,0 +1,335 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Native OTLP -> Collector -> Prometheus contract, including a rolling upgrade.
use crate::common::RustFSTestClusterEnvironment;
use aws_sdk_s3::primitives::ByteStream;
use serde_json::Value;
use std::fs::{self, File};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
struct ToolProcess(Child);
impl Drop for ToolProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn required_binary(name: &str) -> TestResult<PathBuf> {
let path = PathBuf::from(std::env::var(name).map_err(|_| format!("{name} must name a pinned executable"))?);
if !path.is_file() {
return Err(format!("{name} does not name a file: {}", path.display()).into());
}
Ok(path)
}
fn free_port() -> TestResult<u16> {
Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port())
}
fn start_tool(binary: &Path, args: &[String], log: &Path) -> TestResult<ToolProcess> {
let log = File::create(log)?;
Ok(ToolProcess(
Command::new(binary)
.args(args)
.env("NO_PROXY", "127.0.0.1,localhost")
.env_remove("HTTP_PROXY")
.env_remove("HTTPS_PROXY")
.stdout(Stdio::from(log.try_clone()?))
.stderr(Stdio::from(log))
.spawn()?,
))
}
async fn query(client: &reqwest::Client, base: &str, expression: &str) -> TestResult<Value> {
let mut url = reqwest::Url::parse(&format!("{base}/api/v1/query"))?;
url.query_pairs_mut().append_pair("query", expression);
let result: Value = client.get(url).send().await?.error_for_status()?.json().await?;
if result["status"] != "success" {
return Err(format!("PromQL failed: {result}").into());
}
Ok(result["data"]["result"].clone())
}
async fn await_count(client: &reqwest::Client, base: &str, selector: &str, expected: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(120);
loop {
let result = query(client, base, &format!("count({selector}) or vector(0)")).await;
if let Ok(rows) = &result
&& rows[0]["value"][1].as_str().and_then(|value| value.parse::<u64>().ok()) == Some(expected)
{
println!("PASS count={expected}: {selector}");
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!("expected {expected} for {selector}; last result: {result:?}").into());
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
async fn validate_dashboard_queries(client: &reqwest::Client, base: &str, observer: &str) -> TestResult {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.docker/observability/grafana/dashboards/rustfs.json");
let dashboard: Value = serde_json::from_str(&fs::read_to_string(path)?)?;
for name in ["storage_cluster", "storage_observer"] {
let variable = dashboard["templating"]["list"]
.as_array()
.ok_or("dashboard variables")?
.iter()
.find(|variable| variable["name"] == name)
.ok_or("storage selection variable")?;
assert_eq!(variable["multi"], false, "storage views must select one {name}");
assert_eq!(variable["includeAll"], false, "storage views must select one {name}");
}
let mut pending = dashboard["panels"]
.as_array()
.ok_or("dashboard panels")?
.iter()
.collect::<Vec<_>>();
let mut checked = 0;
while let Some(panel) = pending.pop() {
if let Some(children) = panel["panels"].as_array() {
pending.extend(children);
}
for target in panel["targets"].as_array().into_iter().flatten() {
let Some(expression) = target["expr"].as_str() else { continue };
if !expression.contains("rustfs:storage:current") {
continue;
}
if expression.contains("collection_scope=\"cluster\"") {
assert!(
expression.contains("observer=\"$storage_observer\""),
"global views must select one observer: {expression}"
);
}
let mut expression = expression.to_string();
for (name, value) in [
("$__rate_interval", "5m"),
("$storage_cluster", "metrics-e2e"),
("$storage_observer", observer),
("$drive_api", ".*"),
("$server", ".*"),
("$drive", ".*"),
("$job", "rustfs"),
] {
expression = expression.replace(name, value);
}
query(client, base, &expression).await?;
checked += 1;
}
}
assert!(checked > 0, "the storage dashboard queries must be exercised");
println!("PASS: {checked} storage dashboard queries against the live pipeline");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "external tools: pinned Collector, Prometheus, previous release and current RustFS binaries"]
async fn storage_metric_ownership_pipeline() -> TestResult {
let baseline = required_binary("RUSTFS_METRICS_BASELINE_BINARY")?;
let current = required_binary("CARGO_BIN_EXE_rustfs")?;
let collector = required_binary("RUSTFS_OTELCOL_BINARY")?;
let prometheus = required_binary("RUSTFS_PROMETHEUS_BINARY")?;
let temp = tempfile::Builder::new().prefix("rustfs-storage-metrics-").tempdir()?;
let work = if let Ok(path) = std::env::var("RUSTFS_METRICS_E2E_ARTIFACTS") {
let path = PathBuf::from(path);
fs::create_dir_all(&path)?;
tempfile::Builder::new().prefix("storage-run-").tempdir_in(path)?.keep()
} else {
temp.path().to_path_buf()
};
println!("Metrics pipeline logs: {}", work.display());
let otlp = free_port()?;
let scrape = free_port()?;
let prom = free_port()?;
let collector_config = work.join("collector.yaml");
fs::write(
&collector_config,
format!(
r#"receivers:
otlp:
protocols:
http:
endpoint: 127.0.0.1:{otlp}
exporters:
prometheus:
endpoint: 127.0.0.1:{scrape}
send_timestamps: true
metric_expiration: 5m
resource_to_telemetry_conversion:
enabled: true
service:
telemetry:
metrics:
level: none
logs:
level: warn
pipelines:
metrics:
receivers: [otlp]
exporters: [prometheus]
"#
),
)?;
let rules = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.docker/observability/prometheus-rules/rustfs-storage.yml")
.canonicalize()?;
let prom_config = work.join("prometheus.yaml");
fs::write(
&prom_config,
format!(
r#"global:
scrape_interval: 1s
evaluation_interval: 1s
rule_files:
- '{}'
scrape_configs:
- job_name: rustfs
static_configs:
- targets: ['127.0.0.1:{scrape}']
"#,
rules.display()
),
)?;
// Use the shipped expressions, with only the test evaluation interval shortened.
let test_rules = work.join("storage-rules.yaml");
fs::write(&test_rules, fs::read_to_string(&rules)?.replace("interval: 15s", "interval: 1s"))?;
fs::write(
&prom_config,
fs::read_to_string(&prom_config)?.replace(&rules.display().to_string(), &test_rules.display().to_string()),
)?;
let _collector = start_tool(
&collector,
&[format!("--config={}", collector_config.display())],
&work.join("collector.log"),
)?;
let _prometheus = start_tool(
&prometheus,
&[
format!("--config.file={}", prom_config.display()),
format!("--web.listen-address=127.0.0.1:{prom}"),
format!("--storage.tsdb.path={}", work.join("prometheus-data").display()),
],
&work.join("prometheus.log"),
)?;
let http = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()?;
let prom_url = format!("http://127.0.0.1:{prom}");
await_count(&http, &prom_url, "up{job=\"rustfs\"} == 1", 1).await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("NO_PROXY", "127.0.0.1,localhost");
cluster.set_env("RUSTFS_OBS_METRIC_ENDPOINT", format!("http://127.0.0.1:{otlp}/v1/metrics"));
cluster.set_env("OTEL_RESOURCE_ATTRIBUTES", "rustfs.cluster.id=metrics-e2e");
cluster.set_env("RUSTFS_OBS_METER_INTERVAL", "2");
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true");
cluster.set_env("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false");
cluster.set_env("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false");
cluster.set_env("RUSTFS_METRICS_NODE_INTERVAL", "2");
cluster.set_env("RUSTFS_METRICS_CLUSTER_INTERVAL", "5");
for index in 0..4 {
// Four localhost processes otherwise share the startup resource IP.
// Distinct test host IDs model the four hosts in a distributed deployment.
cluster.set_node_env(
index,
"OTEL_RESOURCE_ATTRIBUTES",
format!("rustfs.cluster.id=metrics-e2e,host.id=metrics-node-{index}"),
)?;
cluster.set_node_capture_log_path(index, work.join(format!("node-{index}.log")).display().to_string())?;
}
cluster.start_with_binary(&baseline).await?;
// Reproduce the original four observers x four global drives before fixing it.
await_count(&http, &prom_url, "rustfs_system_drive_total_bytes{collection_scope=\"\",drive!=\"\"}", 16).await?;
let local = "rustfs:storage:current{source_metric=\"rustfs_system_drive_total_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"metrics-e2e\"}";
for index in 0..4 {
cluster.stop_node_gracefully(index).await?;
cluster.start_node_from_binary(index, &current).await?;
await_count(&http, &prom_url, local, u64::try_from(index + 1)?).await?;
}
let rows = query(&http, &prom_url, local).await?;
for row in rows.as_array().ok_or("expected a metric vector")? {
assert_eq!(
row["metric"]["observer"], row["metric"]["server"],
"a node must only export its own detailed drives"
);
}
let observer = cluster.nodes[0].address.clone();
let inventory = format!(
"rustfs:storage:current{{source_metric=\"rustfs_cluster_drive_present\",collection_scope=\"cluster\",rustfs_cluster_id=\"metrics-e2e\",observer=\"{observer}\"}}"
);
await_count(&http, &prom_url, &inventory, 4).await?;
cluster.create_test_bucket("metrics-ownership").await?;
let client = cluster.create_s3_client(0)?;
for index in 0..8 {
client
.put_object()
.bucket("metrics-ownership")
.key(format!("object-{index}"))
.body(ByteStream::from(vec![7_u8; 4096]))
.send()
.await?;
}
await_count(
&http,
&prom_url,
"count by (server) (rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\"})",
4,
).await?;
let counters = query(
&http,
&prom_url,
"rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\"}",
)
.await?;
assert!(
!counters.as_array().ok_or("expected counters")?.is_empty(),
"exercise actual storage counters"
);
for row in counters.as_array().ok_or("expected counters")? {
assert_eq!(row["metric"]["observer"], row["metric"]["server"]);
assert!(
!row["metric"]["disk_id"].as_str().unwrap_or_default().is_empty(),
"counters must carry physical disk identity"
);
}
validate_dashboard_queries(&http, &prom_url, &observer).await?;
for index in (1..4).rev() {
cluster.stop_node(index)?;
// The Collector stays alive; cached samples must not keep stopped owners fresh.
await_count(&http, &prom_url, local, u64::try_from(index)?).await?;
await_count(&http, &prom_url, &inventory, 4).await?;
let unavailable = format!(
"rustfs:storage:current{{source_metric=\"rustfs_cluster_drive_runtime_state\",collection_scope=\"cluster\",rustfs_cluster_id=\"metrics-e2e\",observer=\"{observer}\",state=~\"offline|unknown|suspect\"}} == 1"
);
await_count(&http, &prom_url, &unavailable, u64::try_from(4 - index)?).await?;
}
cluster.stop();
await_count(&http, &prom_url, local, 0).await?;
cluster.start_with_binary(&current).await?;
await_count(&http, &prom_url, local, 4).await?;
await_count(&http, &prom_url, &inventory, 4).await?;
let restored = client.get_object().bucket("metrics-ownership").key("object-0").send().await?;
assert_eq!(restored.body.collect().await?.into_bytes().as_ref(), vec![7_u8; 4096].as_slice());
println!("PASS: baseline duplication; four rolling upgrades; owner identity; counters; 4 -> 3 -> 2 -> 1 -> 0 -> 4 recovery");
Ok(())
}
@@ -32,8 +32,10 @@ use aws_sdk_s3::types::{
VersioningConfiguration, VersioningConfiguration,
}; };
use http::{Method, StatusCode}; use http::{Method, StatusCode};
use serde_json::Value;
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio::time::{Instant, sleep}; use tokio::time::{Instant, sleep};
@@ -41,6 +43,7 @@ type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
type BoxError = Box<dyn std::error::Error + Send + Sync>; type BoxError = Box<dyn std::error::Error + Send + Sync>;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY"; const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5"; const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY"; const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI="; const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
@@ -81,6 +84,14 @@ const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
// of treating it as an upgrade failure. // of treating it as an upgrade failure.
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90); const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
struct G09EvidenceContext {
directory: PathBuf,
current_revision: String,
previous_revision: String,
run_id: String,
measurement_window_id: String,
}
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> { fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let path = std::env::var_os(SOURCE_BINARY_ENV) let path = std::env::var_os(SOURCE_BINARY_ENV)
.map(PathBuf::from) .map(PathBuf::from)
@@ -91,6 +102,155 @@ fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
Ok(path) Ok(path)
} }
fn is_lower_hex_revision(value: &str) -> bool {
value.len() == 40
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
fn current_source_revision() -> Result<String, BoxError> {
let revision = env!("RUSTFS_E2E_BUILD_COMMIT");
if !is_lower_hex_revision(revision) {
return Err(format!("current test binary has invalid source revision {revision}").into());
}
Ok(revision.to_string())
}
async fn binary_source_revision(binary: &Path) -> Result<String, BoxError> {
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
if !output.status.success() {
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
}
let version = String::from_utf8(output.stdout)?;
source_revision_from_version_output(&version, binary)
}
fn source_revision_from_version_output(version: &str, binary: &Path) -> Result<String, BoxError> {
version
.split(|ch: char| !ch.is_ascii_hexdigit())
.find(|token| is_lower_hex_revision(token))
.map(str::to_string)
.ok_or_else(|| format!("{} --version did not expose a 40-byte source revision", binary.display()).into())
}
async fn g09_evidence_context(previous_binary: &Path) -> Result<Option<G09EvidenceContext>, BoxError> {
let Some(directory) = std::env::var_os(G09_EVIDENCE_DIR_ENV) else {
return Ok(None);
};
let directory = PathBuf::from(directory);
std::fs::create_dir_all(&directory)?;
let current_revision = current_source_revision()?;
let previous_revision = binary_source_revision(previous_binary).await?;
if current_revision == previous_revision {
return Err("G09 mixed-version evidence requires distinct current and previous source revisions".into());
}
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(Some(G09EvidenceContext {
directory,
current_revision,
previous_revision,
run_id: format!("g09-upgrade-{}-{now}", std::process::id()),
measurement_window_id: format!("g09-mixed-version-window-{now}"),
}))
}
fn write_g09_evidence(
context: &G09EvidenceContext,
field: &str,
role: &str,
cases: &[&str],
test: &str,
details: Value,
) -> TestResult {
let path = context.directory.join(format!("G09-{field}.json"));
let mut evidence = serde_json::json!({
"schema": 1,
"evidence_type": "measured",
"artifact_kind": "upgrade-compatibility-e2e",
"source_revision": context.current_revision,
"run_id": context.run_id,
"measurement_window_id": context.measurement_window_id,
"gate": "G09",
"field": field,
"versions": [context.previous_revision, context.current_revision],
"current_revision": context.current_revision,
"previous_revision": context.previous_revision,
"mixed_version_role": role,
"mixed_version_cases": cases,
"test": test,
"details": details,
});
if field == "rollback_payload_evidence" {
evidence["rollback_payload_replayed"] = Value::Bool(true);
}
let data = serde_json::to_vec_pretty(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("G09 mixed-version evidence exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
output.write_all(&data)?;
output.write_all(b"\n")?;
output.sync_all()?;
Ok(())
}
#[cfg(test)]
mod g09_evidence_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn source_revision_parser_requires_lowercase_forty_byte_sha() -> TestResult {
let binary = Path::new("rustfs");
assert_eq!(
source_revision_from_version_output("rustfs 1.0.0 abcdef0123456789abcdef0123456789abcdef01 clean", binary)?,
"abcdef0123456789abcdef0123456789abcdef01"
);
assert!(source_revision_from_version_output("rustfs ABCDEF0123456789ABCDEF0123456789ABCDEF01", binary).is_err());
assert!(source_revision_from_version_output("rustfs abcdef", binary).is_err());
Ok(())
}
#[test]
fn g09_evidence_writer_records_measured_role_and_refuses_overwrite() -> TestResult {
let directory = TempDir::new()?;
let context = G09EvidenceContext {
directory: directory.path().to_path_buf(),
current_revision: "b".repeat(40),
previous_revision: "a".repeat(40),
run_id: "g09-upgrade-test-run".to_string(),
measurement_window_id: "g09-upgrade-test-window".to_string(),
};
write_g09_evidence(
&context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"unit",
serde_json::json!({"assertions": ["reader evidence"]}),
)?;
let path = directory.path().join("G09-mixed_version_reader_evidence.json");
let evidence: Value = serde_json::from_slice(&std::fs::read(path)?)?;
assert_eq!(evidence["evidence_type"], "measured");
assert_eq!(evidence["gate"], "G09");
assert_eq!(evidence["field"], "mixed_version_reader_evidence");
assert_eq!(evidence["mixed_version_role"], "mixed-version-reader");
assert_eq!(evidence["versions"], serde_json::json!(["a".repeat(40), "b".repeat(40)]));
let overwrite = write_g09_evidence(
&context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"unit",
serde_json::json!({}),
);
assert!(overwrite.is_err(), "G09 evidence must not overwrite an existing artifact");
Ok(())
}
}
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult { async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
let configuration = VersioningConfiguration::builder() let configuration = VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled) .status(BucketVersioningStatus::Enabled)
@@ -523,6 +683,7 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult { async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
init_logging(); init_logging();
let previous_binary = source_binary()?; let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let current_binary = rustfs_binary_path(); let current_binary = rustfs_binary_path();
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?; let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn"); cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
@@ -555,6 +716,45 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
} }
} }
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current node reads objects written through previous-release client",
"previous-release node reads objects written through current client",
"all nodes list every mixed-version object after homogeneous-current convergence"
],
}),
)?;
write_g09_evidence(
context,
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"multipart writers continue under one-current-node and one-previous-node layouts"
],
}),
)?;
}
Ok(()) Ok(())
} }
@@ -1136,6 +1336,7 @@ async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult { async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
init_logging(); init_logging();
let previous_binary = source_binary()?; let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let replication_target = FakeS3Target::start().await?; let replication_target = FakeS3Target::start().await?;
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET); replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
@@ -1204,6 +1405,54 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
); );
assert_eq!(body, post_rollback_bytes); assert_eq!(body, post_rollback_bytes);
env.restart_server_preserving_data(vec![], &server_env).await?;
let current_again = env.create_s3_client();
assert_versioning_enabled(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_default_sse_s3_encryption(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_bucket_tag(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after rolling forward again").await?;
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, single_key, Some(&single_version))
.await?
.1,
single_bytes
);
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, multipart_key, None).await?.1,
multipart_bytes
);
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, post_rollback_key, None).await?.1,
post_rollback_bytes
);
if let Some(context) = evidence_context.as_ref() {
write_g09_evidence(
context,
"rollback_payload_evidence",
"rollback-payload",
&["rollback-to-old", "rollback-to-new", "unknown-field-retained"],
"upgrade_compatibility_test::rollback_to_previous_release_reads_current_bucket_metadata",
serde_json::json!({
"bucket": ROLLBACK_BUCKET,
"cases": {
"rollback-to-old": [
"previous-release binary reads current-build versioning, SSE-S3, tags, replication target and objects",
"previous-release writer honors the decoded current-build encryption configuration"
],
"rollback-to-new": [
"current build reads the object written by the rolled-back previous release",
"current build reads the current-build single-part and multipart objects after rolling forward again"
],
"unknown-field-retained": [
"previous release skips current-build bucket metadata extension fields without dropping known bucket configuration",
"current build reads the retained bucket configuration after the previous-release round trip"
]
},
}),
)?;
}
replication_target.shutdown().await; replication_target.shutdown().await;
Ok(()) Ok(())
} }
+27 -8
View File
@@ -38,6 +38,16 @@ pub mod bucket {
} }
pub mod lifecycle { pub mod lifecycle {
pub mod legacy_transition_state_reconcile {
pub use crate::bucket::lifecycle::legacy_transition_state_reconcile::{
LegacyTransitionStateCopyRepresentation, LegacyTransitionStateMetadataAlias, LegacyTransitionStateReconcileError,
LegacyTransitionStateReconcileOutcome, LegacyTransitionStateReconcileReadiness,
LegacyTransitionStateReconcileRequest, LegacyTransitionStateReconcileResponse,
LegacyTransitionStateReconcileSelector, LegacyTransitionStateSetRepresentation, LegacyTransitionStateSource,
LegacyTransitionStateTarget,
};
}
pub mod bucket_lifecycle_audit { pub mod bucket_lifecycle_audit {
pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc; pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
} }
@@ -260,18 +270,19 @@ pub mod bucket {
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO, ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge, ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError, ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus, ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, ScannerDirtyUsageMutationObserver,
VersionPurgeStatusType, XferStats, assign_site_replication_rule_priorities, commit_force_delete_intent, ScannerDirtyUsageMutationSource, TargetReplicationResyncStatus, VersionPurgeStatusType, XferStats,
complete_force_delete_intent, delete_replication_state_from_config, delete_replication_version_id, assign_site_replication_rule_priorities, commit_force_delete_intent, complete_force_delete_intent,
get_global_replication_pool, get_global_replication_stats, get_proxy_targets, init_background_replication, delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
get_global_replication_stats, get_proxy_targets, init_background_replication,
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule, invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
merge_incoming_replication_config, merge_user_replication_config, persist_force_delete_intent, merge_incoming_replication_config, merge_user_replication_config, persist_force_delete_intent,
read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map, read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
replication_target_arn_deployment_id, replication_target_arns, resync_start_conflict_id, replication_target_arn_deployment_id, replication_target_arns, resync_start_conflict_id,
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info, set_scanner_dirty_usage_mutation_observer, should_remove_replication_target, should_schedule_delete_replication,
should_use_existing_delete_replication_source, site_replication_rule_deployment_id, should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns, site_replication_rule_deployment_id, unsupported_replication_config_field, validate_replication_config_structure,
version_purge_status_to_filemeta, validate_replication_config_target_arns, version_purge_status_to_filemeta,
}; };
} }
@@ -366,6 +377,14 @@ pub mod config {
} }
pub mod data_usage { pub mod data_usage {
#[cfg(feature = "test-util")]
pub use crate::data_movement::SourceCleanupDeleteBarrier;
#[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,
};
pub use crate::data_usage::{ pub use crate::data_usage::{
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage, DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache, init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
@@ -966,6 +966,11 @@ async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel:
if let Some(err) = first_error { if let Some(err) = first_error {
return Err(err); return Err(err);
} }
runtime_sources::notify_scanner_dirty_usage_mutation(
&oi.bucket,
&oi.name,
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
);
Ok(true) Ok(true)
} }
@@ -4749,6 +4754,11 @@ async fn expire_transitioned_object_with_lock_lost_signal(
// Drop any cached restored-copy body so it does not sit resident // Drop any cached restored-copy body so it does not sit resident
// until TTL after the copy is expired (ODC-26). // until TTL after the copy is expired (ODC-26).
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await; crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
runtime_sources::notify_scanner_dirty_usage_mutation(
&oi.bucket,
&oi.name,
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
);
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn); //audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
Ok(dobj) Ok(dobj)
} }
@@ -4784,6 +4794,11 @@ async fn expire_transitioned_object_with_lock_lost_signal(
// The transitioned version is gone; evict any cached body for this object // The transitioned version is gone; evict any cached body for this object
// so it does not linger until TTL (ODC-26). // so it does not linger until TTL (ODC-26).
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await; crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
runtime_sources::notify_scanner_dirty_usage_mutation(
&oi.bucket,
&oi.name,
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
);
//audit_log_lifecycle(oi, ILMExpiry, tags); //audit_log_lifecycle(oi, ILMExpiry, tags);
@@ -5165,6 +5180,9 @@ pub async fn put_restore_opts(
user_defined: meta, user_defined: meta,
version_id: oi.version_id.map(|e| e.to_string()), version_id: oi.version_id.map(|e| e.to_string()),
mod_time: oi.mod_time, mod_time: oi.mod_time,
// Restore writes stored (possibly encrypted) bytes, so the writer's
// computed MD5 is not the object's public plaintext ETag.
preserve_etag: oi.etag.clone(),
//expires: oi.expires, //expires: oi.expires,
..Default::default() ..Default::default()
}) })
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ mod config_boundary;
pub mod core; pub mod core;
mod durable_namespace; mod durable_namespace;
pub mod evaluator; pub mod evaluator;
pub mod legacy_transition_state_reconcile;
pub mod manual_transition_job; pub mod manual_transition_job;
mod metadata_boundary; mod metadata_boundary;
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config}; pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config};
@@ -20,6 +20,7 @@ use tokio_util::sync::CancellationToken;
use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionState}; use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionState};
use crate::runtime::sources; use crate::runtime::sources;
pub(crate) use crate::runtime::sources::ScannerDirtyUsageMutationSource;
use crate::services::tier::tier::TierConfigMgr; use crate::services::tier::tier::TierConfigMgr;
use crate::store::ECStore; use crate::store::ECStore;
@@ -54,3 +55,7 @@ pub(crate) fn deployment_id() -> Option<String> {
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> { pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
sources::bucket_lifecycle_config(bucket).await sources::bucket_lifecycle_config(bucket).await
} }
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
sources::notify_scanner_dirty_usage_mutation(bucket, object, source);
}
+7 -3
View File
@@ -1681,9 +1681,13 @@ impl BucketMetadataSys {
expected: Option<&Arc<BucketMetadata>>, expected: Option<&Arc<BucketMetadata>>,
namespace_guard: &rustfs_lock::NamespaceLockGuard, namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> { ) -> Result<()> {
if !self if !await_bucket_namespace_operation(
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check") Some(namespace_guard),
.await? bucket,
"bucket metadata heal existence check",
self.api.bucket_exists_for_heal(bucket),
)
.await?
{ {
if matches!(mode, MetadataLoadMode::Refresh) { if matches!(mode, MetadataLoadMode::Refresh) {
let _publish_guard = self let _publish_guard = self
@@ -92,3 +92,6 @@ pub use replication_target_boundary::SsecPassthroughCapability;
pub use replication_target_boundary::VersionIdentityCapability; pub use replication_target_boundary::VersionIdentityCapability;
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity}; pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge; pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
pub use runtime_boundary::{
ScannerDirtyUsageMutationObserver, ScannerDirtyUsageMutationSource, set_scanner_dirty_usage_mutation_observer,
};
@@ -3843,6 +3843,13 @@ async fn persist_replication_state_if_current<S: ReplicationStorage>(
match storage.put_object_metadata(&roi.bucket, &roi.name, &write_opts).await { match storage.put_object_metadata(&roi.bucket, &roi.name, &write_opts).await {
Ok(updated) => { Ok(updated) => {
*object_info = updated; *object_info = updated;
if mode == ReplicationStatusWritebackMode::Update {
runtime_sources::notify_scanner_dirty_usage_mutation(
&roi.bucket,
&roi.name,
runtime_sources::ScannerDirtyUsageMutationSource::Replication,
);
}
Ok(ReplicationStatePersistOutcome::Updated) Ok(ReplicationStatePersistOutcome::Updated)
} }
Err(Error::PreconditionFailed) => Ok(ReplicationStatePersistOutcome::Superseded), Err(Error::PreconditionFailed) => Ok(ReplicationStatePersistOutcome::Superseded),
@@ -19,6 +19,9 @@ use super::replication_pool::DynReplicationPool;
use super::replication_state::ReplicationStats; use super::replication_state::ReplicationStats;
use super::replication_storage_boundary::ReplicationObjectStore; use super::replication_storage_boundary::ReplicationObjectStore;
use crate::runtime::sources; use crate::runtime::sources;
pub use crate::runtime::sources::{
ScannerDirtyUsageMutationObserver, ScannerDirtyUsageMutationSource, set_scanner_dirty_usage_mutation_observer,
};
pub(crate) fn object_store_handle() -> Option<Arc<ReplicationObjectStore>> { pub(crate) fn object_store_handle() -> Option<Arc<ReplicationObjectStore>> {
sources::object_store_handle() sources::object_store_handle()
@@ -43,3 +46,7 @@ pub(crate) fn replication_runtime_initialized() -> bool {
pub(crate) fn bucket_monitor() -> Option<Arc<ReplicationBucketMonitor>> { pub(crate) fn bucket_monitor() -> Option<Arc<ReplicationBucketMonitor>> {
sources::bucket_monitor() sources::bucket_monitor()
} }
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
sources::notify_scanner_dirty_usage_mutation(bucket, object, source);
}
@@ -51,10 +51,10 @@ use rustfs_protos::proto_gen::node_service::{
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest, LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse, ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse,
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse,
ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest, ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse, ScannerScopedDirtyUsageEntry, ServerInfoRequest,
SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest,
TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse, TierMutationFailureClass, TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse,
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
}; };
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS}; pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
@@ -289,6 +289,20 @@ fn scanner_scoped_dirty_usage_ack_payload(
Ok(payload) Ok(payload)
} }
fn scanner_scoped_dirty_usage_ack_response_matches(
request: &ScannerScopedDirtyUsageAckRequest,
response: &ScannerScopedDirtyUsageAckResponse,
) -> bool {
let cleared_within_request = u64::try_from(request.entries.len())
.is_ok_and(|entry_count| response.cleared <= entry_count && (!request.probe_only || response.cleared == 0));
response.protocol_version == rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_PROTOCOL_VERSION
&& response.owner_id == request.owner_id
&& response.instance_id == request.instance_id
&& response.max_entries == rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_ENTRIES
&& response.max_request_bytes == rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
&& cleared_within_request
}
fn scanner_scoped_dirty_usage_ack_reconciled(activity: &ScannerPeerActivity, expected_instance_id: &str) -> bool { fn scanner_scoped_dirty_usage_ack_reconciled(activity: &ScannerPeerActivity, expected_instance_id: &str) -> bool {
activity.instance_id == expected_instance_id && activity.dirty_usage_pending == Some(false) activity.instance_id == expected_instance_id && activity.dirty_usage_pending == Some(false)
} }
@@ -2255,13 +2269,7 @@ impl PeerRestClient {
let body = canonical_scoped_dirty_usage_response(&canonical, &response) let body = canonical_scoped_dirty_usage_response(&canonical, &response)
.map_err(|_| Error::other("scoped dirty usage capability response is too large"))?; .map_err(|_| Error::other("scoped dirty usage capability response is too large"))?;
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?; verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
if response.protocol_version != SCOPED_DIRTY_USAGE_PROTOCOL_VERSION if !scanner_scoped_dirty_usage_ack_response_matches(&payload, &response) {
|| response.owner_id != payload.owner_id
|| response.instance_id != payload.instance_id
|| response.max_entries != SCOPED_DIRTY_USAGE_MAX_ENTRIES
|| response.max_request_bytes != SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
|| response.cleared != 0
{
return Err(Error::other("scoped dirty usage capability response does not match request")); return Err(Error::other("scoped dirty usage capability response does not match request"));
} }
if !response.supported { if !response.supported {
@@ -2297,13 +2305,7 @@ impl PeerRestClient {
let body = canonical_scoped_dirty_usage_response(&canonical, &response) let body = canonical_scoped_dirty_usage_response(&canonical, &response)
.map_err(|_| Error::other("scoped dirty usage acknowledgement response is too large"))?; .map_err(|_| Error::other("scoped dirty usage acknowledgement response is too large"))?;
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?; verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
if response.protocol_version != SCOPED_DIRTY_USAGE_PROTOCOL_VERSION if !scanner_scoped_dirty_usage_ack_response_matches(&payload, &response) || !response.supported {
|| response.owner_id != payload.owner_id
|| response.instance_id != payload.instance_id
|| response.max_entries != SCOPED_DIRTY_USAGE_MAX_ENTRIES
|| response.max_request_bytes != SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
|| !response.supported
{
return Err(Error::other("scoped dirty usage acknowledgement response does not match request")); return Err(Error::other("scoped dirty usage acknowledgement response does not match request"));
} }
} }
@@ -3118,6 +3120,60 @@ mod tests {
} }
} }
#[test]
fn scanner_scoped_dirty_usage_ack_response_bounds_cleared_entries_to_request() {
use rustfs_protos::scoped_dirty_usage::{
SCOPED_DIRTY_USAGE_MAX_ENTRIES, SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES, SCOPED_DIRTY_USAGE_PROTOCOL_VERSION,
};
let mut request = scanner_scoped_dirty_usage_ack_payload(
"33333333-3333-3333-3333-333333333333",
"0123456789abcdef0123456789abcdef",
false,
vec![
ScannerScopedDirtyUsageEntry {
bucket: "archive".to_string(),
bucket_incarnation: Uuid::from_u128(0x11111111111111111111111111111111).as_bytes().to_vec().into(),
generation: 3,
},
ScannerScopedDirtyUsageEntry {
bucket: "photos".to_string(),
bucket_incarnation: Uuid::from_u128(0x22222222222222222222222222222222).as_bytes().to_vec().into(),
generation: 7,
},
],
)
.expect("two ordered entries should form a valid scoped ACK request");
let mut response = ScannerScopedDirtyUsageAckResponse {
protocol_version: SCOPED_DIRTY_USAGE_PROTOCOL_VERSION,
owner_id: request.owner_id.clone(),
instance_id: request.instance_id.clone(),
supported: true,
max_entries: SCOPED_DIRTY_USAGE_MAX_ENTRIES,
max_request_bytes: SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES,
cleared: 1,
response_proof: Bytes::new(),
};
assert!(scanner_scoped_dirty_usage_ack_response_matches(&request, &response));
response.cleared = 2;
assert!(scanner_scoped_dirty_usage_ack_response_matches(&request, &response));
response.cleared = 3;
assert!(
!scanner_scoped_dirty_usage_ack_response_matches(&request, &response),
"a peer cannot clear more entries than the signed request contains"
);
request.probe_only = true;
response.cleared = 1;
assert!(
!scanner_scoped_dirty_usage_ack_response_matches(&request, &response),
"a capability probe cannot report a mutation"
);
response.cleared = 0;
assert!(scanner_scoped_dirty_usage_ack_response_matches(&request, &response));
}
#[test] #[test]
fn scanner_scoped_dirty_usage_ack_reconciliation_requires_same_clean_instance() { fn scanner_scoped_dirty_usage_ack_reconciliation_requires_same_clean_instance() {
let activity = |instance_id: &str, pending| ScannerPeerActivity { let activity = |instance_id: &str, pending| ScannerPeerActivity {
File diff suppressed because it is too large Load Diff
+158 -16
View File
@@ -5256,16 +5256,35 @@ mod decommission_lock_order_tests {
#[test] #[test]
#[serial_test::serial] #[serial_test::serial]
fn scanner_backlog_native_replica_reconciles_capacity_and_cleans_source() { fn data_movement_existing_replica_reconciles_capacity_and_cleans_source() {
run_large_stack_current_thread_async_test("scanner-backlog-reconcile", async || { data_movement_existing_replica_reconciles_capacity_case(false);
}
#[test]
#[serial_test::serial]
fn data_movement_existing_replica_outside_reservation_uses_reserved_target() {
data_movement_existing_replica_reconciles_capacity_case(true);
}
fn data_movement_existing_replica_reconciles_capacity_case(existing_outside_reservation: bool) {
run_large_stack_current_thread_async_test("reserved-replica-reconcile", async move || {
let (_temp_dirs, store, other_store) = let (_temp_dirs, store, other_store) =
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await; test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
let object = "buckets/.scanner-pause-backlog.json"; let object = "buckets/reserved-replica-routing.json";
let body = br#"{"schemaVersion":1,"generation":2}"#.to_vec(); let body = br#"{"schemaVersion":1,"generation":2}"#.to_vec();
let old_body = br#"{"schemaVersion":1,"generation":1}"#.to_vec(); let old_body = br#"{"schemaVersion":1,"generation":1}"#.to_vec();
let source_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20); let source_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20);
let target_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10); let target_time = source_time;
for (pool_index, payload, mod_time) in [(0, body.clone(), source_time), (2, old_body, target_time)] { let target_pool_index = if existing_outside_reservation { 1 } else { 2 };
let mut replicas = vec![(0, body.clone(), source_time), (target_pool_index, old_body, target_time)];
if existing_outside_reservation {
replicas.push((
2,
br#"{"schemaVersion":1,"generation":3}"#.to_vec(),
source_time + time::Duration::seconds(10),
));
}
for (pool_index, payload, mod_time) in replicas.iter().cloned() {
store.pools[pool_index] store.pools[pool_index]
.put_object( .put_object(
RUSTFS_META_BUCKET, RUSTFS_META_BUCKET,
@@ -5278,13 +5297,19 @@ mod decommission_lock_order_tests {
}, },
) )
.await .await
.expect("seed native scanner replicas with independent write times"); .expect("seed existing replicas with independent write times");
} }
let layout = DecommissionErasureLayout { data: 1, parity: 0 }; let layout = DecommissionErasureLayout { data: 1, parity: 0 };
let target_total = body.len() * 8; let target_total = body.len() * 8;
let capacities = vec![ let capacities = vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2), DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total), DecommissionPoolCapacityInfo::for_test(
1,
layout,
if existing_outside_reservation { target_total } else { 0 },
target_total,
if existing_outside_reservation { 0 } else { target_total },
),
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0), DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
]; ];
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]); set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
@@ -5293,6 +5318,17 @@ mod decommission_lock_order_tests {
.await .await
.expect("activate the source reservation"); .expect("activate the source reservation");
let owner = decommission_capacity_owner(&*store.pool_meta.read().await); let owner = decommission_capacity_owner(&*store.pool_meta.read().await);
let reserved_snapshot = store.pool_meta.read().await.clone();
let reservation = reserved_snapshot.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("active source reservation");
assert_eq!(
reservation.targets.iter().map(|target| target.pool_index).collect::<Vec<_>>(),
vec![target_pool_index],
"the fixture must reserve exactly one target"
);
let source_reader = store.pools[0] let source_reader = store.pools[0]
.get_object_reader( .get_object_reader(
RUSTFS_META_BUCKET, RUSTFS_META_BUCKET,
@@ -5314,12 +5350,91 @@ mod decommission_lock_order_tests {
RUSTFS_META_BUCKET.to_string(), RUSTFS_META_BUCKET.to_string(),
source_reader, source_reader,
None, None,
"scanner_backlog_conflict", "reserved_replica_conflict",
Some(owner), Some(owner),
) )
.await .await
.expect_err("a different older native ledger must retain its source and capacity intent"); .expect_err("a different older existing record must retain its source and capacity intent");
assert!(conflict.to_string().contains("Precondition failed"), "unexpected conflict: {conflict}"); assert!(conflict.to_string().contains("Precondition failed"), "unexpected conflict: {conflict}");
let reserved_snapshot = store.pool_meta.read().await.clone();
let mut selection_opts = ObjectOptions {
data_movement: true,
src_pool_idx: 0,
..Default::default()
};
assert_eq!(
store
.select_data_movement_pool_idx(RUSTFS_META_BUCKET, object, body.len() as i64, &selection_opts, true)
.await
.expect("selection without a capacity owner retains existing-replica routing"),
2
);
owner.apply_to(&mut selection_opts);
for stale_owner in [
DecommissionCapacityOwner {
owner_nonce: uuid::Uuid::new_v4(),
..owner
},
DecommissionCapacityOwner {
generation: owner.generation + 1,
..owner
},
] {
let mut stale_opts = selection_opts.clone();
stale_owner.apply_to(&mut stale_opts);
assert!(
matches!(
store
.select_data_movement_pool_idx(RUSTFS_META_BUCKET, object, body.len() as i64, &stale_opts, true)
.await,
Err(crate::error::Error::DecommissionCapacityBlocked { .. })
),
"a stale owner must not fall back to another target"
);
}
{
let mut meta = store.pool_meta.write().await;
meta.pools[0]
.decommission
.as_mut()
.unwrap()
.capacity_reservation
.as_mut()
.unwrap()
.expires_at = time::OffsetDateTime::now_utc() - time::Duration::seconds(1);
}
assert!(
matches!(
store
.select_data_movement_pool_idx(RUSTFS_META_BUCKET, object, body.len() as i64, &selection_opts, true)
.await,
Err(crate::error::Error::DecommissionCapacityBlocked { .. })
),
"an expired owner must not fall back to another target"
);
*store.pool_meta.write().await = reserved_snapshot.clone();
if !existing_outside_reservation {
{
let mut meta = store.pool_meta.write().await;
let target = &mut meta.pools[0]
.decommission
.as_mut()
.unwrap()
.capacity_reservation
.as_mut()
.unwrap()
.targets[0];
target.consumed_physical_bytes = target.reserved_physical_bytes;
}
assert_eq!(
store
.select_data_movement_pool_idx(RUSTFS_META_BUCKET, object, body.len() as i64, &selection_opts, true)
.await
.expect("an existing reserved replica can still be selected after capacity was consumed"),
target_pool_index
);
*store.pool_meta.write().await = reserved_snapshot;
}
let mut persisted = crate::core::pools::PoolMeta::default(); let mut persisted = crate::core::pools::PoolMeta::default();
persisted persisted
.load_no_lock_from_replicas(store.pools.clone()) .load_no_lock_from_replicas(store.pools.clone())
@@ -5336,11 +5451,24 @@ mod decommission_lock_order_tests {
.pending_target_physical_bytes, .pending_target_physical_bytes,
body.len() body.len()
); );
let previous = store.pools[2] for (pool_index, payload, mod_time) in &replicas {
let mut reader = store.pools[*pool_index]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("a refused existing record replacement must preserve every replica");
assert_eq!(reader.object_info.mod_time, Some(*mod_time));
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read the unchanged existing record");
assert_eq!(&actual, payload);
}
let previous = store.pools[target_pool_index]
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default()) .get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
.await .await
.expect("read the native writer's CAS revision"); .expect("read the existing writer's CAS revision");
let replacement = store.pools[2] let replacement = store.pools[target_pool_index]
.put_object( .put_object(
RUSTFS_META_BUCKET, RUSTFS_META_BUCKET,
object, object,
@@ -5356,7 +5484,7 @@ mod decommission_lock_order_tests {
}, },
) )
.await .await
.expect("native scanner CAS converges the payload without a migration marker"); .expect("existing CAS converges the payload without a migration marker");
assert!(!data_movement::is_owned_data_movement_target(&replacement)); assert!(!data_movement::is_owned_data_movement_target(&replacement));
*other_store.pool_meta.write().await = persisted; *other_store.pool_meta.write().await = persisted;
set_decommission_capacity_info_overrides_for_test(other_store.id, vec![capacities]); set_decommission_capacity_info_overrides_for_test(other_store.id, vec![capacities]);
@@ -5374,7 +5502,7 @@ mod decommission_lock_order_tests {
) )
.await .await
.expect("replica conflict recovery must be bounded") .expect("replica conflict recovery must be bounded")
.expect("identical native replica should finish migration on the reloaded node"); .expect("identical existing replica should finish migration on the reloaded node");
let mut reconciled = crate::core::pools::PoolMeta::default(); let mut reconciled = crate::core::pools::PoolMeta::default();
reconciled reconciled
.load_no_lock_from_replicas(other_store.pools.clone()) .load_no_lock_from_replicas(other_store.pools.clone())
@@ -5404,14 +5532,14 @@ mod decommission_lock_order_tests {
.await .await
.expect_err("the source should be cleaned only after equivalent-target capacity reconciliation"); .expect_err("the source should be cleaned only after equivalent-target capacity reconciliation");
assert!(crate::error::is_err_object_not_found(&missing)); assert!(crate::error::is_err_object_not_found(&missing));
let mut target_reader = other_store.pools[2] let mut target_reader = other_store.pools[target_pool_index]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default()) .get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await .await
.expect("the surviving replica should remain readable"); .expect("the surviving replica should remain readable");
assert_eq!( assert_eq!(
target_reader.object_info.mod_time, target_reader.object_info.mod_time,
Some(target_time), Some(target_time),
"recovery must not overwrite the native target" "recovery must not overwrite the existing target"
); );
let mut actual = Vec::new(); let mut actual = Vec::new();
target_reader target_reader
@@ -5419,6 +5547,20 @@ mod decommission_lock_order_tests {
.await .await
.expect("read surviving ledger bytes"); .expect("read surviving ledger bytes");
assert_eq!(actual, body); assert_eq!(actual, body);
if existing_outside_reservation {
let (_, outside_body, outside_time) = replicas.last().expect("unreserved existing replica");
let mut outside = other_store.pools[2]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("migration must leave the unreserved existing replica intact");
assert_eq!(outside.object_info.mod_time, Some(*outside_time));
let mut actual = Vec::new();
outside
.read_to_end(&mut actual)
.await
.expect("read the untouched unreserved replica");
assert_eq!(&actual, outside_body);
}
}); });
} }
+52 -33
View File
@@ -15,6 +15,7 @@
// #730: data-movement migration keeps staged cleanup helpers until copy paths converge. // #730: data-movement migration keeps staged cleanup helpers until copy paths converge.
pub(crate) mod backpressure; pub(crate) mod backpressure;
pub(crate) mod scanner_backlog;
use crate::core::pools::{DecommissionCapacityOwner, decommission_capacity_mutation_id}; use crate::core::pools::{DecommissionCapacityOwner, decommission_capacity_mutation_id};
use crate::error::{ use crate::error::{
@@ -984,24 +985,6 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
.is_some_and(|(source_time, target_time)| target_time > source_time) .is_some_and(|(source_time, target_time)| target_time > source_time)
} }
fn is_equivalent_scanner_backlog_replica(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
// Scanner publishes this exact payload to surviving sets with CAS. Each
// set assigns its own write time; that timestamp is not a ledger generation.
// Accept only an identical, known unversioned identity, never a different
// record based on timestamp ordering or a similarly named user object.
source.bucket == crate::disk::RUSTFS_META_BUCKET
&& target.bucket == source.bucket
&& source.name == "buckets/.scanner-pause-backlog.json"
&& target.name == source.name
&& is_unversioned_data_movement_object(source)
&& is_unversioned_data_movement_object(target)
&& !source.delete_marker
&& source.mod_time.is_some()
&& target.mod_time.is_some()
&& source.etag.as_ref().is_some_and(|etag| !etag.is_empty())
&& is_equivalent_data_movement_object_identity(source, target, false, compare_part_checksums)
}
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool { fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
let identity = data_movement_upload_identity(source); let identity = data_movement_upload_identity(source);
source.mod_time.is_some() source.mod_time.is_some()
@@ -1217,7 +1200,7 @@ struct SourceCleanupDeleteBarrierState {
dead_code, dead_code,
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)" reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
)] )]
pub(crate) struct SourceCleanupDeleteBarrier { pub struct SourceCleanupDeleteBarrier {
state: Arc<SourceCleanupDeleteBarrierState>, state: Arc<SourceCleanupDeleteBarrierState>,
} }
@@ -1231,7 +1214,7 @@ static SOURCE_CLEANUP_DELETE_BARRIERS: std::sync::OnceLock<std::sync::Mutex<Vec<
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)" reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
)] )]
impl SourceCleanupDeleteBarrier { impl SourceCleanupDeleteBarrier {
pub(crate) fn install(bucket: &str, object: &str) -> Self { pub fn install(bucket: &str, object: &str) -> Self {
let state = Arc::new(SourceCleanupDeleteBarrierState { let state = Arc::new(SourceCleanupDeleteBarrierState {
bucket: bucket.to_string(), bucket: bucket.to_string(),
object: object.to_string(), object: object.to_string(),
@@ -1254,7 +1237,7 @@ impl SourceCleanupDeleteBarrier {
Self { state } Self { state }
} }
pub(crate) async fn wait_until_paused(&self) { pub async fn wait_until_paused(&self) {
tokio::time::timeout(StdDuration::from_secs(30), self.state.arrived.notified()) tokio::time::timeout(StdDuration::from_secs(30), self.state.arrived.notified())
.await .await
.expect("source cleanup should reach the pre-delete barrier"); .expect("source cleanup should reach the pre-delete barrier");
@@ -1270,7 +1253,7 @@ impl SourceCleanupDeleteBarrier {
self.state.is_paused.load(Ordering::Acquire) self.state.is_paused.load(Ordering::Acquire)
} }
pub(crate) fn release(&self) { pub fn release(&self) {
self.state.release.notify_one(); self.state.release.notify_one();
} }
} }
@@ -1449,7 +1432,8 @@ fn resolve_data_movement_overwrite_resume_result_for(
target_pool_idx: usize, target_pool_idx: usize,
compare_part_checksums: bool, compare_part_checksums: bool,
) -> Result<bool> { ) -> Result<bool> {
if !should_check_data_movement_overwrite_resume(err) if scanner_backlog::is_scanner_pause_backlog(&source.bucket, &source.name)
|| !should_check_data_movement_overwrite_resume(err)
|| !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx) || !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx)
{ {
return Ok(false); return Ok(false);
@@ -1471,9 +1455,7 @@ fn resolve_data_movement_overwrite_resume_result_for(
return Ok(true); return Ok(true);
} }
Ok(matches!(err, Error::PreconditionFailed) Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
&& (is_equivalent_scanner_backlog_replica(source, &target, compare_part_checksums)
|| is_superseding_unversioned_data_movement_object(source, &target)))
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -1521,9 +1503,27 @@ fn data_movement_part_stage_error(
bucket: &str, bucket: &str,
object: &str, object: &str,
part_number: usize, part_number: usize,
err: impl std::fmt::Display, err: Error,
) -> Error { ) -> Error {
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}")) let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
return data_movement_context_error(rendered, err);
}
// A missing target part is not evidence that the source can be deleted.
// Keep other part errors opaque to the source-cleanup classifiers.
Error::other(rendered)
}
#[cfg(test)]
pub(crate) fn data_movement_part_stage_error_for_test(
op_label: &str,
stage: &str,
bucket: &str,
object: &str,
part_number: usize,
err: Error,
) -> Error {
data_movement_part_stage_error(op_label, stage, bucket, object, part_number, err)
} }
fn is_data_movement_part_read_error(err: &Error) -> bool { fn is_data_movement_part_read_error(err: &Error) -> bool {
@@ -1628,6 +1628,9 @@ async fn migrate_object_inner(
capacity_owner: Option<DecommissionCapacityOwner>, capacity_owner: Option<DecommissionCapacityOwner>,
mutation_fence: Option<DecommissionFixedReadAnchor>, mutation_fence: Option<DecommissionFixedReadAnchor>,
) -> Result<()> { ) -> Result<()> {
if scanner_backlog::is_scanner_pause_backlog(&bucket, &rd.object_info.name) {
return Err(Error::other("scanner pause backlog requires native retirement handoff"));
}
let mut mutation_fence = mutation_fence; let mut mutation_fence = mutation_fence;
let object_info = rd.object_info.clone(); let object_info = rd.object_info.clone();
let capacity_owner = capacity_owner.map(|owner| { let capacity_owner = capacity_owner.map(|owner| {
@@ -2428,8 +2431,15 @@ mod tests {
let err = let err =
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown); data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
let message = err.to_string(); let message = err.to_string();
assert!(message.contains("rebalance_object: put_object_part failed for bucket-a/object-a part 7")); assert_eq!(
assert!(message.contains(Error::SlowDown.to_string().as_str())); message,
Error::other(format!(
"rebalance_object: put_object_part failed for bucket-a/object-a part 7: {}",
Error::SlowDown
))
.to_string()
);
assert!(data_movement_stage_source(&err).is_none());
} }
#[test] #[test]
@@ -3329,16 +3339,25 @@ mod tests {
} }
#[test] #[test]
fn test_scanner_backlog_resume_accepts_identical_native_replica_with_older_write_time() { fn test_scanner_backlog_resume_requires_native_cohort_proof_even_for_identical_payload() {
let (source, target) = scanner_backlog_replica_pair(); let (source, target) = scanner_backlog_replica_pair();
assert!(!is_owned_data_movement_target(&target), "native scanner writes are not migration copies"); assert!(!is_owned_data_movement_target(&target), "native scanner writes are not migration copies");
assert!(!is_equivalent_data_movement_object(&source, &target)); assert!(!is_equivalent_data_movement_object(&source, &target));
assert!( assert!(
scanner_backlog_precondition_resumes(&source, target), !scanner_backlog_precondition_resumes(&source, target),
"identical ledger payloads have replica-local write times, not distinct committed generations" "a single identical replica cannot prove native cohort authority"
); );
} }
#[test]
fn test_scanner_backlog_resume_rejects_newer_timestamp_and_full_single_replica_identity() {
let (source, mut target) = scanner_backlog_replica_pair();
target.mod_time = source.mod_time.map(|time| time + time::Duration::SECOND);
target.etag = Some("different-native-ledger".to_string());
assert!(!scanner_backlog_precondition_resumes(&source, target));
assert!(!scanner_backlog_precondition_resumes(&source, source.clone()));
}
#[test] #[test]
fn test_scanner_backlog_resume_rejects_changed_payload_or_metadata() { fn test_scanner_backlog_resume_rejects_changed_payload_or_metadata() {
let (source, target) = scanner_backlog_replica_pair(); let (source, target) = scanner_backlog_replica_pair();
@@ -0,0 +1,292 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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;
use crate::object_api::{ObjectInfo, PutObjReader, WriteCompletion};
use crate::set_disk::SetDisks;
use crate::storage_api_contracts::object::HTTPPreconditions;
use crate::storage_api_contracts::object::ObjectIO as _;
use futures::future::join_all;
use http::HeaderMap;
use std::sync::{Arc, OnceLock};
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";
/// A bounded, storage-fenced native replica. Only a confirmed missing object
/// has no payload; read failures never enter the Scanner verifier.
pub struct ScannerPauseBacklogRetirementReplica {
pub pool_index: usize,
pub set_index: usize,
pub data: Option<Vec<u8>>,
}
/// Native records for a membership handoff. Existing durable ledgers are
/// preserved; an empty native bootstrap may initialize its first ledger.
pub struct ScannerPauseBacklogRetirementPlan {
pub seed_record: Option<Vec<u8>>,
pub commit_record: Vec<u8>,
pub stable_record: Vec<u8>,
}
pub type ScannerPauseBacklogRetirementPlanner =
fn(usize, &[ScannerPauseBacklogRetirementReplica]) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, String>;
static RETIREMENT_PLANNER: OnceLock<ScannerPauseBacklogRetirementPlanner> = OnceLock::new();
/// Install the stateless native record planner before storage starts workers.
/// The scanner runtime switch does not control this storage safety check.
pub fn register_scanner_pause_backlog_retirement_planner(planner: ScannerPauseBacklogRetirementPlanner) {
RETIREMENT_PLANNER.get_or_init(|| planner);
}
pub(crate) fn is_scanner_pause_backlog(bucket: &str, object: &str) -> bool {
bucket == RUSTFS_META_BUCKET && object == SCANNER_PAUSE_BACKLOG_PATH
}
pub(crate) struct ScannerPauseBacklogRetirementRead {
pub replica: ScannerPauseBacklogRetirementReplica,
pub etag: Option<String>,
}
impl ScannerPauseBacklogRetirementRead {
pub(crate) fn preconditions(&self) -> HTTPPreconditions {
match &self.etag {
Some(etag) => HTTPPreconditions {
if_match: Some(etag.clone()),
..Default::default()
},
None => HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
},
}
}
}
async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetirementRead> {
let mut replica = ScannerPauseBacklogRetirementReplica {
pool_index: set.pool_index,
set_index: set.set_index,
data: None,
};
let reader = match set
.get_object_reader(
RUSTFS_META_BUCKET,
SCANNER_PAUSE_BACKLOG_PATH,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
{
Ok(reader) => reader,
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
return Ok(ScannerPauseBacklogRetirementRead { replica, etag: None });
}
Err(err) => return Err(err),
};
let info = &reader.object_info;
if info.version_id.is_some_and(|version| !version.is_nil())
|| info.delete_marker
|| info.is_dir
|| info.etag.as_ref().is_none_or(String::is_empty)
|| info.size < 0
|| info.size > MAX_SCANNER_PAUSE_BACKLOG_BYTES as i64
{
return Err(Error::other("scanner pause backlog retirement found an unsupported replica identity"));
}
let etag = info.etag.clone();
let expected_size = info.size as usize;
let mut data = Vec::new();
reader
.take(MAX_SCANNER_PAUSE_BACKLOG_BYTES + 1)
.read_to_end(&mut data)
.await?;
if data.len() != expected_size || data.len() > MAX_SCANNER_PAUSE_BACKLOG_BYTES as usize {
return Err(Error::other("scanner pause backlog retirement replica has an invalid payload length"));
}
replica.data = Some(data);
Ok(ScannerPauseBacklogRetirementRead { replica, etag })
}
/// The caller retains the fixed object write lock and durable topology read
/// fence through both this snapshot and physical source cleanup.
pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
source_pool_index: usize,
source_set_index: usize,
sets: Vec<Arc<SetDisks>>,
) -> Result<Vec<ScannerPauseBacklogRetirementRead>> {
let replicas = join_all(sets.into_iter().map(read_replica))
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
if !replicas.iter().any(|read| {
read.replica.pool_index == source_pool_index && read.replica.set_index == source_set_index && read.replica.data.is_some()
}) {
return Err(Error::other("scanner pause backlog retirement current source replica is missing"));
}
Ok(replicas)
}
pub(crate) fn plan_scanner_pause_backlog_retirement(
source_pool_index: usize,
replicas: &[ScannerPauseBacklogRetirementRead],
) -> Result<Option<ScannerPauseBacklogRetirementPlan>> {
let planner = RETIREMENT_PLANNER
.get()
.ok_or_else(|| Error::other("scanner pause backlog native retirement planner is unavailable"))?;
let snapshots = replicas
.iter()
.map(|read| ScannerPauseBacklogRetirementReplica {
pool_index: read.replica.pool_index,
set_index: read.replica.set_index,
data: read.replica.data.clone(),
})
.collect::<Vec<_>>();
planner(source_pool_index, &snapshots).map_err(Error::other)
}
/// The native writer and retirement handoff use the same conditional, full-tail
/// write. Their callers retain object and durable membership fences until return.
pub(crate) async fn persist_native_scanner_pause_backlog_replica(
set: Arc<SetDisks>,
data: Vec<u8>,
preconditions: HTTPPreconditions,
mut opts: ObjectOptions,
_phase: &'static str,
) -> Result<ObjectInfo> {
if data.len() > MAX_SCANNER_PAUSE_BACKLOG_BYTES as usize {
return Err(Error::other("scanner pause backlog exceeds its size bound"));
}
opts.max_parity = true;
opts.write_completion = WriteCompletion::TailDrained;
opts.http_preconditions = Some(preconditions);
#[cfg(feature = "test-util")]
let fault = test_util::matching_write(&set, _phase)?;
let result = set
.put_object(RUSTFS_META_BUCKET, SCANNER_PAUSE_BACKLOG_PATH, &mut PutObjReader::from_vec(data), &opts)
.await;
#[cfg(feature = "test-util")]
if result.is_ok()
&& let Some(fault) = fault
{
fault.arrived.notify_one();
fault.release.notified().await;
}
result
}
#[cfg(feature = "test-util")]
pub mod test_util {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Notify;
#[derive(Debug, thiserror::Error)]
#[error("injected native scanner backlog {phase} write failure")]
struct InjectedWriteFailure {
phase: &'static str,
}
pub(super) struct WriteFault {
set: Arc<SetDisks>,
phase: &'static str,
remaining: AtomicUsize,
fail_before_write: bool,
pub(super) arrived: Notify,
pub(super) release: Notify,
}
static WRITE_FAULTS: Mutex<Vec<Arc<WriteFault>>> = Mutex::new(Vec::new());
/// Scope a one-shot fault to the actual set instance, so other stores and
/// concurrent tests keep using the ordinary native persistence path.
pub struct NativeScannerPauseBacklogWriteFault {
state: Arc<WriteFault>,
}
impl NativeScannerPauseBacklogWriteFault {
fn install(set: Arc<SetDisks>, phase: &'static str, nth: usize, fail_before_write: bool) -> Self {
assert!(nth > 0);
let state = Arc::new(WriteFault {
set,
phase,
remaining: AtomicUsize::new(nth),
fail_before_write,
arrived: Notify::new(),
release: Notify::new(),
});
let mut faults = WRITE_FAULTS.lock().unwrap();
assert!(
!faults
.iter()
.any(|fault| Arc::ptr_eq(&fault.set, &state.set) && fault.phase == phase)
);
faults.push(Arc::clone(&state));
Self { state }
}
pub fn fail_before_write(set: Arc<SetDisks>, phase: &'static str, nth: usize) -> Self {
Self::install(set, phase, nth, true)
}
pub fn pause_after_write(set: Arc<SetDisks>, phase: &'static str) -> Self {
Self::install(set, phase, 1, false)
}
pub async fn wait_until_paused(&self) {
self.state.arrived.notified().await;
}
pub fn release(&self) {
self.state.release.notify_one();
}
}
impl Drop for NativeScannerPauseBacklogWriteFault {
fn drop(&mut self) {
self.release();
WRITE_FAULTS.lock().unwrap().retain(|fault| !Arc::ptr_eq(fault, &self.state));
}
}
pub(super) fn matching_write(set: &Arc<SetDisks>, phase: &'static str) -> Result<Option<Arc<WriteFault>>> {
let fault = WRITE_FAULTS
.lock()
.unwrap()
.iter()
.find(|fault| Arc::ptr_eq(&fault.set, set) && fault.phase == phase)
.cloned();
let Some(fault) = fault else { return Ok(None) };
if fault
.remaining
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1))
!= Ok(1)
{
return Ok(None);
}
if fault.fail_before_write {
return Err(Error::other(InjectedWriteFailure { phase }));
}
Ok(Some(fault))
}
}
+2
View File
@@ -3397,6 +3397,7 @@ mod tests {
scan_plan_digest: Some([1; 32]), scan_plan_digest: Some([1; 32]),
complete: false, complete: false,
tombstone: false, tombstone: false,
segment_invalidation_proof: None,
}]; }];
partial.buckets_usage.insert( partial.buckets_usage.insert(
"bucket".to_string(), "bucket".to_string(),
@@ -3469,6 +3470,7 @@ mod tests {
scan_plan_digest: Some([1; 32]), scan_plan_digest: Some([1; 32]),
complete: true, complete: true,
tombstone: false, tombstone: false,
segment_invalidation_proof: None,
}], }],
..Default::default() ..Default::default()
}; };
+107 -1
View File
@@ -627,7 +627,13 @@ impl From<tokio::task::JoinError> for DiskError {
impl Clone for DiskError { impl Clone for DiskError {
fn clone(&self) -> Self { fn clone(&self) -> Self {
match self { match self {
DiskError::Io(io_error) => DiskError::Io(std::io::Error::new(io_error.kind(), io_error.to_string())), DiskError::Io(io_error) => DiskError::Io(
rustfs_rio::clone_internode_http_io_error(io_error)
.and_then(std::io::Error::into_inner)
// The helper derives a kind from the source; Clone must retain the original outer kind.
.map(|source| std::io::Error::new(io_error.kind(), source))
.unwrap_or_else(|| std::io::Error::new(io_error.kind(), io_error.to_string())),
),
DiskError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded, DiskError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
DiskError::Unexpected => DiskError::Unexpected, DiskError::Unexpected => DiskError::Unexpected,
DiskError::CorruptedFormat => DiskError::CorruptedFormat, DiskError::CorruptedFormat => DiskError::CorruptedFormat,
@@ -1265,6 +1271,49 @@ mod tests {
assert!(!bad_request.is_retryable_internode_write_failure()); assert!(!bad_request.is_retryable_internode_write_failure());
} }
#[test]
fn test_internode_http_clone_preserves_retryability_status_and_context() {
use http::StatusCode;
use rustfs_rio::InternodeHttpErrorKind::{ConnectionRefused, ConnectionReset, HttpStatus, Unknown};
for (kind, retryable) in [
(ConnectionRefused, true),
(ConnectionReset, true),
(HttpStatus(StatusCode::TOO_MANY_REQUESTS), true),
(HttpStatus(StatusCode::SERVICE_UNAVAILABLE), true),
(HttpStatus(StatusCode::CONFLICT), true),
(Unknown, false),
(HttpStatus(StatusCode::BAD_REQUEST), false),
(HttpStatus(StatusCode::INTERNAL_SERVER_ERROR), false),
] {
let original = DiskError::from(rustfs_rio::new_test_internode_http_io_error(kind));
assert_eq!(original.internode_http_error_kind(), Some(kind));
assert_eq!(original.is_retryable_internode_write_failure(), retryable);
let cloned = original.clone();
assert_eq!(cloned, original, "clone must preserve the error bucket for {kind:?}");
assert_eq!(
cloned.is_retryable_internode_write_failure(),
retryable,
"clone changed retryability for {kind:?}"
);
assert_eq!(cloned.internode_http_error_kind(), Some(kind));
if let HttpStatus(status) = kind {
assert!(cloned.is_internode_http_status(status.as_u16()));
}
let DiskError::Io(io_error) = &cloned else {
panic!("unmarked internode error must remain Io: {cloned:?}");
};
let source = io_error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
.expect("clone must retain the structured internode error");
assert_eq!(source.context().method(), "PUT");
assert_eq!(source.context().target(), "/rustfs/rpc/put_file_stream");
assert_eq!(source.context().operation(), Some(INTERNODE_OPERATION_PUT_FILE_STREAM));
}
}
#[tokio::test] #[tokio::test]
async fn read_stream_conflict_is_not_a_retryable_put_file_failure() { async fn read_stream_conflict_is_not_a_retryable_put_file_failure() {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -1309,11 +1358,57 @@ mod tests {
!error.is_retryable_internode_write_failure(), !error.is_retryable_internode_write_failure(),
"read-operation 409 must not trigger put-file retry" "read-operation 409 must not trigger put-file retry"
); );
let cloned = error.clone();
let reduced = crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(error)], &[], 1)
.expect("the read conflict must remain the dominant error");
for preserved in [&cloned, &reduced] {
assert!(
!preserved.is_retryable_internode_write_failure(),
"cloning or reducing a read conflict must not turn it into a PUT retry"
);
assert!(preserved.is_internode_http_status(409));
let DiskError::Io(io_error) = preserved else {
panic!("read conflict must remain Io: {preserved:?}");
};
let source = io_error
.get_ref()
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
.expect("read conflict must retain its request context");
assert_eq!(source.context().method(), "GET");
assert_eq!(source.context().target(), "/rustfs/rpc/read_file_stream");
assert_eq!(
source.context().operation(),
Some(rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_READ_FILE_STREAM)
);
}
}) })
.await .await
.expect("isolated read-conflict test must finish within its budget"); .expect("isolated read-conflict test must finish within its budget");
} }
#[test]
fn test_internode_http_clone_preserves_outer_io_kind_and_message() {
let source = rustfs_rio::new_test_internode_http_io_error(InternodeHttpErrorKind::ConnectionReset)
.into_inner()
.expect("the internode helper must provide a typed source");
let original_io = io::Error::new(io::ErrorKind::InvalidData, source);
let message = original_io.to_string();
let original = DiskError::from(original_io);
assert_eq!(original.internode_http_error_kind(), Some(InternodeHttpErrorKind::ConnectionReset));
assert!(original.is_retryable_internode_write_failure());
let cloned = original.clone();
let reduced = crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(original)], &[], 1)
.expect("the wrapped internode error must remain the dominant error");
for preserved in [&cloned, &reduced] {
let DiskError::Io(io_error) = preserved else {
panic!("the wrapped error must remain Io: {preserved:?}");
};
assert_eq!(io_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(io_error.to_string(), message);
}
}
#[test] #[test]
fn test_internode_missing_errors_preserve_disk_error_types() { fn test_internode_missing_errors_preserve_disk_error_types() {
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error()); let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
@@ -1325,6 +1420,17 @@ mod tests {
assert_eq!(file_missing, DiskError::FileNotFound); assert_eq!(file_missing, DiskError::FileNotFound);
assert_eq!(volume_missing, DiskError::VolumeNotFound); assert_eq!(volume_missing, DiskError::VolumeNotFound);
assert!(matches!(unmarked_server_error, DiskError::Io(_))); assert!(matches!(unmarked_server_error, DiskError::Io(_)));
for missing in [file_missing, volume_missing] {
assert_eq!(missing.clone(), missing);
assert_eq!(
crate::disk::error_reduce::reduce_write_quorum_errs(
&[Some(missing.clone()), Some(missing.clone()), None],
&[],
2
),
Some(missing)
);
}
} }
#[test] #[test]
+72
View File
@@ -226,6 +226,78 @@ mod tests {
assert_eq!(res, Some(quorum_err)); assert_eq!(res, Some(quorum_err));
} }
#[test]
fn test_write_quorum_reduction_preserves_internode_http_identity() {
use http::StatusCode;
use rustfs_rio::InternodeHttpErrorKind::{ConnectionRefused, HttpStatus, Unknown};
for (kind, retryable) in [
(ConnectionRefused, true),
(HttpStatus(StatusCode::SERVICE_UNAVAILABLE), true),
(HttpStatus(StatusCode::CONFLICT), true),
(Unknown, false),
(HttpStatus(StatusCode::BAD_REQUEST), false),
] {
// Construct both producer errors independently: the reducer owns the first clone.
let first = Error::from(rustfs_rio::new_test_internode_http_io_error(kind));
let second = Error::from(rustfs_rio::new_test_internode_http_io_error(kind));
assert_eq!(first.internode_http_error_kind(), Some(kind));
assert_eq!(second.internode_http_error_kind(), Some(kind));
assert_eq!(first.is_retryable_internode_write_failure(), retryable);
let errors = [Some(first), Some(second), None];
let reduced = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, 2)
.expect("two equal producer errors must dominate one successful write");
assert_eq!(Some(&reduced), errors[0].as_ref());
assert_eq!(
reduced.is_retryable_internode_write_failure(),
retryable,
"quorum reduction changed retryability for {kind:?}"
);
assert_eq!(reduced.internode_http_error_kind(), Some(kind));
if let HttpStatus(status) = kind {
assert!(reduced.is_internode_http_status(status.as_u16()));
}
let Error::Io(io_error) = &reduced else {
panic!("the dominant error must remain Io: {reduced:?}");
};
let source = io_error
.get_ref()
.and_then(|source| source.downcast_ref::<rustfs_rio::InternodeHttpError>())
.expect("quorum reduction must retain the structured internode error");
assert_eq!(source.context().method(), "PUT");
assert_eq!(source.context().target(), "/rustfs/rpc/put_file_stream");
assert_eq!(
source.context().operation(),
Some(rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_PUT_FILE_STREAM)
);
}
}
#[test]
fn test_clone_and_write_quorum_do_not_promote_non_retryable_errors() {
use http::StatusCode;
use rustfs_rio::InternodeHttpErrorKind::{HttpStatus, Unknown};
for original in [
Error::from(rustfs_rio::new_test_internode_http_io_error(Unknown)),
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::BAD_REQUEST))),
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::FORBIDDEN))),
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::NOT_FOUND))),
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(
StatusCode::INTERNAL_SERVER_ERROR,
))),
err_io("internode connection reset: PUT /rustfs/rpc/put_file_stream"),
] {
assert!(!original.is_retryable_internode_write_failure());
let cloned = original.clone();
let reduced =
reduce_write_quorum_errs(&[Some(original)], &[], 1).expect("a non-retryable error must remain an error");
assert!(!cloned.is_retryable_internode_write_failure());
assert!(!reduced.is_retryable_internode_write_failure());
}
}
#[test] #[test]
fn test_count_errs() { fn test_count_errs() {
let e1 = err_io("a"); let e1 = err_io("a");
+106
View File
@@ -22543,6 +22543,112 @@ mod test {
); );
} }
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_storage_full_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
.await
.expect("previous MRF manifest should commit"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_0,
Some(previous_manifest.clone()),
Some(successor_manifest),
)
.await
.expect_err("storage-full fsync failure must fail the MRF manifest successor commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("previous committed MRF manifest should remain readable after storage-full rollback"),
previous_manifest
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after storage-full manifest publication failure"),
legacy_journal
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_storage_full_delete_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let committed_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(committed_manifest.clone()),)
.await
.expect("committed MRF manifest should publish"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
let err = disk
.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, Some(committed_manifest.clone()), None)
.await
.expect_err("storage-full fsync failure must fail the MRF manifest cleanup delete");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("committed MRF manifest should be restored after failed cleanup delete"),
committed_manifest
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after failed cleanup delete"),
legacy_journal
);
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() { async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
+128 -38
View File
@@ -452,13 +452,13 @@ pub(crate) mod windows_rename_test_hooks {
/// Test-only hooks into the destination-parent walk of rename preparation. /// Test-only hooks into the destination-parent walk of rename preparation.
/// ///
/// The prune race lives between two syscalls inside /// Pruning and Windows sharing races live between syscalls inside
/// [`mkdir_all_below_existing_base_std`], so only an injection at that exact /// [`mkdir_all_below_existing_base_std`], so only an injection at that exact
/// point reproduces it deterministically. Hooks are keyed by the absolute path /// point reproduces it deterministically. Hooks are keyed by the absolute path
/// of the component just opened and queued per path: a retrying preparation /// of the component just opened and queued per path: a retrying preparation
/// visits the same component again, so a test models a pruner that keeps /// visits the same component again, so a test models a pruner that keeps
/// walking upward by queueing one hook per visit. /// walking upward by queueing one hook per visit.
#[cfg(all(test, unix))] #[cfg(all(test, any(unix, windows)))]
pub(crate) mod prepare_rename_test_hooks { pub(crate) mod prepare_rename_test_hooks {
use super::*; use super::*;
@@ -4208,11 +4208,14 @@ pub(crate) fn mkdir_all_below_existing_base_std(
let mut handles = Vec::with_capacity(capacity); let mut handles = Vec::with_capacity(capacity);
handles.push(publication_root.directory.clone()); handles.push(publication_root.directory.clone());
let mut guard = ExistingBaseDirectoryGuard::new(handles); let mut guard = ExistingBaseDirectoryGuard::new(handles);
for component in base_relative.components() { let mut components = base_relative
.components()
.map(|component| (component, FILE_OPEN))
.chain(relative.components().map(|component| (component, FILE_OPEN_IF)))
.filter(|(component, _)| !matches!(component, Component::CurDir))
.peekable();
while let Some((component, disposition)) = components.next() {
let Component::Normal(component) = component else { let Component::Normal(component) = component else {
if matches!(component, Component::CurDir) {
continue;
}
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
"rename base directory contains an invalid path component", "rename base directory contains an invalid path component",
@@ -4222,40 +4225,22 @@ pub(crate) fn mkdir_all_below_existing_base_std(
.handles .handles
.last() .last()
.ok_or_else(|| io::Error::other("Windows publication root guard is empty"))?; .ok_or_else(|| io::Error::other("Windows publication root guard is empty"))?;
let child = open_windows_directory_component(parent, component, FILE_OPEN)?; // The kernel opens the final parent for write during a relative
guard.handles.push(child); // rename. Share writes from its first open: a temporary read-only
} // share would block another rename into the same trash directory.
for component in relative.components() { // Ancestors stay strict and no handle shares delete access, keeping
let Component::Normal(component) = component else { // every retained directory identity pinned.
continue; let share_access = if components.peek().is_none() {
FILE_SHARE_READ | FILE_SHARE_WRITE
} else {
FILE_SHARE_READ
}; };
let parent = guard let child = open_windows_relative_directory_component(parent, component, disposition, share_access)?;
.handles
.last()
.ok_or_else(|| io::Error::other("Windows base directory guard is empty"))?;
let child = open_windows_directory_component(parent, component, FILE_OPEN_IF)?;
guard.handles.push(child); guard.handles.push(child);
} #[cfg(test)]
if components.peek().is_none() {
// Windows resolves a handle-relative rename by opening the target for prepare_rename_test_hooks::run_after_component_opened(dir_path);
// write. Keep every ancestor strict, but let that internal open share }
// the final parent. Delete sharing remains omitted, so the retained
// directory entry cannot be renamed or removed during publication.
if guard.handles.len() > 1 {
let component = dir_path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination parent must have a name"))?;
let parent_index = guard.handles.len() - 2;
let parent = guard
.handles
.get(parent_index)
.ok_or_else(|| io::Error::other("Windows destination guard lost its parent handle"))?;
let rename_parent =
open_windows_relative_directory_component(parent, component, FILE_OPEN, FILE_SHARE_READ | FILE_SHARE_WRITE)?;
*guard
.handles
.last_mut()
.ok_or_else(|| io::Error::other("Windows destination guard is empty"))? = rename_parent;
} }
Ok(guard) Ok(guard)
@@ -5446,6 +5431,111 @@ mod tests {
assert_eq!(std::fs::read(dst).expect("read committed metadata"), b"metadata"); assert_eq!(std::fs::read(dst).expect("read committed metadata"), b"metadata");
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rename_all_concurrent_trash_renames_remove_every_rollback_directory() {
let temp_dir = tempdir().expect("create temp dir");
let trash = temp_dir.path().join(".rustfs.sys/tmp/.trash");
std::fs::create_dir_all(&trash).expect("create trash directory");
let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root");
let sources: Vec<_> = (0..16)
.map(|index| {
let source = temp_dir
.path()
.join(format!("bucket/{index}.mp4"))
.join(uuid::Uuid::new_v4().to_string());
std::fs::create_dir_all(&source).expect("create rollback directory");
std::fs::write(source.join("xl.meta.bkp"), b"rollback metadata").expect("write metadata backup");
source
})
.collect();
let results = futures::future::join_all(sources.iter().enumerate().map(|(index, source)| {
super::rename_all_ignore_missing_source(source, trash.join(index.to_string()), &trash, &publication_root)
}))
.await;
for (index, (source, result)) in sources.iter().zip(results).enumerate() {
result.expect("concurrent rollback cleanup must reach the shared trash directory");
assert!(!source.exists(), "rollback cleanup must not leave a directory in the bucket");
assert_eq!(
std::fs::read(trash.join(index.to_string()).join("xl.meta.bkp")).expect("read moved metadata backup"),
b"rollback metadata",
"trash staging must retain the complete backup"
);
}
}
#[cfg(windows)]
#[test]
fn windows_trash_rename_succeeds_during_concurrent_parent_preparation() {
use std::os::windows::fs::OpenOptionsExt;
use std::sync::atomic::{AtomicBool, Ordering};
use windows_sys::Win32::{
Foundation::{ERROR_SHARING_VIOLATION, GENERIC_WRITE},
Storage::FileSystem::{
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
FILE_SHARE_WRITE,
},
};
// Exercise the final parent both in the existing base walk and in the
// creatable suffix walk. Neither may briefly deny write sharing.
for nested_parent in [false, true] {
let temp_dir = tempdir().expect("create temp dir");
let tmp = temp_dir.path().join(".rustfs.sys/tmp");
let trash = tmp.join(".trash");
std::fs::create_dir_all(&trash).expect("create trash directory");
let base = if nested_parent { &tmp } else { &trash };
let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root");
let sources = ["first", "second"].map(|name| {
let source = temp_dir
.path()
.join("bucket")
.join(name)
.join(uuid::Uuid::new_v4().to_string());
std::fs::create_dir_all(&source).expect("create rollback directory");
std::fs::write(source.join("xl.meta.bkp"), name.as_bytes()).expect("write metadata backup");
source
});
let destinations = [trash.join("first"), trash.join("second")];
let first_preparation = prepare_rename_with_retry(&sources[0], &destinations[0], base, &publication_root)
.expect("prepare the first trash rename");
let first_source = sources[0].clone();
let first_destination = destinations[0].clone();
let interleaved = Arc::new(AtomicBool::new(false));
let interleaved_hook = Arc::clone(&interleaved);
prepare_rename_test_hooks::queue_after_component_opened(&trash, move || {
interleaved_hook.store(true, Ordering::Release);
rename_prepared(&first_source, &first_destination, &first_preparation)
.expect("another preparation's first parent handle must allow the pending trash rename");
});
let second_preparation = prepare_rename_with_retry(&sources[1], &destinations[1], base, &publication_root)
.expect("prepare the second trash rename");
assert!(interleaved.load(Ordering::Acquire), "the competing parent-open window must be exercised");
for (path, access) in [(&tmp, GENERIC_WRITE), (&trash, DELETE)] {
let err = std::fs::OpenOptions::new()
.access_mode(access)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
.expect_err("ancestor writes and final-parent deletion must remain excluded");
assert_eq!(
err.raw_os_error(),
Some(i32::try_from(ERROR_SHARING_VIOLATION).expect("Windows error code must fit i32"))
);
}
rename_prepared(&sources[1], &destinations[1], &second_preparation).expect("publish the second trash rename");
for (index, payload) in [b"first".as_slice(), b"second".as_slice()].into_iter().enumerate() {
assert!(!sources[index].exists(), "both rollback directories must leave the bucket");
assert_eq!(
std::fs::read(destinations[index].join("xl.meta.bkp")).expect("read the staged backup"),
payload
);
}
}
}
#[cfg(windows)] #[cfg(windows)]
#[tokio::test] #[tokio::test]
async fn windows_rename_all_supports_same_parent_publication() { async fn windows_rename_all_supports_same_parent_publication() {
+77 -20
View File
@@ -14,7 +14,7 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
sync::{Arc, OnceLock}, sync::{Arc, LazyLock, OnceLock, RwLock as StdRwLock},
time::SystemTime, time::SystemTime,
}; };
@@ -57,6 +57,13 @@ use uuid::Uuid;
const TEST_RPC_SECRET: &str = "test-rpc-secret"; const TEST_RPC_SECRET: &str = "test-rpc-secret";
pub(crate) type WorkloadSnapshotProviderRef = Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>; pub(crate) type WorkloadSnapshotProviderRef = Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>;
pub type ScannerDirtyUsageMutationObserver = Arc<dyn Fn(&str, &str, ScannerDirtyUsageMutationSource) + Send + Sync + 'static>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScannerDirtyUsageMutationSource {
Replication,
TierExpiration,
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub(crate) struct LockRegistry { pub(crate) struct LockRegistry {
@@ -88,6 +95,8 @@ impl LockRegistry {
} }
static WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER: OnceLock<WorkloadSnapshotProviderRef> = OnceLock::new(); static WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER: OnceLock<WorkloadSnapshotProviderRef> = OnceLock::new();
static SCANNER_DIRTY_USAGE_MUTATION_OBSERVER: LazyLock<StdRwLock<Option<ScannerDirtyUsageMutationObserver>>> =
LazyLock::new(|| StdRwLock::new(None));
pub(crate) fn set_workload_admission_snapshot_provider( pub(crate) fn set_workload_admission_snapshot_provider(
provider: WorkloadSnapshotProviderRef, provider: WorkloadSnapshotProviderRef,
@@ -99,6 +108,28 @@ pub(crate) fn workload_admission_snapshot_provider() -> Option<WorkloadSnapshotP
WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER.get().cloned() WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER.get().cloned()
} }
pub fn set_scanner_dirty_usage_mutation_observer(
observer: Option<ScannerDirtyUsageMutationObserver>,
) -> Option<ScannerDirtyUsageMutationObserver> {
let mut slot = SCANNER_DIRTY_USAGE_MUTATION_OBSERVER
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::replace(&mut *slot, observer)
}
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
if bucket.is_empty() || object.is_empty() {
return;
}
let observer = SCANNER_DIRTY_USAGE_MUTATION_OBSERVER
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
if let Some(observer) = observer {
observer(bucket, object, source);
}
}
pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_error: &'static str) { pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_error: &'static str) {
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error); global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
} }
@@ -570,36 +601,26 @@ pub(crate) async fn initialize_local_disk_maps(
Ok(()) Ok(())
} }
#[cfg(test)]
tokio::task_local! {
pub(crate) static TEST_TIER_CONFIG_MGR: Arc<tokio::sync::RwLock<TierConfigMgr>>;
}
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> { pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
#[cfg(not(test))]
let handle = get_global_tier_config_mgr(); let handle = get_global_tier_config_mgr();
#[cfg(test)] TierConfigMgr::reload_handle(&handle, store.clone()).await?;
let handle = TEST_TIER_CONFIG_MGR
.try_with(Arc::clone)
.unwrap_or_else(|_| get_global_tier_config_mgr());
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
if initial_reload.is_err() {
// Keep local recovery active when the initial snapshot cannot be loaded.
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
}
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store)); tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
initial_reload.map_err(Error::from) Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
LockRegistry, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name, reconcile_local_disk_ids, LockRegistry, ScannerDirtyUsageMutationSource, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name,
replace_local_disk_id, set_local_node_name, notify_scanner_dirty_usage_mutation, reconcile_local_disk_ids, replace_local_disk_id, set_local_node_name,
set_scanner_dirty_usage_mutation_observer,
}; };
use crate::disk::endpoint::Endpoint; use crate::disk::endpoint::Endpoint;
use rustfs_lock::{LocalClient, LockClient}; use rustfs_lock::{LocalClient, LockClient};
use std::{collections::HashMap, sync::Arc}; use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use uuid::Uuid; use uuid::Uuid;
fn url_endpoint(raw: &str) -> Endpoint { fn url_endpoint(raw: &str) -> Endpoint {
@@ -634,6 +655,42 @@ mod tests {
assert!(Arc::ptr_eq(&clients[1], &client_b)); assert!(Arc::ptr_eq(&clients[1], &client_b));
} }
#[test]
#[serial_test::serial(scanner_dirty_usage_mutation_observer)]
fn scanner_dirty_usage_mutation_observer_filters_empty_identity_and_preserves_source() {
let observed = Arc::new(Mutex::new(Vec::new()));
let observed_clone = Arc::clone(&observed);
let previous = set_scanner_dirty_usage_mutation_observer(Some(Arc::new(move |bucket, object, source| {
observed_clone.lock().expect("observer lock should not be poisoned").push((
bucket.to_string(),
object.to_string(),
source,
));
})));
notify_scanner_dirty_usage_mutation("photos", "2026/image.jpg", ScannerDirtyUsageMutationSource::Replication);
notify_scanner_dirty_usage_mutation("", "2026/empty-bucket.jpg", ScannerDirtyUsageMutationSource::TierExpiration);
notify_scanner_dirty_usage_mutation("photos", "", ScannerDirtyUsageMutationSource::TierExpiration);
notify_scanner_dirty_usage_mutation("archive", "expired.bin", ScannerDirtyUsageMutationSource::TierExpiration);
set_scanner_dirty_usage_mutation_observer(previous);
assert_eq!(
*observed.lock().expect("observer lock should not be poisoned"),
vec![
(
"photos".to_string(),
"2026/image.jpg".to_string(),
ScannerDirtyUsageMutationSource::Replication
),
(
"archive".to_string(),
"expired.bin".to_string(),
ScannerDirtyUsageMutationSource::TierExpiration
),
]
);
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn local_node_name_round_trips_through_common_runtime_helper() { async fn local_node_name_round_trips_through_common_runtime_helper() {
@@ -568,6 +568,10 @@ pub(crate) fn tier_delete_journal_topology_generation(proof: &TierDeleteJournalF
stable_tier_delete_journal_topology_generation(&proof.token.topology_fingerprint) stable_tier_delete_journal_topology_generation(&proof.token.topology_fingerprint)
} }
pub(crate) fn cross_pool_fence_topology_generation(proof: &CrossPoolFenceFleetProofToken) -> String {
stable_tier_delete_journal_topology_generation(&proof.0.topology_fingerprint)
}
/// Acquire one non-cloneable authority that must span the complete reconcile /// Acquire one non-cloneable authority that must span the complete reconcile
/// effect window, including its final strong readback. /// effect window, including its final strong readback.
pub async fn acquire_legacy_transition_state_reconcile_fleet_proof() -> Option<LegacyTransitionStateReconcileFleetProofToken> { pub async fn acquire_legacy_transition_state_reconcile_fleet_proof() -> Option<LegacyTransitionStateReconcileFleetProofToken> {
@@ -1120,6 +1124,14 @@ pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerp
RemoteVersionStateFleetProofGuard RemoteVersionStateFleetProofGuard
} }
#[cfg(all(test, feature = "test-util"))]
pub(crate) fn install_current_remote_version_state_fleet_proof_for_test() -> RemoteVersionStateFleetProofGuard {
let topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY
.get()
.expect("the test store must bind its fleet topology before installing a writer proof");
install_remote_version_state_fleet_proof_for_test(topology)
}
#[cfg(all(test, feature = "test-util"))] #[cfg(all(test, feature = "test-util"))]
pub(crate) struct TransitionTransactionCompactionFleetProofGuard; pub(crate) struct TransitionTransactionCompactionFleetProofGuard;
@@ -572,7 +572,7 @@ impl ECStore {
where where
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>, S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
{ {
// Lock order: pool_meta_save_gate -> pool.bin -> rebalance.bin. // Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin.
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await; let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
pool_meta_guard.ensure_write_safe("rebalance worker activation")?; pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
// Classify the durable rebalance record while holding both namespace // Classify the durable rebalance record while holding both namespace
@@ -50,6 +50,11 @@ fn ensure_rebalance_entry_active(cancel: &CancellationToken) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)]
tokio::task_local! {
static REBALANCE_ENTRY_RUN_FENCE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
}
#[derive(Debug)] #[derive(Debug)]
struct RebalanceEntryTarget { struct RebalanceEntryTarget {
bucket: String, bucket: String,
@@ -256,9 +261,15 @@ impl ECStore {
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time))); .sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
// Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate. // Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate.
// Target capacity admission can then acquire pool.bin under the run fence.
// Stop waits for in-flight entries through cleanup, but not for entries admitted later. // Stop waits for in-flight entries through cleanup, but not for entries admitted later.
ensure_rebalance_entry_active(&cancel)?; ensure_rebalance_entry_active(&cancel)?;
let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?; let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?;
#[cfg(test)]
if let Ok((arrived, release)) = REBALANCE_ENTRY_RUN_FENCE_BARRIER.try_with(Clone::clone) {
arrived.notify_one();
release.notified().await;
}
let lock_lost_signal = run_guard.lock_lost_signal(); let lock_lost_signal = run_guard.lock_lost_signal();
#[cfg(test)] #[cfg(test)]
let _run_signal_test_fence = lock_lost_signal let _run_signal_test_fence = lock_lost_signal
@@ -1237,6 +1248,130 @@ mod tests {
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning"); assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
} }
#[tokio::test]
#[serial_test::serial]
async fn real_rebalance_entry_progresses_while_peer_activation_waits_for_run_fence() {
const REBALANCE_ID: &str = "rebalance-peer-activation-lock-order";
let (_temp_dirs, store, peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(
active_rebalance_meta(REBALANCE_ID),
))
.await;
assert!(!Arc::ptr_eq(&store.ctx, &peer.ctx), "node-local movement gates must be independent");
{
let mut meta = peer.rebalance_meta.write().await;
let meta = meta.as_mut().expect("peer should know the durable run");
meta.activation_gate = Arc::default();
meta.cancel = None;
}
let bucket = crate::disk::RUSTFS_META_BUCKET;
let object = "rebalance-peer-activation-object";
let version_id = uuid::Uuid::new_v4();
let payload = b"entry must drain before peer activation takes the pool fence".repeat(1024);
let source_set = store.pools[0].get_disks_by_key(object);
let target_set = store.pools[1].get_disks_by_key(object);
let opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
let mut writer = PutObjReader::from_vec(payload.clone());
let source_before = source_set
.put_object(bucket, object, &mut writer, &opts)
.await
.expect("source version should be written");
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
let arrived = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
// JoinSet aborts both scoped tasks if an assertion or timeout fails.
let mut tasks = tokio::task::JoinSet::new();
let entry_store = Arc::clone(&store);
tasks.spawn(
REBALANCE_ENTRY_RUN_FENCE_BARRIER.scope((Arc::clone(&arrived), Arc::clone(&release)), async move {
entry_store
.rebalance_entry(
RebalanceEntryTarget {
bucket: bucket.to_string(),
pool_index: 0,
},
entry,
source_set,
Arc::new(RebalanceBucketConfigs::default()),
Arc::from(REBALANCE_ID),
CancellationToken::new(),
)
.await
}),
);
tokio::time::timeout(StdDuration::from_secs(30), arrived.notified())
.await
.expect("real entry must acquire its persisted run read fence");
let attempted = Arc::new(tokio::sync::Notify::new());
let peer_pool = Arc::clone(&peer.pools[0]);
let (activation_done, activation_result) = tokio::sync::oneshot::channel();
tasks.spawn(
crate::core::pools::REBALANCE_ACTIVATION_LOCK_ATTEMPT.scope(Arc::clone(&attempted), async move {
let result = peer.fence_rebalance_worker_activation(peer_pool, REBALANCE_ID).await;
let result = result.map(|fence| match fence {
super::super::control::RebalanceWorkerActivationFence::Ready(fence) => {
fence.ensure_held().expect("peer activation must retain both fences");
}
super::super::control::RebalanceWorkerActivationFence::NotStartedTerminal => {
panic!("the paused entry's run must still require activation");
}
});
activation_done.send(result).expect("activation receiver should remain alive");
Ok(RebalanceEntryOutcome::Completed)
}),
);
tokio::time::timeout(StdDuration::from_secs(30), attempted.notified())
.await
.expect("peer activation must attempt the persisted rebalance write fence");
release.notify_one();
tokio::time::timeout(StdDuration::from_secs(30), async {
while let Some(result) = tasks.join_next().await {
assert!(matches!(
result
.expect("scoped task must not panic")
.expect("entry must not fail or defer"),
RebalanceEntryOutcome::Completed
));
}
})
.await
.expect("entry and peer activation must both make progress");
activation_result
.await
.expect("peer activation result should be sent")
.expect("peer activation must not time out behind the entry it blocks");
let mut reader = target_set
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
.await
.expect("the exact target version must be readable");
let mut actual = Vec::new();
reader
.stream
.read_to_end(&mut actual)
.await
.expect("target body should drain completely");
assert_eq!(actual, payload);
assert_eq!(reader.object_info.version_id, source_before.version_id);
assert_eq!(reader.object_info.etag, source_before.etag);
assert_eq!(reader.object_info.mod_time, source_before.mod_time);
let source_error = store.pools[0]
.get_object_info(bucket, object, &opts)
.await
.expect_err("completed entry must clean up the source version");
assert!(crate::error::is_err_object_not_found(&source_error) || crate::error::is_err_version_not_found(&source_error));
let meta = store.rebalance_meta.read().await;
let stats = &meta.as_ref().expect("local run must remain installed").pool_stats[0];
assert_eq!(stats.num_objects, 1);
assert_eq!(stats.num_versions, 1);
assert_eq!(stats.cleanup_warnings.count, 0);
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() { async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() {
@@ -1907,6 +1907,124 @@ fn test_is_transient_rebalance_error_accepts_wrapped_disk_timeout() {
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout)))); assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout))));
} }
#[test]
fn test_rebalance_stage_wrapped_transient_errors_remain_retryable() {
let cases = [
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
Error::SlowDown,
Error::ErasureReadQuorum,
Error::ErasureWriteQuorum,
Error::Io(std::io::Error::other(DiskError::Timeout)),
Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)),
];
for mut error in cases {
for depth in 0..=3 {
assert!(is_transient_rebalance_error(&error), "transient source lost at depth {depth}: {error:?}");
assert!(
should_defer_rebalance_entry_failure(&error),
"exhausted transient entries must be deferred"
);
assert!(should_retry_rebalance_listing(&error, 0, 3));
assert!(
!should_retry_rebalance_listing(&error, 2, 3),
"wrapping must not bypass the attempt limit"
);
error = data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
error,
);
}
}
}
#[test]
fn test_rebalance_stage_wrapped_terminal_errors_remain_terminal() {
let cases = [
Error::FileAccessDenied,
Error::FileCorrupt,
Error::OperationCanceled,
Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string()),
Error::Lock(rustfs_lock::LockError::already_locked("bucket/object", "owner")),
Error::other("permission denied"),
];
for mut error in cases {
for depth in 0..=3 {
assert!(
!is_transient_rebalance_error(&error),
"terminal source must survive depth {depth}: {error:?}"
);
assert!(!should_defer_rebalance_entry_failure(&error));
// Object names are untrusted context, not evidence of a transient failure.
error = data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"remote lock rpc timed out",
error,
);
}
}
}
#[tokio::test]
async fn test_rebalance_stage_wrapped_lock_timeout_retries_real_migration_loop() {
for succeeds_on_retry in [true, false] {
let backend = MigrationBackendSpy::new(None, None);
let attempts = AtomicUsize::new(0);
let waits = AtomicUsize::new(0);
let mut transfer = |_, _, _| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if succeeds_on_retry && attempt > 0 {
return Ok(());
}
Err(data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
))
}
};
let version = version_normal();
let result = migrate_entry_version_with_retry_wait(
&backend,
"bucket".to_string(),
0,
&version,
None,
3,
false,
&mut transfer,
|_: String, _: String, _: ObjectOptions| async { Ok::<_, Error>(ObjectInfo::default()) },
|_| {
waits.fetch_add(1, Ordering::SeqCst);
std::future::ready(())
},
)
.await;
assert_eq!(result.moved, succeeds_on_retry);
assert_eq!(result.failed, !succeeds_on_retry);
assert_eq!(attempts.load(Ordering::SeqCst), if succeeds_on_retry { 2 } else { 3 });
assert_eq!(backend.get_calls(), attempts.load(Ordering::SeqCst));
assert_eq!(waits.load(Ordering::SeqCst), attempts.load(Ordering::SeqCst) - 1);
if !succeeds_on_retry {
assert_eq!(result.stage, Some("write_target"));
assert!(should_defer_rebalance_entry_failure(
result.error.as_ref().expect("exhaustion must retain its source error")
));
}
}
}
#[test] #[test]
fn test_is_transient_rebalance_error_accepts_io_timeout_message() { fn test_is_transient_rebalance_error_accepts_io_timeout_message() {
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout")))); assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout"))));
@@ -244,6 +244,7 @@ pub(super) fn resolve_rebalance_bucket_result(
} }
pub(super) fn is_transient_rebalance_error(err: &Error) -> bool { pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
let err = rebalance_error_source(err);
match err { match err {
Error::SlowDown Error::SlowDown
| Error::ErasureReadQuorum | Error::ErasureReadQuorum
@@ -256,6 +257,15 @@ pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
} }
} }
fn rebalance_error_source(mut err: &Error) -> &Error {
// Stage context contains object names, so classify the preserved source,
// not timeout-like text supplied by an object name. Iterate nested stages.
while let Some(source) = crate::data_movement::data_movement_stage_source(err) {
err = source;
}
err
}
fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool { fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
match err { match err {
rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true, rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true,
@@ -309,6 +319,7 @@ pub(super) fn rebalance_listing_retry_delay(attempt: usize) -> Duration {
} }
fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool { fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
let err = rebalance_error_source(err);
match err { match err {
Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true, Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true,
Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()), Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()),
@@ -585,3 +596,48 @@ impl SetDisks {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod error_source_tests {
use super::*;
#[test]
fn stage_wrapped_errors_select_the_source_backoff_policy() {
let cases = [
(
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
true,
),
(
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
true,
),
(Error::other("remote lock rpc timed out"), true),
(Error::SlowDown, false),
(Error::Io(std::io::Error::other(DiskError::Timeout)), false),
(Error::FileAccessDenied, false),
];
for (mut error, lock_backoff) in cases {
for depth in 0..=3 {
assert_eq!(
is_rebalance_lock_or_rpc_timeout(&error),
lock_backoff,
"wrong backoff at depth {depth}: {error:?}"
);
if !lock_backoff {
assert_eq!(rebalance_migration_retry_delay(1, &error), REBALANCE_MIGRATION_RETRY_BASE_DELAY * 2);
}
error = crate::data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"remote lock rpc timed out",
error,
);
}
}
}
}
+23 -2
View File
@@ -653,6 +653,26 @@ impl MockWarmBackend {
#[async_trait] #[async_trait]
impl WarmBackend for MockWarmBackend { impl WarmBackend for MockWarmBackend {
async fn probe_legacy_metadata(
&self,
object: &str,
remote_version: Option<&str>,
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
use super::warm_backend::LegacyTransitionStateProbe as Probe;
let candidate = match remote_version {
Some(version) if !version.is_empty() => self.probe_transition_version(object, version).await?,
_ => self.probe_transition_candidate(object).await?,
};
Ok(match candidate {
TransitionCandidateProbe::Missing => Probe::Missing,
TransitionCandidateProbe::UnversionedPresent => Probe::UnversionedPresent,
TransitionCandidateProbe::VersionedPresent(version) if version == "null" => Probe::SuspendedNullPresent,
TransitionCandidateProbe::VersionedPresent(version) => Probe::VersionedPresent(version),
TransitionCandidateProbe::Ambiguous => Probe::Ambiguous,
TransitionCandidateProbe::Unsupported => Probe::Unsupported,
})
}
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> { fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
if remote_version_id.is_empty() { if remote_version_id.is_empty() {
return Ok(()); return Ok(());
@@ -874,8 +894,9 @@ pub async fn register_mock_tier_backend(handle: &Arc<RwLock<TierConfigMgr>>, tie
..Default::default() ..Default::default()
}, },
); );
tier_config_mgr drop(tier_config_mgr);
.install_test_driver(tier_name, Box::new(backend)) TierConfigMgr::install_test_driver_in(handle, tier_name, Box::new(backend))
.await
.expect("mock tier driver should install"); .expect("mock tier driver should install");
} }
+42
View File
@@ -2322,6 +2322,14 @@ struct SharedWarmBackendProxy(SharedWarmBackend);
#[async_trait::async_trait] #[async_trait::async_trait]
impl WarmBackend for SharedWarmBackendProxy { impl WarmBackend for SharedWarmBackendProxy {
async fn probe_legacy_metadata(
&self,
object: &str,
remote_version: Option<&str>,
) -> io::Result<crate::services::tier::warm_backend::LegacyTransitionStateProbe> {
self.0.probe_legacy_metadata(object, remote_version).await
}
async fn validate(&self) -> io::Result<()> { async fn validate(&self) -> io::Result<()> {
self.0.validate().await self.0.validate().await
} }
@@ -2490,6 +2498,27 @@ impl TierOperationLease {
self.inner.driver.probe_transition_version(object, remote_version_id).await self.inner.driver.probe_transition_version(object, remote_version_id).await
} }
pub(crate) async fn probe_legacy_transition_state(
&self,
object: &str,
remote_version: Option<&str>,
) -> io::Result<crate::services::tier::warm_backend::LegacyTransitionStateProbe> {
let Some(reconciler) = self
.inner
.reconciler
.get_or_try_init(|| async {
crate::services::tier::warm_backend::new_transition_candidate_reconciler(&self.inner.tier_config)
.await
.map(|reconciler| reconciler.map(Arc::from))
})
.await
.map_err(|err| io::Error::other(err.message))?
else {
return self.inner.driver.probe_legacy_metadata(object, remote_version).await;
};
reconciler.probe_legacy_transition_state(object, remote_version).await
}
pub(crate) fn is_current_generation(&self) -> bool { pub(crate) fn is_current_generation(&self) -> bool {
lock_unpoisoned(&self.runtime) lock_unpoisoned(&self.runtime)
.generations .generations
@@ -6013,6 +6042,19 @@ impl TierConfigMgr {
Ok(()) Ok(())
} }
#[cfg(any(test, feature = "test-util"))]
pub(crate) async fn install_test_driver_in(
handle: &Arc<RwLock<Self>>,
tier_name: &str,
driver: WarmBackendImpl,
) -> std::result::Result<(), AdminError> {
let mut manager = handle.write().await;
// Register the generation runtime before installing the mock so its
// explicit lack of a network reconciler survives the first lease.
tier_driver_runtime(handle, &manager);
manager.install_test_driver(tier_name, driver)
}
#[cfg(any(test, feature = "test-util"))] #[cfg(any(test, feature = "test-util"))]
pub(crate) fn install_test_driver( pub(crate) fn install_test_driver(
&mut self, &mut self,
@@ -89,6 +89,18 @@ pub enum TransitionCandidateProbe {
Unsupported, Unsupported,
} }
/// Live evidence for repairing legacy metadata. Ordinary candidate GETs do not
/// establish the bucket's versioning model and cannot supply this authority.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LegacyTransitionStateProbe {
Missing,
UnversionedPresent,
SuspendedNullPresent,
VersionedPresent(String),
Ambiguous,
Unsupported,
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(crate) struct TransitionCandidateIdentity { pub(crate) struct TransitionCandidateIdentity {
pub transaction_id: uuid::Uuid, pub transaction_id: uuid::Uuid,
@@ -97,6 +109,14 @@ pub(crate) struct TransitionCandidateIdentity {
#[async_trait::async_trait] #[async_trait::async_trait]
pub(crate) trait TransitionCandidateReconciler { pub(crate) trait TransitionCandidateReconciler {
async fn probe_legacy_transition_state(
&self,
_object: &str,
_remote_version: Option<&str>,
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
Ok(LegacyTransitionStateProbe::Unsupported)
}
async fn probe_transition_candidate_for( async fn probe_transition_candidate_for(
&self, &self,
object: &str, object: &str,
@@ -106,6 +126,14 @@ pub(crate) trait TransitionCandidateReconciler {
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait WarmBackend { pub trait WarmBackend {
async fn probe_legacy_metadata(
&self,
_object: &str,
_remote_version: Option<&str>,
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
Ok(LegacyTransitionStateProbe::Unsupported)
}
async fn validate(&self) -> Result<(), std::io::Error> { async fn validate(&self) -> Result<(), std::io::Error> {
Ok(()) Ok(())
} }
@@ -448,6 +476,18 @@ impl MeteredWarmBackend {
#[async_trait::async_trait] #[async_trait::async_trait]
impl WarmBackend for MeteredWarmBackend { impl WarmBackend for MeteredWarmBackend {
async fn probe_legacy_metadata(
&self,
object: &str,
remote_version: Option<&str>,
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
let result = self.inner.probe_legacy_metadata(object, remote_version).await;
if matches!(result, Ok(LegacyTransitionStateProbe::Unsupported)) {
return result;
}
Self::record(TierRequestOperation::Probe, result)
}
/// Delegated without a counter: only one backend issues a remote request /// Delegated without a counter: only one backend issues a remote request
/// here, and every other one takes the trait default, so a `validate` /// here, and every other one takes the trait default, so a `validate`
/// counter would mostly record requests that never happened. /// counter would mostly record requests that never happened.
@@ -524,6 +564,18 @@ struct MeteredTransitionCandidateReconciler {
#[async_trait::async_trait] #[async_trait::async_trait]
impl TransitionCandidateReconciler for MeteredTransitionCandidateReconciler { impl TransitionCandidateReconciler for MeteredTransitionCandidateReconciler {
async fn probe_legacy_transition_state(
&self,
object: &str,
remote_version: Option<&str>,
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
let result = self.inner.probe_legacy_transition_state(object, remote_version).await;
if matches!(result, Ok(LegacyTransitionStateProbe::Unsupported)) {
return result;
}
MeteredWarmBackend::record(TierRequestOperation::Probe, result)
}
async fn probe_transition_candidate_for( async fn probe_transition_candidate_for(
&self, &self,
object: &str, object: &str,
@@ -101,6 +101,19 @@ impl WarmBackend for WarmBackendMinIO {
#[async_trait::async_trait] #[async_trait::async_trait]
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendMinIO { impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendMinIO {
async fn probe_legacy_transition_state(
&self,
object: &str,
remote_version: Option<&str>,
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_legacy_transition_state(
&self.0,
object,
remote_version,
)
.await
}
async fn probe_transition_candidate_for( async fn probe_transition_candidate_for(
&self, &self,
object: &str, object: &str,
@@ -146,6 +146,19 @@ impl WarmBackend for WarmBackendRustFS {
#[async_trait::async_trait] #[async_trait::async_trait]
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendRustFS { impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendRustFS {
async fn probe_legacy_transition_state(
&self,
object: &str,
remote_version: Option<&str>,
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_legacy_transition_state(
&self.0,
object,
remote_version,
)
.await
}
async fn probe_transition_candidate_for( async fn probe_transition_candidate_for(
&self, &self,
object: &str, object: &str,
@@ -376,7 +376,6 @@ struct TransitionCandidateVersions {
} }
impl TransitionCandidateVersions { impl TransitionCandidateVersions {
#[cfg(test)]
fn extend(&mut self, remote_object: &str, versions: &ListVersionsResult) { fn extend(&mut self, remote_object: &str, versions: &ListVersionsResult) {
for version in versions.versions.iter().filter(|version| version.key == remote_object) { for version in versions.versions.iter().filter(|version| version.key == remote_object) {
if self.version_id.is_some() { if self.version_id.is_some() {
@@ -512,6 +511,20 @@ mod tests {
} }
async fn candidate_probe_fixture() -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> { async fn candidate_probe_fixture() -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
scripted_probe_fixture([
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nx-amz-version-id: opaque-version\r\nConnection: close\r\n\r\nx",
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nConnection: close\r\n\r\nx",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 66\r\nConnection: close\r\n\r\n<Error><Code>NoSuchObject</Code><Message>missing</Message></Error>",
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/xml\r\nContent-Length: 65\r\nConnection: close\r\n\r\n<Error><Code>AccessDenied</Code><Message>denied</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Type: application/xml\r\nContent-Length: 72\r\nConnection: close\r\n\r\n<Error><Code>InvalidRange</Code><Message>empty version</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 67\r\nConnection: close\r\n\r\n<Error><Code>NoSuchVersion</Code><Message>missing</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
].into_iter().map(str::to_owned).collect()).await
}
async fn scripted_probe_fixture(responses: Vec<String>) -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await { let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener, Ok(listener) => listener,
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
@@ -522,17 +535,6 @@ mod tests {
.expect("listener local address should be available") .expect("listener local address should be available")
.to_string(); .to_string();
let fixture = tokio::spawn(async move { let fixture = tokio::spawn(async move {
let responses = [
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nx-amz-version-id: opaque-version\r\nConnection: close\r\n\r\nx",
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nConnection: close\r\n\r\nx",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 66\r\nConnection: close\r\n\r\n<Error><Code>NoSuchObject</Code><Message>missing</Message></Error>",
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/xml\r\nContent-Length: 65\r\nConnection: close\r\n\r\n<Error><Code>AccessDenied</Code><Message>denied</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Type: application/xml\r\nContent-Length: 72\r\nConnection: close\r\n\r\n<Error><Code>InvalidRange</Code><Message>empty version</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 67\r\nConnection: close\r\n\r\n<Error><Code>NoSuchVersion</Code><Message>missing</Message></Error>",
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
];
let mut requests = Vec::new(); let mut requests = Vec::new();
for response in responses { for response in responses {
let (mut stream, _) = listener.accept().await.expect("fixture should accept candidate GET"); let (mut stream, _) = listener.accept().await.expect("fixture should accept candidate GET");
@@ -673,6 +675,117 @@ mod tests {
assert!(requests[8].to_ascii_lowercase().contains("?versionid=historical-version")); assert!(requests[8].to_ascii_lowercase().contains("?versionid=historical-version"));
} }
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}",
body.len()
)
}
fn legacy_probe_versioning_response(status: &str) -> String {
let state = if status.is_empty() {
String::new()
} else {
format!("<Status>{status}</Status>")
};
legacy_probe_xml_response(&format!(
"<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">{state}</VersioningConfiguration>"
))
}
fn legacy_probe_versions_response(versions: &[&str]) -> String {
let versions = versions.iter().map(|version| format!(
"<Version><Key>archive/object</Key><VersionId>{version}</VersionId><IsLatest>true</IsLatest><LastModified>2026-09-01T00:00:00Z</LastModified><ETag>\"legacy-etag\"</ETag><Size>7</Size><StorageClass>STANDARD</StorageClass></Version>"
)).collect::<String>();
legacy_probe_xml_response(&format!(
"<ListVersionsResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><Name>bucket</Name><Prefix>archive/object</Prefix><KeyMarker/><VersionIdMarker/><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated>{versions}</ListVersionsResult>"
))
}
#[tokio::test]
async fn legacy_transition_state_probe_verifies_disabled_suspended_and_enabled_responses() {
use super::super::warm_backend::LegacyTransitionStateProbe as Probe;
for (initial, confirmed, version, expected) in [
("", "", "null", Probe::UnversionedPresent),
("Suspended", "Suspended", "null", Probe::SuspendedNullPresent),
("Enabled", "Enabled", "version-a", Probe::VersionedPresent("version-a".to_string())),
("Enabled", "Enabled", "null", Probe::SuspendedNullPresent),
("", "", "unexpected-version", Probe::Ambiguous),
("Suspended", "Enabled", "null", Probe::Ambiguous),
] {
let responses = vec![
legacy_probe_versioning_response(initial),
legacy_probe_versions_response(&[version]),
legacy_probe_versioning_response(confirmed),
];
let (backend, fixture) = scripted_probe_fixture(responses)
.await
.expect("legacy probe loopback fixture");
let result =
tokio::time::timeout(Duration::from_secs(10), backend.probe_legacy_transition_state("archive/object", None))
.await
.expect("legacy probe must finish")
.expect("legacy probe should decode provider XML");
assert_eq!(result, expected, "initial={initial} confirmed={confirmed} version={version}");
let requests = fixture.await.expect("legacy probe fixture should finish");
assert_eq!(requests.len(), 3);
assert!(requests.iter().all(|request| request.starts_with("GET ")));
assert!(requests[0].lines().next().expect("request line").contains("versioning"));
assert!(requests[1].lines().next().expect("request line").contains("versions"));
assert!(requests[2].lines().next().expect("request line").contains("versioning"));
}
}
#[tokio::test]
async fn legacy_transition_state_probe_preserves_historical_exact_version() {
use super::super::warm_backend::LegacyTransitionStateProbe as Probe;
let responses = vec![
legacy_probe_versioning_response("Enabled"),
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nx-amz-version-id: historical-version\r\nConnection: close\r\n\r\nx".to_string(),
legacy_probe_versioning_response("Enabled"),
];
let (backend, fixture) = scripted_probe_fixture(responses).await.expect("exact legacy probe fixture");
assert_eq!(
backend
.probe_legacy_transition_state("archive/object", Some("historical-version"))
.await
.expect("exact version proof"),
Probe::VersionedPresent("historical-version".to_string())
);
let requests = fixture.await.expect("exact probe fixture should finish");
assert!(
requests[1]
.lines()
.next()
.expect("request line")
.contains("versionId=historical-version")
);
assert!(requests[1].to_ascii_lowercase().contains("range: bytes=0-0"));
assert!(requests.iter().all(|request| request.starts_with("GET ")));
}
#[tokio::test]
async fn legacy_transition_state_probe_retains_multiple_candidates() {
use super::super::warm_backend::LegacyTransitionStateProbe as Probe;
let responses = vec![
legacy_probe_versioning_response("Enabled"),
legacy_probe_versions_response(&["version-a", "version-b"]),
];
let (backend, fixture) = scripted_probe_fixture(responses)
.await
.expect("ambiguous legacy probe fixture");
assert_eq!(
backend
.probe_legacy_transition_state("archive/object", None)
.await
.expect("ambiguous proof"),
Probe::Ambiguous
);
let requests = fixture.await.expect("ambiguous fixture should finish");
assert_eq!(requests.len(), 2);
assert!(requests.iter().all(|request| request.starts_with("GET ")));
}
fn list_versions(versions: &[(&str, &str)], delete_markers: &[(&str, &str)], is_truncated: bool) -> ListVersionsResult { fn list_versions(versions: &[(&str, &str)], delete_markers: &[(&str, &str)], is_truncated: bool) -> ListVersionsResult {
ListVersionsResult { ListVersionsResult {
versions: versions versions: versions
@@ -921,6 +1034,56 @@ impl WarmBackend for WarmBackendS3 {
#[async_trait::async_trait] #[async_trait::async_trait]
impl TransitionCandidateReconciler for WarmBackendS3 { impl TransitionCandidateReconciler for WarmBackendS3 {
async fn probe_legacy_transition_state(
&self,
object: &str,
remote_version: Option<&str>,
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
use super::warm_backend::LegacyTransitionStateProbe as Probe;
let initial_versioning = self.remote_bucket_versioning().await?;
let candidate = if let Some(version) = remote_version.filter(|version| !version.is_empty()) {
validate_remote_version_id(version)?;
match self.probe_transition_version(object, version).await? {
TransitionCandidateProbe::VersionedPresent(actual) if actual == version => Some(actual),
TransitionCandidateProbe::Missing => return Ok(Probe::Missing),
_ => return Ok(Probe::Ambiguous),
}
} else {
let remote_object = self.get_dest(object);
let mut opts = ListObjectsOptions::default();
opts.set("prefix", &remote_object);
opts.set("max-keys", "1000");
let mut key_marker = String::new();
let mut version_marker = String::new();
let mut candidates = TransitionCandidateVersions::default();
let mut complete = false;
// This is one synchronous record inspection, not an unbounded
// remote history scan. The caller also bounds the whole probe.
for _ in 0..128 {
let page = self
.client
.list_object_versions_query(&self.bucket, &opts, &key_marker, &version_marker, "")
.await?;
candidates.extend(&remote_object, &page);
if candidates.ambiguous {
return Ok(Probe::Ambiguous);
}
if !page.is_truncated {
complete = true;
break;
}
advance_version_markers(&mut key_marker, &mut version_marker, &page)?;
}
if !complete {
return Ok(Probe::Ambiguous);
}
candidates.version_id
};
let confirmed_versioning = self.remote_bucket_versioning().await?;
classify_legacy_transition_state(candidate.as_deref(), initial_versioning, confirmed_versioning)
}
async fn probe_transition_candidate_for( async fn probe_transition_candidate_for(
&self, &self,
object: &str, object: &str,
@@ -931,3 +1094,32 @@ impl TransitionCandidateReconciler for WarmBackendS3 {
.await .await
} }
} }
fn classify_legacy_transition_state(
candidate: Option<&str>,
initial: RemoteBucketVersioning,
confirmed: RemoteBucketVersioning,
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
use super::warm_backend::LegacyTransitionStateProbe as Probe;
if initial != confirmed {
return Ok(Probe::Ambiguous);
}
let Some(version) = candidate else {
return Ok(Probe::Missing);
};
if !version.is_empty() {
validate_remote_version_id(version)?;
if uuid::Uuid::parse_str(version).is_ok_and(|id| id.is_nil()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy tier probe returned a nil version identifier",
));
}
}
Ok(match (confirmed, version) {
(RemoteBucketVersioning::Disabled, "" | "null") => Probe::UnversionedPresent,
(RemoteBucketVersioning::Disabled, _) | (_, "") => Probe::Ambiguous,
(_, "null") => Probe::SuspendedNullPresent,
(_, version) => Probe::VersionedPresent(version.to_string()),
})
}
+42 -7
View File
@@ -326,14 +326,15 @@ impl SetDisks {
let parity_blocks = Self::common_parity(&parities, default_parity_count as i32); let parity_blocks = Self::common_parity(&parities, default_parity_count as i32);
if parity_blocks < 0 { if parity_blocks < 0 {
// No parity value reached read quorum. Distinguish two cases: // A consistent layout can require more replies than the initial
// enough disks answered with valid-looking metadata that simply // half-set probe. Reaching that probe alone is not corruption;
// cannot be reconciled (corrupt/foreign entries — retrying cannot // only invalid or conflicting healthy replies establish that.
// help, and heal should see Corrupt, rustfs#5801) versus too few
// healthy answers (a genuine quorum condition where retry may
// succeed once disks recover).
let healthy_replies = errs.iter().filter(|err| err.is_none()).count(); let healthy_replies = errs.iter().filter(|err| err.is_none()).count();
if healthy_replies >= expected_rquorum { let consistent_parity = parities
.iter()
.find(|&&parity| parity >= 0)
.filter(|&&parity| parities.iter().filter(|&&candidate| candidate == parity).count() == healthy_replies);
if healthy_replies >= expected_rquorum && consistent_parity.is_none() {
error!( error!(
"object_quorum_from_meta: irreconcilable parity across {healthy_replies} healthy replies (corrupt metadata), errs={errs:?}" "object_quorum_from_meta: irreconcilable parity across {healthy_replies} healthy replies (corrupt metadata), errs={errs:?}"
); );
@@ -1652,6 +1653,40 @@ mod tests {
assert_eq!(err, DiskError::FileCorrupt); assert_eq!(err, DiskError::FileCorrupt);
} }
#[test]
fn consistent_parity_below_its_data_shard_quorum_is_not_corruption() {
for (drive_count, parity) in [(6, 2), (8, 2), (12, 4)] {
let data = drive_count - parity;
let mut metas = (1..=drive_count)
.map(|index| {
let mut info = FileInfo::new("bucket/object", data, parity);
info.size = 1024;
info.erasure.index = index;
info
})
.collect::<Vec<_>>();
let mut errs = vec![Some(DiskError::DiskNotFound); drive_count];
errs[..data].fill(None);
assert_eq!(
SetDisks::object_quorum_from_meta(&metas, &errs, parity).expect("exact data quorum should resolve"),
(data as i32, data as i32)
);
errs[data - 1] = Some(DiskError::DiskNotFound);
assert_eq!(
SetDisks::object_quorum_from_meta(&metas, &errs, parity).expect_err("one fewer shard cannot resolve"),
DiskError::ErasureReadQuorum,
"layout {drive_count}/{parity} has consistent metadata but insufficient shards"
);
metas[0].erasure.parity_blocks = usize::MAX;
assert_eq!(
SetDisks::object_quorum_from_meta(&metas, &errs, parity).expect_err("corrupt healthy replies must be rejected"),
DiskError::FileCorrupt
);
}
}
/// Too few healthy replies remains a genuine quorum condition where a /// Too few healthy replies remains a genuine quorum condition where a
/// retry may succeed once disks recover. /// retry may succeed once disks recover.
#[test] #[test]
+26
View File
@@ -865,6 +865,7 @@ pub(crate) use core::io_primitives::{ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, ren
mod ctx; mod ctx;
mod metadata; mod metadata;
mod ops; mod ops;
pub(crate) use ops::bucket::BucketInfoQuorum;
#[cfg(test)] #[cfg(test)]
pub(crate) use ops::hermetic_set_disks_isolated; pub(crate) use ops::hermetic_set_disks_isolated;
@@ -3888,6 +3889,31 @@ pub struct SetDisks {
>, >,
} }
/// 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`;
/// an unreadable disk cannot prove that no conflicting copy exists.
pub(crate) async fn read_legacy_transition_state_metadata_copies(
set: &SetDisks,
bucket: &str,
object: &str,
) -> std::result::Result<Vec<Option<Vec<u8>>>, DiskError> {
let disk_object = rustfs_utils::path::encode_dir_object(object);
let disks = set.get_disks_internal().await;
if disks.is_empty() {
return Err(DiskError::DiskNotFound);
}
let (copies, errs) = SetDisks::read_all_raw_file_info(&disks, bucket, disk_object.as_str(), false).await;
for err in errs.into_iter().flatten() {
if !matches!(err, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::VolumeNotFound) {
return Err(err);
}
}
Ok(copies.into_iter().map(|copy| copy.map(|copy| copy.buf)).collect())
}
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones // DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
// each endpoint's canonical Arc, so an exact Arc set identifies the lock domain. // each endpoint's canonical Arc, so an exact Arc set identifies the lock domain.
pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool { pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
+65 -52
View File
@@ -21,12 +21,72 @@
use super::super::{ use super::super::{
BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap, BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap,
MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_write_quorum_errs, MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_read_quorum_errs,
reduce_write_quorum_errs,
}; };
use crate::api::bucket::metadata_sys; use crate::api::bucket::metadata_sys;
use crate::disk::DiskAPI; use crate::disk::DiskAPI;
#[derive(Clone, Copy)]
pub(crate) enum BucketInfoQuorum {
Read,
Write,
}
impl SetDisks { impl SetDisks {
pub(crate) async fn stat_bucket_with_quorum(&self, bucket: &str, quorum: BucketInfoQuorum) -> Result<BucketInfo> {
let disks = self.disk_inventory().await;
let disk_count = disks.len();
let mut futures = Vec::with_capacity(disk_count);
for disk in disks {
let bucket = bucket.to_string();
futures.push(async move {
match disk {
Some(disk) => disk.stat_volume(&bucket).await,
None => Err(DiskError::DiskNotFound),
}
});
}
let results = join_all(futures).await;
let mut infos = Vec::with_capacity(results.len());
let mut errs = Vec::with_capacity(results.len());
for result in results {
match result {
Ok(info) => {
infos.push(Some(info));
errs.push(None);
}
Err(err) => {
infos.push(None);
errs.push(Some(err));
}
}
}
let error = match quorum {
// Bucket mutations use a majority regardless of object storage
// class. A namespace read must intersect that majority; object
// readers still enforce the persisted layout's data-shard quorum.
BucketInfoQuorum::Read => reduce_read_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count.div_ceil(2).max(1)),
BucketInfoQuorum::Write => reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count / 2 + 1),
};
if let Some(err) = error {
return Err(err.into());
}
infos
.into_iter()
.flatten()
.next()
.map(|info| BucketInfo {
name: info.name,
created: info.created,
..Default::default()
})
.ok_or(Error::VolumeNotFound)
}
pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> { pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> {
let disks = self.disk_inventory().await; let disks = self.disk_inventory().await;
let write_quorum = (disks.len() / 2) + 1; let write_quorum = (disks.len() / 2) + 1;
@@ -131,59 +191,12 @@ impl BucketOperations for SetDisks {
#[tracing::instrument(skip(self))] #[tracing::instrument(skip(self))]
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> { async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
let disks = self.disk_inventory().await; let mut info = self.stat_bucket_with_quorum(bucket, BucketInfoQuorum::Write).await?;
let write_quorum = (disks.len() / 2) + 1;
let mut futures = Vec::with_capacity(disks.len());
for disk in disks {
let bucket = bucket.to_string();
futures.push(async move {
match disk {
Some(disk) => disk.stat_volume(&bucket).await,
None => Err(DiskError::DiskNotFound),
}
});
}
let results = join_all(futures).await;
let mut infos = Vec::with_capacity(results.len());
let mut errs = Vec::with_capacity(results.len());
for result in results {
match result {
Ok(info) => {
infos.push(Some(info));
errs.push(None);
}
Err(err) => {
infos.push(None);
errs.push(Some(err));
}
}
}
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
return Err(err.into());
}
let mut versioning = false;
let mut object_locking = false;
if let Ok(sys) = metadata_sys::get(bucket).await { if let Ok(sys) = metadata_sys::get(bucket).await {
versioning = sys.versioning(); info.versioning = sys.versioning();
object_locking = sys.object_locking(); info.object_locking = sys.object_locking();
} }
Ok(info)
infos
.into_iter()
.flatten()
.next()
.map(|info| BucketInfo {
name: info.name,
created: info.created,
versioning,
object_locking,
..Default::default()
})
.ok_or(Error::VolumeNotFound)
} }
#[tracing::instrument(skip(self))] #[tracing::instrument(skip(self))]
+322 -6
View File
@@ -9209,6 +9209,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let dest_obj = transaction.remote_object.clone(); let dest_obj = transaction.remote_object.clone();
let mut transition_meta = (*oi.user_defined).clone(); let mut transition_meta = (*oi.user_defined).clone();
rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS); rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
// The tier holds opaque stored bytes. Its metadata must not be treated
// as a second object header set: forwarding SSE intent or wrapped DEKs
// would request a second encryption pass and disclose local envelope
// material to the remote provider.
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
transition_meta.insert("name".to_string(), object.to_string()); transition_meta.insert("name".to_string(), object.to_string());
rustfs_utils::http::metadata_compat::insert_str( rustfs_utils::http::metadata_compat::insert_str(
&mut transition_meta, &mut transition_meta,
@@ -9765,13 +9770,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
part_opts.part_number = Some(part_info.number); part_opts.part_number = Some(part_info.number);
#[cfg(test)] #[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?; fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?;
if part_info.actual_size <= 0 { if part_info.size == 0 {
return Err(Error::other(format!("invalid multipart restore part size {}", part_info.actual_size))); return Err(Error::other(format!("invalid multipart restore stored part size {}", part_info.size)));
} }
let stored_part_size = i64::try_from(part_info.size).map_err(|_| {
Error::other(format!("multipart restore stored part size exceeds i64: {}", part_info.size))
})?;
#[cfg(test)] #[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?; fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?;
let part_end = part_offset let part_end = part_offset
.checked_add(part_info.actual_size - 1) .checked_add(stored_part_size - 1)
.ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?; .ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?;
let rs = Some(HTTPRangeSpec { let rs = Some(HTTPRangeSpec {
is_suffix_length: false, is_suffix_length: false,
@@ -9799,13 +9807,19 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[cfg(test)] #[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?; fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?;
let hash_reader = let hash_reader =
HashReader::from_stream(reader, part_info.actual_size, part_info.actual_size, None, None, false)?; HashReader::from_stream(reader, stored_part_size, part_info.actual_size, None, None, false)?;
let mut p_reader = PutObjReader::new(hash_reader); let mut p_reader = PutObjReader::new(hash_reader);
#[cfg(test)] #[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?; fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?;
// `ropts` carries the object's ETag so the single-part copy-back
// keeps it (the writer only ever sees stored bytes). A part write
// must not inherit that object-level value, or every restored part
// would be recorded under the same ETag; each part keeps its own.
let mut part_write_opts = ropts.clone();
part_write_opts.preserve_etag = Some(part_info.etag.clone()).filter(|etag| !etag.is_empty());
let p_info = self_ let p_info = self_
.clone() .clone()
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &ropts) .put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &part_write_opts)
.await?; .await?;
#[cfg(test)] #[cfg(test)]
let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) { let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) {
@@ -9815,7 +9829,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
} else { } else {
p_info p_info
}; };
if p_info.size as i64 != part_info.actual_size { if p_info.size as i64 != stored_part_size {
return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError { return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError {
bucket: bucket.to_string(), bucket: bucket.to_string(),
object: object.to_string(), object: object.to_string(),
@@ -9847,6 +9861,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
user_defined: restore_commit_metadata, user_defined: restore_commit_metadata,
no_lock: false, no_lock: false,
decommission_capacity_admission: opts.decommission_capacity_admission.clone(), decommission_capacity_admission: opts.decommission_capacity_admission.clone(),
// The composite ETag would otherwise be recomputed from the
// parts as they were written back, which for an encrypted or
// compressed object digests stored bytes rather than the
// object's public ETag.
preserve_etag: oi.etag.clone(),
..Default::default() ..Default::default()
}; };
self_ self_
@@ -13488,6 +13507,303 @@ mod transition_commit_failure_tests {
); );
} }
/// backlog#2368 B5: the tier stores opaque bytes, so the archive request
/// must not carry the object's encryption headers. Forwarding them made
/// every S3 target reject an SSE-C archive outright, asked the target to
/// encrypt an SSE-KMS object a second time under a key id it does not own,
/// and handed the wrapped DEK to a third-party provider.
#[tokio::test]
#[serial_test::serial]
async fn transition_does_not_forward_encryption_metadata_to_the_tier() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "transition-encryption-metadata-bucket";
let object = "object.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let encryption_metadata = [
("x-amz-server-side-encryption", "aws:kms"),
("x-amz-server-side-encryption-aws-kms-key-id", "arn:aws:kms:us-east-1:123:key/abc"),
("x-amz-server-side-encryption-customer-algorithm", "AES256"),
(rustfs_utils::http::INTERNAL_ENCRYPTION_KEY_HEADER, "d3JhcHBlZC1kZWs="),
(rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER, "AAAAAAAAAAAAAAAA"),
(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER, "AES256"),
];
let mut user_defined: HashMap<String, String> = encryption_metadata
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
user_defined,
..Default::default()
},
)
.await
.expect("the encrypted source object should be written");
let original = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the source object should be readable");
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
set_disks
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("the encrypted object should transition");
let transitioned = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the transitioned object should be readable");
let remote_metadata = backend
.metadata(&transitioned.transitioned_object.name)
.await
.expect("the tier must have received the object");
for (key, _) in encryption_metadata {
assert!(
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
"transition must not forward {key} to the tier: {remote_metadata:?}"
);
}
assert!(
remote_metadata
.iter()
.any(|(key, value)| key.eq_ignore_ascii_case("x-amz-meta-owner") && value == "finance"),
"ordinary user metadata must still travel to the tier: {remote_metadata:?}"
);
// Read-through and restore both resolve encryption locally, so the
// stripped keys must survive untouched in the local metadata.
for (key, value) in encryption_metadata {
assert_eq!(
transitioned.user_defined.get(key).map(String::as_str),
Some(value),
"the local copy must keep {key}"
);
}
}
/// Deterministic bytes that do not repeat with a short period, so a slice
/// taken at the wrong offset cannot coincidentally compare equal.
fn stored_representation_bytes(seed: u32, len: usize) -> Vec<u8> {
(0..len)
.map(|index| {
let mixed = (index as u32).wrapping_add(seed).wrapping_mul(2_654_435_761);
(mixed >> 13) as u8
})
.collect()
}
/// Compares two stored representations without dumping megabytes of bytes
/// into the failure output.
fn assert_stored_representation_eq(actual: &[u8], expected: &[u8], what: &str) {
assert_eq!(actual.len(), expected.len(), "{what}: stored length differs");
if let Some(offset) = actual.iter().zip(expected).position(|(left, right)| left != right) {
panic!(
"{what}: stored bytes differ at offset {offset} (found {:#04x}, expected {:#04x})",
actual[offset], expected[offset]
);
}
}
async fn read_stored_representation(set_disks: &Arc<SetDisks>, bucket: &str, object: &str) -> Vec<u8> {
let mut reader = set_disks
.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("stored-representation reader should open");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("stored body should drain");
body
}
/// backlog#2368 B3: the multipart restore loop addresses the tier in STORED
/// coordinates. Accumulating each part's PLAINTEXT length instead handed
/// every part a misaligned slice of the remote object whose length still
/// satisfied the range, the `HashReader` and the completion size check, so
/// the copy-back reported success while silently replacing the bytes.
///
/// The fixture reproduces the encrypted geometry — a stored form LONGER
/// than the plaintext it encodes — because that is what keeps a
/// plaintext-coordinate range inside the tier object and makes the
/// corruption silent rather than a short read.
#[tokio::test]
#[serial_test::serial]
async fn multipart_restore_copies_the_stored_representation_back_verbatim() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "restore-multipart-stored-coordinates-bucket";
let object = "object.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
// The minimum-part-size gate reads the PLAINTEXT length, so part one
// clears 5 MiB there while its stored form carries encoding overhead.
let part_shapes = [(6 * 1024 * 1024_usize, 9_216_usize), (256 * 1024_usize, 512_usize)];
let mut user_defined = HashMap::new();
user_defined.insert(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "AES256".to_string());
user_defined.insert(
rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
"AAAAAAAAAAAAAAAA".to_string(),
);
let upload = set_disks
.new_multipart_upload(
bucket,
object,
&ObjectOptions {
user_defined: user_defined.clone(),
..Default::default()
},
)
.await
.expect("multipart upload should be created");
let mut uploaded_parts = Vec::new();
let mut expected_stored = Vec::new();
for (index, (plaintext_len, overhead)) in part_shapes.iter().enumerate() {
let stored = stored_representation_bytes(index as u32 * 7 + 1, plaintext_len + overhead);
expected_stored.extend_from_slice(&stored);
let stored_len = stored.len() as i64;
let hash_reader =
HashReader::from_stream(std::io::Cursor::new(stored), stored_len, *plaintext_len as i64, None, None, false)
.expect("hash reader over the stored representation");
let mut reader = PutObjReader::new(hash_reader);
let info = set_disks
.put_object_part(bucket, object, &upload.upload_id, index + 1, &mut reader, &ObjectOptions::default())
.await
.expect("stored part should be staged");
assert_eq!(info.size as i64, stored_len, "a part is stored in its encoded length");
uploaded_parts.push(CompletePart {
part_num: info.part_num,
etag: info.etag,
..Default::default()
});
}
let original = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, uploaded_parts, &ObjectOptions::default())
.await
.expect("source multipart upload should complete");
let original_parts: Vec<(usize, usize, i64, String)> = original
.parts
.iter()
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
.collect();
for (_, size, actual_size, _) in &original_parts {
assert!(
*size as i64 > *actual_size,
"the fixture must keep the two coordinate systems apart: stored {size} vs plaintext {actual_size}"
);
}
let stored_before = read_stored_representation(&set_disks, bucket, object).await;
assert_stored_representation_eq(&stored_before, &expected_stored, "the fixture must store its encoded bytes verbatim");
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
set_disks
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("multipart source should transition before restore");
let operation_id = Uuid::new_v4();
set_disks
.put_object_metadata(
bucket,
object,
&ObjectOptions {
eval_metadata: Some(restore_metadata(operation_id, true)),
..Default::default()
},
)
.await
.expect("the restore generation should be installed");
let mut restore_opts = ObjectOptions::default();
restore_opts.transition.restore_request.days = Some(1);
restore_opts.user_defined = restore_operation_id_metadata(operation_id);
set_disks
.clone()
.restore_transitioned_object(bucket, object, &restore_opts)
.await
.expect("multipart restore should complete");
let stored_after = read_stored_representation(&set_disks, bucket, object).await;
assert_stored_representation_eq(
&stored_after,
&expected_stored,
"a multipart restore must copy the stored representation back verbatim",
);
let restored = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the restored object should be readable");
let restored_parts: Vec<(usize, usize, i64, String)> = restored
.parts
.iter()
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
.collect();
assert_eq!(
restored_parts, original_parts,
"restore must rebuild the same part layout, sizes and part ETags"
);
assert_eq!(restored.size, original.size, "restore must keep the stored object size");
// backlog#2369 P7.1: the copy-back digests stored bytes, so the object's
// public ETag has to be carried over rather than recomputed.
assert_eq!(restored.etag, original.etag, "restore must preserve the object ETag");
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() { async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() {
+403 -15
View File
@@ -19,7 +19,7 @@ use crate::bucket::{
}; };
use crate::error::is_err_bucket_not_found; use crate::error::is_err_bucket_not_found;
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
use crate::set_disk::get_lock_acquire_timeout; use crate::set_disk::{BucketInfoQuorum, get_lock_acquire_timeout};
use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp}; use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp};
use crate::storage_api_contracts::namespace::NamespaceLocking as _; use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use futures::stream::{self, StreamExt}; use futures::stream::{self, StreamExt};
@@ -770,19 +770,57 @@ impl ECStore {
Ok(()) Ok(())
} }
/// Prove a live bucket generation before repairing missing expansion volumes.
/// Unlike request validation, repair only needs one erasure set to confirm
/// existence; an incomplete expansion set is precisely what repair fixes.
/// Callers must hold the bucket namespace lock through the subsequent heal.
pub(crate) async fn bucket_exists_for_heal(&self, bucket: &str) -> Result<bool> {
let results = futures::future::join_all(
self.bucket_sets()
.map(|(_, _, set)| async move { set.get_bucket_info(bucket, &BucketOptions::default()).await }),
)
.await;
let mut first_error = None;
for result in results {
match result {
Ok(_) => return Ok(true),
Err(err) if is_err_strict_volume_not_found(&err) => {}
Err(err) if first_error.is_none() => first_error = Some(err),
Err(_) => {}
}
}
match first_error {
Some(err) => Err(err),
None => Ok(false),
}
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub(crate) async fn get_bucket_info_from_sets(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> { pub(crate) async fn get_bucket_info_from_sets(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
self.get_bucket_info_from_sets_with_quorum(bucket, opts, BucketInfoQuorum::Write)
.await
}
async fn get_bucket_info_from_sets_with_quorum(
&self,
bucket: &str,
opts: &BucketOptions,
quorum: BucketInfoQuorum,
) -> Result<BucketInfo> {
// One host may participate in several pools after expansion. Resolve the // One host may participate in several pools after expansion. Resolve the
// namespace against each erasure set so disks from different pools can // namespace against each erasure set so disks from different pools can
// never be combined into one bucket quorum. // never be combined into one bucket quorum.
// Bucket validation is request-path IO. Keep the previous peer fanout's // Bucket validation is request-path IO. Keep the previous peer fanout's
// latency shape by probing every set concurrently; scanner listings use // latency shape by probing every set concurrently; scanner listings use
// a separate bounded path below because they run continuously. // a separate bounded path below because they run continuously.
let mut scoped_results = let mut scoped_results = futures::future::join_all(self.bucket_sets().map(|(pool_index, set_index, set)| async move {
futures::future::join_all(self.bucket_sets().map(|(pool_index, set_index, set)| async move { let result = match quorum {
(pool_index, set_index, set.get_bucket_info(bucket, opts).await) BucketInfoQuorum::Read => set.stat_bucket_with_quorum(bucket, quorum).await,
})) BucketInfoQuorum::Write => set.get_bucket_info(bucket, opts).await,
.await; };
(pool_index, set_index, result)
}))
.await;
scoped_results.sort_unstable_by_key(|(pool_index, set_index, _)| (*pool_index, *set_index)); scoped_results.sort_unstable_by_key(|(pool_index, set_index, _)| (*pool_index, *set_index));
let mut first_info = None; let mut first_info = None;
@@ -806,7 +844,11 @@ impl ECStore {
#[instrument(skip(self))] #[instrument(skip(self))]
pub(super) async fn handle_get_bucket_info(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> { pub(super) async fn handle_get_bucket_info(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
let mut info = self.get_bucket_info_from_sets(bucket, opts).await?; let mut info = match self.get_bucket_info_from_sets(bucket, opts).await {
Ok(info) => info,
Err(Error::ErasureWriteQuorum) => return self.get_bucket_info_at_read_quorum(bucket, opts).await,
Err(err) => return Err(err),
};
if let Ok(sys) = metadata_sys::get_in(&self.ctx, bucket).await { if let Ok(sys) = metadata_sys::get_in(&self.ctx, bucket).await {
if should_override_created_from_metadata(sys.created) { if should_override_created_from_metadata(sys.created) {
@@ -819,6 +861,35 @@ impl ECStore {
Ok(info) Ok(info)
} }
async fn get_bucket_info_at_read_quorum(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
// Lock order: bucket lifecycle -> internal metadata object read locks.
// Keep create/delete from changing the namespace while a read quorum
// confirms both physical presence and persisted bucket metadata.
let guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
await_bucket_namespace_operation(Some(&guard), bucket, "bucket read quorum validation", async {
let mut info = self
.get_bucket_info_from_sets_with_quorum(bucket, opts, BucketInfoQuorum::Read)
.await?;
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
if !persisted {
// A minority of directories left by failed creation is not an
// authoritative bucket. Never turn fabricated defaults into
// permission to serve degraded reads.
return Err(Error::ErasureReadQuorum);
}
if metadata.name != bucket {
return Err(Error::FileCorrupt);
}
if should_override_created_from_metadata(metadata.created) {
info.created = Some(metadata.created);
}
info.versioning = metadata.versioning();
info.object_locking = metadata.object_locking();
Ok(info)
})
.await
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub(super) async fn handle_list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> { pub(super) async fn handle_list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
// TODO(backlog): support cached bucket listing via opts.cached // TODO(backlog): support cached bucket listing via opts.cached
@@ -1049,7 +1120,7 @@ mod tests {
run_physical_bucket_deletion, scan_metadata_less_residue, scan_metadata_less_residue_with_budget, run_physical_bucket_deletion, scan_metadata_less_residue, scan_metadata_less_residue_with_budget,
should_override_created_from_metadata, validate_table_bucket_delete_allowed, should_override_created_from_metadata, validate_table_bucket_delete_allowed,
}; };
use crate::bucket::metadata::table_bucket_catalog_metadata_prefix; use crate::bucket::metadata::{BucketMetadata, table_bucket_catalog_metadata_prefix};
use crate::bucket::metadata_sys; use crate::bucket::metadata_sys;
use crate::cluster::rpc::peer_s3_client::install_delete_bucket_empty_scan_barrier; use crate::cluster::rpc::peer_s3_client::install_delete_bucket_empty_scan_barrier;
use crate::disk::{BUCKET_META_PREFIX, DiskAPI, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE}; use crate::disk::{BUCKET_META_PREFIX, DiskAPI, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE};
@@ -1076,6 +1147,7 @@ mod tests {
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::io::AsyncReadExt;
use tokio::sync::{Notify, OnceCell}; use tokio::sync::{Notify, OnceCell};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
@@ -1359,11 +1431,18 @@ mod tests {
} }
async fn setup_multi_pool_bucket_test_env() -> (tempfile::TempDir, Arc<ECStore>) { async fn setup_multi_pool_bucket_test_env() -> (tempfile::TempDir, Arc<ECStore>) {
setup_bucket_quorum_test_env(&[4, 4], None).await
}
async fn setup_bucket_quorum_test_env(
drives_per_pool: &[usize],
standard_parity: Option<usize>,
) -> (tempfile::TempDir, Arc<ECStore>) {
let temp_dir = tempfile::tempdir().expect("multi-pool bucket test directory should be created"); let temp_dir = tempfile::tempdir().expect("multi-pool bucket test directory should be created");
let mut pools = Vec::new(); let mut pools = Vec::new();
for pool_index in 0..2 { for (pool_index, &drive_count) in drives_per_pool.iter().enumerate() {
let mut endpoints = Vec::new(); let mut endpoints = Vec::new();
for disk_index in 0..4 { for disk_index in 0..drive_count {
let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}")); let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}"));
tokio::fs::create_dir_all(&disk_path) tokio::fs::create_dir_all(&disk_path)
.await .await
@@ -1378,7 +1457,7 @@ mod tests {
pools.push(PoolEndpoints { pools.push(PoolEndpoints {
legacy: false, legacy: false,
set_count: 1, set_count: 1,
drives_per_set: 4, drives_per_set: drive_count,
endpoints: Endpoints::from(endpoints), endpoints: Endpoints::from(endpoints),
cmd_line: format!("bucket-test-pool-{pool_index}"), cmd_line: format!("bucket-test-pool-{pool_index}"),
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH), platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
@@ -1399,9 +1478,12 @@ mod tests {
) )
.await .await
.expect("multi-pool ECStore should initialize"); .expect("multi-pool ECStore should initialize");
let storage_class = let mut storage_class_kvs = rustfs_config::server_config::KVS::new();
crate::config::storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[4, 4]) if let Some(parity) = standard_parity {
.expect("multi-pool storage class should match both four-disk pools"); storage_class_kvs.insert(crate::config::storageclass::CLASS_STANDARD.to_string(), format!("EC:{parity}"));
}
let storage_class = crate::config::storageclass::lookup_config_for_pools_without_env(&storage_class_kvs, drives_per_pool)
.expect("storage class should match every test erasure set");
for pool in &ecstore.pools { for pool in &ecstore.pools {
for set in &pool.disk_set { for set in &pool.disk_set {
set.set_test_storage_class_config(storage_class.clone()); set.set_test_storage_class_config(storage_class.clone());
@@ -2050,6 +2132,98 @@ mod tests {
.expect("metadata initialization should recreate the bucket volume in the new pool"); .expect("metadata initialization should recreate the bucket volume in the new pool");
} }
#[tokio::test]
#[serial]
async fn bucket_metadata_init_repairs_half_created_expansion_pool() {
// Check both pool orders: an incomplete set must not hide a later
// complete set, and a complete set must not weaken request validation.
for complete_pool in 0..2 {
let (temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
let bucket = format!("partial-expansion-{}", Uuid::new_v4().simple());
for pool_index in 0..2 {
let present_disks = if pool_index == complete_pool { 4 } else { 2 };
for disk_index in 0..present_disks {
tokio::fs::create_dir(
temp_dir
.path()
.join(format!("pool{pool_index}-disk{disk_index}"))
.join(&bucket),
)
.await
.expect("fixture bucket volume should be created");
}
}
assert_eq!(
ecstore
.get_bucket_info_from_sets(&bucket, &BucketOptions::default())
.await
.expect_err("request validation must reject a half-created expansion set"),
StorageError::ErasureWriteQuorum
);
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), vec![bucket.clone()]).await;
for pool_index in 0..2 {
for disk_index in 0..4 {
assert!(
temp_dir
.path()
.join(format!("pool{pool_index}-disk{disk_index}"))
.join(&bucket)
.is_dir(),
"metadata initialization must heal every missing expansion volume"
);
}
}
ecstore
.get_bucket_info_from_sets(&bucket, &BucketOptions::default())
.await
.expect("strict request validation should succeed after volume repair");
}
}
#[tokio::test]
#[serial]
async fn bucket_metadata_init_does_not_combine_partial_set_evidence() {
let (temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
let bucket = format!("no-quorum-expansion-{}", Uuid::new_v4().simple());
for pool_index in 0..2 {
for disk_index in 0..2 {
tokio::fs::create_dir(
temp_dir
.path()
.join(format!("pool{pool_index}-disk{disk_index}"))
.join(&bucket),
)
.await
.expect("fixture bucket volume should be created");
}
}
assert_eq!(
ecstore
.bucket_exists_for_heal(&bucket)
.await
.expect_err("repair must require a complete quorum within one set"),
StorageError::ErasureWriteQuorum
);
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), vec![bucket.clone()]).await;
for pool_index in 0..2 {
for disk_index in 0..4 {
assert_eq!(
temp_dir
.path()
.join(format!("pool{pool_index}-disk{disk_index}"))
.join(&bucket)
.is_dir(),
disk_index < 2,
"unproven bucket generations must not recreate missing volumes"
);
}
}
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn bucket_metadata_init_does_not_recreate_stale_bucket_name() { async fn bucket_metadata_init_does_not_recreate_stale_bucket_name() {
@@ -2067,6 +2241,218 @@ mod tests {
} }
} }
#[tokio::test]
#[serial]
async fn bucket_info_read_quorum_tracks_erasure_layout() {
for (drive_count, parity) in [(2, 1), (3, 1), (4, 2), (5, 2), (6, 3), (8, 4), (6, 2), (12, 6)] {
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[drive_count], Some(parity)).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("read-quorum-{drive_count}-{parity}");
let object = "uncached-object";
let body = b"erasure read quorum must follow the persisted layout".repeat(32_768);
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("healthy namespace should accept bucket creation");
store
.put_object(&bucket, object, &mut PutObjReader::from_vec(body.clone()), &ObjectOptions::default())
.await
.expect("healthy erasure set should accept the seed object");
let set = &store.pools[0].disk_set[0];
let lock = set
.new_ns_lock(&bucket, object)
.await
.expect("seed namespace lock should resolve");
drop(
lock.get_write_lock(Duration::from_secs(30))
.await
.expect("seed physical fanout must finish before taking disks offline"),
);
if (drive_count, parity) == (6, 3) {
let mut kvs = rustfs_config::server_config::KVS::new();
kvs.insert(crate::config::storageclass::CLASS_STANDARD.to_string(), "EC:2".to_string());
set.set_test_storage_class_config(
crate::config::storageclass::lookup_config_for_pools_without_env(&kvs, &[drive_count])
.expect("a later storage-class change must not raise old objects' read quorum"),
);
}
let offline_indexes = (0..parity).collect::<Vec<_>>();
let offline = take_set_disks_offline(&store, set, &offline_indexes).await;
let info = store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.expect("bucket validation must admit the object's exact read quorum");
assert_eq!(info.name, bucket);
let mut reader = store
.get_object_reader(&bucket, object, None, Default::default(), &ObjectOptions::default())
.await
.expect("the persisted layout should remain readable at its exact data-shard quorum");
let mut restored = Vec::new();
reader
.stream
.read_to_end(&mut restored)
.await
.expect("quorum read should reconstruct the body");
assert_eq!(restored, body, "layout {drive_count}/{parity} must retain exact object contents");
drop(reader);
if drive_count - parity == drive_count / 2 {
let error = store
.get_bucket_info_from_sets(&bucket, &BucketOptions::default())
.await
.expect_err("bucket mutations must retain their majority namespace check");
assert_eq!(error, StorageError::ErasureWriteQuorum);
}
let below_quorum = take_set_disks_offline(&store, set, &[parity]).await;
let read = store
.get_object_reader(&bucket, object, None, Default::default(), &ObjectOptions::default())
.await;
match read {
Ok(mut reader) => assert!(
reader.stream.read_to_end(&mut Vec::new()).await.is_err(),
"layout {drive_count}/{parity} must reject fewer than its data-shard quorum"
),
Err(error) => assert!(
matches!(error, StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _)),
"a missing shard must report read quorum loss, got {error}"
),
}
restore_set_disks(&store, set, below_quorum).await;
restore_set_disks(&store, set, offline).await;
}
}
#[tokio::test]
#[serial]
async fn bucket_info_read_quorum_is_scoped_to_each_erasure_set() {
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4, 6], None).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "read-quorum-mixed-pools";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("healthy pools should accept bucket creation");
let first_set = &store.pools[0].disk_set[0];
let second_set = &store.pools[1].disk_set[0];
let first_offline = take_set_disks_offline(&store, first_set, &[0, 1]).await;
let second_offline = take_set_disks_offline(&store, second_set, &[0, 1, 2]).await;
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.expect("each set independently satisfies its namespace read quorum");
for (set, extra_disk) in [(first_set, 2), (second_set, 3)] {
let extra_offline = take_set_disks_offline(&store, set, &[extra_disk]).await;
assert_eq!(
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.expect_err("another pool must not subsidize a set below its read quorum"),
StorageError::ErasureReadQuorum
);
restore_set_disks(&store, set, extra_offline).await;
}
restore_set_disks(&store, first_set, first_offline).await;
restore_set_disks(&store, second_set, second_offline).await;
}
#[tokio::test]
#[serial]
async fn bucket_info_read_quorum_requires_authoritative_metadata() {
for state in ["missing", "corrupt", "foreign", "incarnation"] {
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4], None).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("read-quorum-{state}-metadata");
let mut metadata = if state == "missing" {
store
.make_bucket_on_sets(&bucket, &MakeBucketOptions::default())
.await
.expect("simulate directories left before bucket metadata is published");
BucketMetadata::new(&bucket)
} else {
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("healthy bucket should publish metadata");
metadata_sys::get_in(&store.ctx, &bucket)
.await
.expect("seed metadata should be cached")
.as_ref()
.clone()
};
let path = metadata.save_file_path();
match state {
"corrupt" => crate::config::com::save_config(store.clone(), &path, b"corrupt".to_vec())
.await
.expect("persist corrupt metadata while the cached copy remains valid"),
"foreign" => {
metadata.name = "different-bucket".to_string();
let mut encoded = vec![1, 0, 1, 0];
encoded.extend(metadata.marshal_msg().expect("foreign metadata should encode"));
crate::config::com::save_config(store.clone(), &path, encoded)
.await
.expect("persist metadata for a different bucket at the requested path");
}
"incarnation" => crate::bucket::metadata::save_bucket_incarnation(store.clone(), &bucket, Uuid::new_v4())
.await
.expect("persist a different bucket generation"),
_ => {}
}
let set = &store.pools[0].disk_set[0];
let offline = take_set_disks_offline(&store, set, &[0, 1]).await;
let error = store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.expect_err("read admission must not trust residual directories or cached metadata");
match state {
"missing" => assert_eq!(error, StorageError::ErasureReadQuorum),
"foreign" => assert_eq!(error, StorageError::FileCorrupt),
"incarnation" => assert!(error.to_string().contains("sidecar does not match bucket metadata")),
"corrupt" => assert!(error.to_string().contains("format invalid"), "unexpected corruption error: {error}"),
_ => unreachable!(),
}
restore_set_disks(&store, set, offline).await;
}
}
#[tokio::test]
#[serial]
async fn bucket_info_read_quorum_accepts_persisted_legacy_metadata() {
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4], None).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "interop";
store
.make_bucket_on_sets(bucket, &MakeBucketOptions::default())
.await
.expect("legacy bucket directories should exist");
let hex = include_str!("../../tests/fixtures/minio/bucket_metadata.blob.hex")
.split_whitespace()
.collect::<String>();
let body = (0..hex.len())
.step_by(2)
.map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("pinned MinIO metadata fixture"))
.collect();
crate::config::com::save_config(store.clone(), &BucketMetadata::new(bucket).save_file_path(), body)
.await
.expect("legacy metadata should be persisted without an incarnation sidecar");
let set = &store.pools[0].disk_set[0];
let offline = take_set_disks_offline(&store, set, &[0, 1]).await;
let info = store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.expect("persisted MinIO metadata should authorize reads at the namespace read quorum");
assert_eq!(info.name, bucket);
assert!(info.versioning);
assert!(info.object_locking);
restore_set_disks(&store, set, offline).await;
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn bucket_namespace_reads_report_missing_when_every_set_is_absent() { async fn bucket_namespace_reads_report_missing_when_every_set_is_absent() {
@@ -2090,6 +2476,7 @@ mod tests {
#[serial] #[serial]
async fn bucket_namespace_reads_fail_closed_when_any_set_loses_quorum() { async fn bucket_namespace_reads_fail_closed_when_any_set_loses_quorum() {
let (_temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await; let (_temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), Vec::new()).await;
let bucket = format!("degraded-expansion-{}", Uuid::new_v4().simple()); let bucket = format!("degraded-expansion-{}", Uuid::new_v4().simple());
ecstore.pools[0].disk_set[0] ecstore.pools[0].disk_set[0]
.make_bucket(&bucket, &MakeBucketOptions::default()) .make_bucket(&bucket, &MakeBucketOptions::default())
@@ -2097,6 +2484,7 @@ mod tests {
.expect("bucket should be created in the original pool only"); .expect("bucket should be created in the original pool only");
ecstore.pools[1].disk_set[0].disks.write().await[0] = None; ecstore.pools[1].disk_set[0].disks.write().await[0] = None;
ecstore.pools[1].disk_set[0].disks.write().await[1] = None; ecstore.pools[1].disk_set[0].disks.write().await[1] = None;
ecstore.pools[1].disk_set[0].disks.write().await[2] = None;
let list_err = ecstore let list_err = ecstore
.list_bucket(&BucketOptions::default()) .list_bucket(&BucketOptions::default())
@@ -2108,7 +2496,7 @@ mod tests {
.get_bucket_info(&bucket, &BucketOptions::default()) .get_bucket_info(&bucket, &BucketOptions::default())
.await .await
.expect_err("bucket validation must fail when an expansion pool is unavailable"); .expect_err("bucket validation must fail when an expansion pool is unavailable");
assert_eq!(info_err, StorageError::ErasureWriteQuorum); assert_eq!(info_err, StorageError::ErasureReadQuorum);
} }
#[tokio::test] #[tokio::test]
-317
View File
@@ -18,7 +18,6 @@ use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
use crate::set_disk::get_lock_acquire_timeout; use crate::set_disk::get_lock_acquire_timeout;
use crate::storage_api_contracts::heal::HealOperations as _; use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _; use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_lock::NamespaceLockGuard; use rustfs_lock::NamespaceLockGuard;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use tracing::trace; use tracing::trace;
@@ -379,79 +378,6 @@ impl ECStore {
Ok(result) Ok(result)
} }
/// Heal every pool metadata owner in the selected scope without allowing
/// one healthy pool to hide another pool's failed repair.
pub async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
let scopes = self.heal_erasure_set_scopes(opts).await?;
let mut results = Vec::new();
for (pool_index, set_index) in scopes {
if !self.replacement_pool_metadata_applies(pool_index, set_index)? {
continue;
}
let set = &self.pools[pool_index].disk_set[set_index];
let targets = set.set_endpoints.iter().map(ToString::to_string).collect::<Vec<_>>();
if targets.is_empty()
|| targets.len() != set.set_drive_count
|| targets.iter().collect::<BTreeSet<_>>().len() != targets.len()
{
return Err(Error::SlowDown);
}
// Administrative remove/no-lock options apply to user objects,
// never to the cluster's authoritative metadata transaction.
let metadata_opts = HealOpts {
dry_run: opts.dry_run,
recreate: opts.recreate,
scan_mode: opts.scan_mode,
pool: Some(pool_index),
set: Some(set_index),
..Default::default()
};
let (result, error) = self
.handle_heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, "", &metadata_opts)
.await?;
if let Some(error) = error {
return Err(error);
}
if !opts.dry_run {
let ok_state = DriveState::Ok.to_string();
let complete = result.after.drives.len() == targets.len()
&& targets.iter().all(|target| {
let mut outcomes = result.after.drives.iter().filter(|drive| drive.endpoint == *target);
outcomes.next().is_some_and(|drive| drive.state == ok_state) && outcomes.next().is_none()
});
if !complete
|| !set
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, "", &targets)
.await?
{
return Err(Error::SlowDown);
}
}
results.push(result);
}
Ok(results)
}
/// Whether this replacement set owns the pool's metadata replica.
///
/// Pool metadata follows normal object placement within each pool. A valid
/// non-owner set has no replica to repair; missing metadata on the owner
/// set still requires healing and target-specific readback.
pub fn replacement_pool_metadata_applies(&self, pool_index: usize, set_index: usize) -> Result<bool> {
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let selected = pool.get_disks_for_heal_object(
POOL_META_NAME,
&HealOpts {
set: Some(set_index),
..Default::default()
},
)?;
Ok(Arc::ptr_eq(&selected, &pool.get_disks_by_key(POOL_META_NAME)))
}
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))] #[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
pub async fn replacement_targets_have_version( pub async fn replacement_targets_have_version(
&self, &self,
@@ -903,249 +829,6 @@ mod tests {
} }
} }
#[tokio::test]
async fn replacement_pool_metadata_applies_to_the_written_replica_in_each_pool() {
let mut store = minimal_heal_store().await;
for pool_index in 0..store.pools.len() {
assert!(
store
.replacement_pool_metadata_applies(pool_index, 0)
.expect("a valid single-set pool should have a metadata owner")
);
}
store.ctx = Arc::new(InstanceContext::new());
for algorithm in [
crate::disk::format::DistributionAlgoVersion::V1,
crate::disk::format::DistributionAlgoVersion::V2,
crate::disk::format::DistributionAlgoVersion::V3,
] {
let mut temp_dirs = Vec::new();
for pool_index in 0..store.pools.len() {
let (dirs, mut pool) =
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
temp_dirs.extend(dirs);
Arc::get_mut(&mut pool)
.expect("fixture pool should have one owner")
.distribution_algo = algorithm.clone();
store.pools[pool_index] = pool;
}
for (pool_index, pool) in store.pools.iter().enumerate() {
let mut required_sets = 0;
for set_index in 0..pool.disk_set.len() {
required_sets += usize::from(
store
.replacement_pool_metadata_applies(pool_index, set_index)
.expect("valid replacement topology should be classified before metadata exists"),
);
}
assert_eq!(required_sets, 1, "missing metadata cannot exempt the owner set");
save_config(pool.clone(), POOL_META_NAME, b"pool metadata placement".to_vec())
.await
.expect("normal config writes should persist one metadata replica per pool");
for (set_index, set) in pool.disk_set.iter().enumerate() {
let applies = store
.replacement_pool_metadata_applies(pool_index, set_index)
.expect("valid replacement topology should be classified");
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
let replica = disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await;
if applies {
replica.expect("the metadata owner must match actual persisted shards");
} else {
assert!(
matches!(replica, Err(crate::disk::error::DiskError::FileNotFound)),
"non-owner sets must have no persisted metadata shard; observed error: {:?}",
replica.as_ref().err()
);
}
}
}
assert!(
store
.replacement_pool_metadata_applies(pool_index, pool.disk_set.len())
.is_err()
);
}
}
assert!(store.replacement_pool_metadata_applies(store.pools.len(), 0).is_err());
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_repairs_each_owner_and_preserves_dry_run() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
let first_missing = remove_pool_meta_shard(&store, 0).await;
let second_missing = remove_pool_meta_shard(&store, 1).await;
let destructive_options = HealOpts {
remove: true,
no_lock: true,
..Default::default()
};
let results = store
.heal_pool_metadata(&HealOpts {
dry_run: true,
..destructive_options
})
.await
.expect("dry-run should inspect both metadata owners without requiring a commit");
assert_eq!(results.len(), 2);
assert!(
first_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_err()
);
assert!(
second_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_err()
);
let lock = store.pools[0]
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("metadata namespace lock should be available");
let guard = lock
.get_read_lock(get_lock_acquire_timeout())
.await
.expect("a metadata reader should hold the shared fence");
let error = temp_env::async_with_vars(
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))],
store.heal_pool_metadata(&destructive_options),
)
.await
.expect_err("administrative no-lock cannot bypass the metadata write fence");
assert!(matches!(error, Error::Lock(rustfs_lock::LockError::Timeout { .. })));
drop(guard);
let results = store
.heal_pool_metadata(&destructive_options)
.await
.expect("every metadata owner should be repaired");
assert_eq!(results.len(), 2);
assert!(first_missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok());
assert!(
second_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_ok()
);
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_does_not_hide_missing_later_pool() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
delete_config(store.pools[1].clone(), POOL_META_NAME)
.await
.expect("the second pool metadata replica should be removed");
let error = store
.heal_pool_metadata(&HealOpts::default())
.await
.expect_err("the healthy first pool must not hide the second owner's missing replica");
assert!(!matches!(error, Error::NoHealRequired));
let second_set = store.pools[1].get_disks_by_key(POOL_META_NAME);
for disk in second_set.disks.read().await.iter().flatten() {
assert!(disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err());
}
}
#[tokio::test]
async fn ordinary_pool_metadata_heal_skips_only_valid_non_owner_sets() {
let mut store = minimal_heal_store().await;
store.ctx = Arc::new(InstanceContext::new());
for algorithm in [
crate::disk::format::DistributionAlgoVersion::V1,
crate::disk::format::DistributionAlgoVersion::V2,
crate::disk::format::DistributionAlgoVersion::V3,
] {
let mut temp_dirs = Vec::new();
for pool_index in 0..store.pools.len() {
let (dirs, mut pool) =
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
temp_dirs.extend(dirs);
Arc::get_mut(&mut pool)
.expect("fixture pool should have one owner")
.distribution_algo = algorithm.clone();
store.pools[pool_index] = pool;
}
for pool_index in 0..store.pools.len() {
let owner = (0..store.pools[pool_index].disk_set.len())
.find(|set_index| {
store
.replacement_pool_metadata_applies(pool_index, *set_index)
.expect("valid metadata placement")
})
.expect("every pool must have one metadata owner");
let non_owner = 1 - owner;
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(non_owner),
..Default::default()
})
.await
.expect("valid non-owner should need no metadata write")
.is_empty()
);
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(owner),
..Default::default()
})
.await
.is_err(),
"an owner with no authoritative metadata must fail"
);
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(2),
..Default::default()
})
.await
.is_err(),
"invalid sets cannot claim the non-owner exemption"
);
}
}
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(2),
..Default::default()
})
.await
.is_err()
);
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_requires_every_owner_endpoint() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
let owner = store.pools[0].get_disks_by_key(POOL_META_NAME);
let offline_disk = owner.disks.write().await[0]
.take()
.expect("fixture owner disk should start online");
let result = store
.heal_pool_metadata(&HealOpts {
pool: Some(0),
..Default::default()
})
.await;
assert!(result.is_err(), "a surviving metadata shard must not hide an offline owner endpoint");
owner.disks.write().await[0] = Some(offline_disk);
}
async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore { async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore {
let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME); let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME);
let missing_disk = target_set.disks.read().await[0] let missing_disk = target_set.disks.read().await[0]
+289 -143
View File
@@ -95,7 +95,6 @@ fn preflight_startup_rpc_secret_with(
} }
} }
const LOCAL_DECOMMISSION_INITIAL_RESUME_DELAY: Duration = Duration::from_secs(60 * 3);
const LOCAL_DECOMMISSION_RESUME_RETRY_DELAY: Duration = Duration::from_secs(30); const LOCAL_DECOMMISSION_RESUME_RETRY_DELAY: Duration = Duration::from_secs(30);
const LOCAL_DECOMMISSION_WATCHDOG_INTERVAL: Duration = Duration::from_secs(30); const LOCAL_DECOMMISSION_WATCHDOG_INTERVAL: Duration = Duration::from_secs(30);
const LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY: Duration = Duration::from_secs(60 * 5); const LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY: Duration = Duration::from_secs(60 * 5);
@@ -280,20 +279,26 @@ where
} }
} }
async fn reconcile_local_decommission_after_init(store: &Arc<ECStore>, rx: CancellationToken) -> Result<()> {
store
.ensure_pool_meta_side_effects_safe("decommission worker recovery blocked while pool metadata requires recovery")
.await?;
if store.has_active_local_decommission_worker().await {
return Ok(());
}
store.refresh_pool_status_meta().await?;
let resume_required = pool_meta_has_active_decommission(&*store.pool_meta.read().await);
if resume_required {
crate::core::pools::acquire_pool_activation_fleet_proof(&store.ctx).await?;
}
store.spawn_missing_local_decommission_routines_with_token(rx).await
}
async fn supervise_local_decommission_after_init(store: Arc<ECStore>, rx: CancellationToken) { async fn supervise_local_decommission_after_init(store: Arc<ECStore>, rx: CancellationToken) {
run_local_decommission_watchdog(rx.clone(), || { run_local_decommission_watchdog(rx.clone(), || {
let store = store.clone(); let store = store.clone();
let worker_rx = rx.clone(); let worker_rx = rx.clone();
async move { async move { reconcile_local_decommission_after_init(&store, worker_rx).await }
store
.ensure_pool_meta_side_effects_safe("decommission worker recovery blocked while pool metadata requires recovery")
.await?;
if store.has_active_local_decommission_worker().await {
return Ok(());
}
store.refresh_pool_status_meta().await?;
store.spawn_missing_local_decommission_routines_with_token(worker_rx).await
}
}) })
.await; .await;
} }
@@ -784,14 +789,9 @@ impl ECStore {
); );
} }
if has_local_decommission_leadership { if has_local_decommission_leadership {
let store = self.clone(); // The watchdog checks recovery safety and retries transient failures.
let decommission_rx = rx.clone(); // Resume persisted work without an unconditional cold-start delay.
tokio::spawn(async move { tokio::spawn(supervise_local_decommission_after_init(self.clone(), rx.clone()));
if !wait_for_local_decommission_resume_delay(&decommission_rx, LOCAL_DECOMMISSION_INITIAL_RESUME_DELAY).await {
return;
}
supervise_local_decommission_after_init(store, decommission_rx).await;
});
} }
let recovery_store = self.clone(); let recovery_store = self.clone();
@@ -2104,6 +2104,44 @@ mod tests {
); );
} }
#[tokio::test(start_paused = true)]
async fn test_local_decommission_watchdog_cancelled_start_does_not_reconcile() {
let rx = CancellationToken::new();
rx.cancel();
run_local_decommission_watchdog(rx, || async {
panic!("cancelled startup must not schedule persisted work");
})
.await;
}
#[tokio::test]
#[serial_test::serial]
async fn test_local_decommission_recovery_waits_for_live_fleet_proof_before_reserving_worker() {
let (_temp_dirs, store, _other_store) = crate::services::rebalance::test_two_pool_stores(None).await;
mark_test_pool_decommissioning(&store, 0).await;
assert!(store.ctx.is_dist_erasure().await);
let worker_rx = CancellationToken::new();
{
let _proof_guard = crate::services::notification_sys::without_cross_pool_fence_fleet_proof_for_test();
let err = super::reconcile_local_decommission_after_init(&store, worker_rx.clone())
.await
.expect_err("cold distributed recovery must wait for live fleet proof");
assert!(
crate::core::pools::is_pool_activation_fleet_proof_error(&err),
"recovery must reach the live fleet proof gate: {err:?}"
);
assert!(store.decommission_cancelers.read().await.iter().all(Option::is_none));
assert!(pool_meta_has_active_decommission(&*store.pool_meta.read().await));
}
super::reconcile_local_decommission_after_init(&store, worker_rx.clone())
.await
.expect("restored fleet proof should admit the persisted worker");
assert!(store.has_active_local_decommission_worker().await);
worker_rx.cancel();
}
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn test_local_decommission_watchdog_retries_general_failures_until_cancelled() { async fn test_local_decommission_watchdog_retries_general_failures_until_cancelled() {
let rx = CancellationToken::new(); let rx = CancellationToken::new();
@@ -2115,11 +2153,13 @@ mod tests {
let attempts = attempts.clone(); let attempts = attempts.clone();
let rx = rx.clone(); let rx = rx.clone();
async move { async move {
if attempts.fetch_add(1, Ordering::SeqCst) == 0 { match attempts.fetch_add(1, Ordering::SeqCst) {
Err(StorageError::SlowDown) 0 => Err(StorageError::other("pool activation requires a live fleet capability proof")),
} else { 1 => Err(StorageError::SlowDown),
rx.cancel(); _ => {
Ok(()) rx.cancel();
Ok(())
}
} }
} }
} }
@@ -2128,8 +2168,11 @@ mod tests {
tokio::task::yield_now().await; tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 1); assert_eq!(attempts.load(Ordering::SeqCst), 1);
tokio::time::advance(LOCAL_DECOMMISSION_RESUME_RETRY_DELAY).await; tokio::time::advance(LOCAL_DECOMMISSION_RESUME_RETRY_DELAY).await;
task.await.expect("watchdog task should exit after cancellation"); tokio::task::yield_now().await;
assert_eq!(attempts.load(Ordering::SeqCst), 2); assert_eq!(attempts.load(Ordering::SeqCst), 2);
tokio::time::advance(local_decommission_watchdog_retry_delay(2)).await;
task.await.expect("watchdog task should exit after cancellation");
assert_eq!(attempts.load(Ordering::SeqCst), 3);
} }
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
@@ -12760,6 +12803,227 @@ mod tests {
body body
} }
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn legacy_transition_state_inspection_and_apply_keep_all_disk_copies_unchanged() {
run_large_stack_async_test("legacy-state-reconcile-inspection", legacy_transition_state_inspection_and_apply_case);
}
#[cfg(feature = "test-util")]
async fn legacy_transition_state_inspection_and_apply_case() {
use crate::bucket::lifecycle::legacy_transition_state_reconcile::{
LegacyTransitionStateReconcileOutcome as Outcome, LegacyTransitionStateReconcileRequest,
LegacyTransitionStateReconcileSelector,
};
for (remote_version, expected_state) in [
("", rustfs_filemeta::TransitionVersionState::KnownDisabled),
("null", rustfs_filemeta::TransitionVersionState::SuspendedNull),
("opaque-version", rustfs_filemeta::TransitionVersionState::Exact),
] {
let temp_dir = tempfile::tempdir().expect("legacy reconcile store directory");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "legacy-state-reconcile-inspect", &[4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "LEGACY-RECONCILE";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
backend.set_put_remote_version(Some(remote_version.to_string())).await;
let bucket = "legacy-state-reconcile-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create legacy fixture bucket");
let mut reader = PutObjReader::from_vec(b"legacy reconcile body".repeat(1024));
let source = store
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("write source");
{
// Create the fixture under the existing remote-version writer
// gate. This does not authorize legacy metadata reconciliation.
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")),
],
store.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("source ETag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
),
)
.await
.expect("transition source");
}
assert!(
crate::services::notification_sys::acquire_legacy_transition_state_reconcile_fleet_proof()
.await
.is_none(),
"fixture setup must not grant the missing reconciliation write capability"
);
let selector = LegacyTransitionStateReconcileSelector {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: "null".to_string(),
};
if expected_state == rustfs_filemeta::TransitionVersionState::Exact {
backend
.set_transition_candidate_probe_override(Some(
crate::services::tier::warm_backend::TransitionCandidateProbe::Ambiguous,
))
.await;
}
let converged = store
.inspect_legacy_transition_state(selector.clone())
.await
.expect("inspect an already explicit transition");
assert_eq!(converged.outcome, Outcome::Migrated, "{converged:?}");
assert!(!converged.changed);
backend.set_transition_candidate_probe_override(None).await;
rewrite_transitioned_xlmeta_as_legacy_unknown(temp_dir.path(), 0, bucket, object, remote_version.is_empty()).await;
let paths = (0..4)
.map(|disk| {
temp_dir
.path()
.join(format!("pool0/set0/disk{disk}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"))
})
.collect::<Vec<_>>();
let mut original = Vec::new();
for path in &paths {
original.push(tokio::fs::read(path).await.expect("original xl.meta"));
}
backend.clear_op_log().await;
let inspection = store.inspect_legacy_transition_state(selector.clone());
assert!(
std::mem::size_of_val(&inspection) <= 4 * 1024,
"admin inspection future must remain stack-bounded"
);
let inspected = inspection.await.expect("inspect legacy state");
assert_eq!(inspected.outcome, Outcome::ReadyToMigrate, "{inspected:?}");
assert!(!inspected.readiness.post_ready, "current fleet cannot authorize conditional writes");
let target = inspected.target.expect("live probe should establish one model");
assert_eq!(target.state, expected_state);
let request = LegacyTransitionStateReconcileRequest {
confirm: true,
selector,
source: inspected.source.expect("immutable source"),
original_sets: inspected.original_sets,
target,
reconciliation_digest: inspected.reconciliation_digest.expect("expected tuple digest"),
};
let mut tampered = request.clone();
tampered.source.remote_object.push_str("-other");
let probes_before = backend.op_log().await.len();
let rejected = store
.reconcile_legacy_transition_state(tampered)
.await
.expect("reject tampered tuple");
assert_eq!(rejected.outcome, Outcome::Corrupt);
assert_eq!(backend.op_log().await.len(), probes_before, "invalid digest must not probe the backend");
let applied = store
.reconcile_legacy_transition_state(request)
.await
.expect("apply must report unavailable write authority");
assert_eq!(applied.outcome, Outcome::BackendUnavailable, "{applied:?}");
assert_eq!(applied.reason_code, "write_fence_unavailable");
assert!(!applied.changed);
for (path, expected) in paths.iter().zip(&original) {
assert_eq!(tokio::fs::read(path).await.expect("xl.meta after inspection"), *expected);
}
assert_eq!(backend.remove_count().await, 0);
assert!(
backend
.op_log()
.await
.iter()
.all(|operation| matches!(operation, MockWarmOp::Probe { .. }))
);
backend.set_unreachable(true).await;
let unavailable = store
.inspect_legacy_transition_state(LegacyTransitionStateReconcileSelector {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: "null".to_string(),
})
.await
.expect("unreachable tier is a diagnostic outcome");
assert_eq!(unavailable.outcome, Outcome::BackendUnavailable);
assert!(!unavailable.changed);
backend.set_unreachable(false).await;
for candidate in ["", "00000000-0000-0000-0000-000000000000", "bad\nversion"] {
backend
.set_transition_candidate_probe_override(Some(
crate::services::tier::warm_backend::TransitionCandidateProbe::VersionedPresent(candidate.to_string()),
))
.await;
let invalid_proof = store
.inspect_legacy_transition_state(LegacyTransitionStateReconcileSelector {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: "null".to_string(),
})
.await
.expect("invalid backend proof is a diagnostic outcome");
assert_eq!(invalid_proof.outcome, Outcome::BackendUnavailable, "{invalid_proof:?}");
assert!(invalid_proof.target.is_none());
}
backend.set_transition_candidate_probe_override(None).await;
backend.clear_op_log().await;
for path in &paths[1..] {
tokio::fs::remove_file(path)
.await
.expect("hide majority metadata copies in fixture");
}
let minority = store
.inspect_legacy_transition_state(LegacyTransitionStateReconcileSelector {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: "null".to_string(),
})
.await
.expect("inspect minority legacy record");
assert_eq!(
minority.outcome,
Outcome::BackendUnavailable,
"a minority owner must remain visible: {minority:?}"
);
assert!(
backend.op_log().await.is_empty(),
"unproven metadata quorum cannot initiate a remote probe"
);
for (path, bytes) in paths.iter().zip(&original) {
tokio::fs::write(path, bytes).await.expect("restore fixture copies");
}
tokio::fs::write(&paths[0], b"corrupt-xl-meta")
.await
.expect("inject corrupt copy");
let corrupt = store
.inspect_legacy_transition_state(LegacyTransitionStateReconcileSelector {
bucket: bucket.to_string(),
object: object.to_string(),
version_id: "null".to_string(),
})
.await
.expect("inspect corrupt legacy record");
assert_eq!(corrupt.outcome, Outcome::Corrupt, "{corrupt:?}");
assert!(backend.op_log().await.is_empty(), "corruption must fail before backend I/O");
}
}
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
#[tokio::test] #[tokio::test]
#[serial_test::serial(storage_class_env)] #[serial_test::serial(storage_class_env)]
@@ -18932,124 +19196,6 @@ mod tests {
.await; .await;
} }
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_config_init_recovers_after_initial_reload_failure_without_another_mutation() {
run_large_stack_async_test("tier-config-init-recovery", || async {
use crate::services::tier::tier::{TIER_DRIVER_TEST_FACTORY, TierDriverTestFactory};
let temp_dir = tempfile::tempdir().expect("create tier startup recovery store dir");
let (ctx, store, _shutdown) = build_isolated_test_store(temp_dir.path(), "tier-startup-recovery", &[4]).await;
let manager = ctx.tier_config_mgr();
let candidate = TierConfigMgr::new();
let tier_name = "STARTUP-RECOVERY";
let backend = register_mock_tier(&candidate, tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&candidate, tier_name)
.await
.expect("candidate backend identity should resolve")
.backend_identity();
let candidate_digest = {
let candidate = candidate.read().await;
candidate
.save_tiering_config(store.clone())
.await
.expect("committed tier config should persist");
tier_config_candidate_digest(&candidate).expect("committed candidate digest should build")
};
let config_path = format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE);
let config_bytes = com::read_config(store.clone(), &config_path)
.await
.expect("committed tier config bytes should load");
let config_etag = store
.get_object_info(RUSTFS_META_BUCKET, &config_path, &ObjectOptions::default())
.await
.expect("committed tier config metadata should load")
.etag
.expect("committed tier config should have an ETag");
let mutation_id = uuid::Uuid::new_v4();
let intent = TierMutationIntent {
mutation_id,
revision: 2,
kind: TierMutationIntentKind::Add,
state: TierMutationIntentState::Committed,
old_config_etag: None,
committed_config_etag: Some(config_etag),
candidate_digest,
affected_targets: vec![TierMutationIntentTarget {
tier_name: tier_name.to_string(),
old_backend_identity: None,
new_backend_identity: Some(backend_identity),
}],
expires_at_unix_nanos: 1,
};
// Persist the restart state directly: no peer Commit or runtime block
// installation may leave a notification that masks a missing startup wakeup.
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("committed restart intent should persist without notifying the manager");
com::save_config(store.clone(), &config_path, vec![0])
.await
.expect("controlled invalid config should persist");
runtime_sources::TEST_TIER_CONFIG_MGR
.scope(manager.clone(), async {
let err = runtime_sources::init_tier_config_mgr(store.clone())
.await
.expect_err("initial reload must report the controlled config parse failure");
assert!(err.to_string().contains("tierConfigInit: no data"), "unexpected startup failure: {err}");
})
.await;
assert!(manager.read().await.tiers.is_empty(), "failed startup must not publish the candidate");
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
Ok(_) => panic!("failed startup must retain the recovered committed fence"),
Err(err) => err,
};
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
// Restore only the durable bytes. Recovery must come from the worker
// started by init, after the test-only handle scope has already ended.
com::save_config(store.clone(), &config_path, config_bytes)
.await
.expect("restoring committed config bytes should remove the startup failure");
// Reload only publishes this Add into an empty manager, so it has
// no replaced backend to construct or probe. Only the lease check
// below needs a driver; keep its factory on the observing task.
let driver_factory: TierDriverTestFactory = Arc::new(move |_| Ok(Box::new(backend.clone())));
TIER_DRIVER_TEST_FACTORY
.scope(driver_factory, async {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
Ok(lease) => {
drop(lease);
match load_tier_mutation_intent_record(store.clone(), mutation_id).await {
Err(Error::ConfigNotFound) => break,
Ok(retained) => assert_eq!(retained, intent),
Err(err) => panic!("committed recovery intent should remain readable: {err}"),
}
}
Err(err) => assert!(
TierConfigMgr::operation_lease_blocked_by_mutation(&err),
"recovery must retain the fence until the tier becomes available: {err}"
),
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("startup worker must publish the tier and clean its fence without another mutation");
})
.await;
let recovered = manager.read().await;
assert_eq!(
tier_config_candidate_digest(&recovered).expect("recovered config digest should build"),
candidate_digest,
"startup recovery must publish the committed configuration"
);
});
}
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
#[tokio::test] #[tokio::test]
#[serial_test::serial(storage_class_env)] #[serial_test::serial(storage_class_env)]
+248 -9
View File
@@ -164,6 +164,16 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
max_keys max_keys
} }
fn list_versions_scan_limit(max_keys: i32, has_version_marker: bool) -> i32 {
if max_keys <= 0 {
return 0;
}
// The marker object's versions may all be filtered out after gathering.
// Reserve its raw entry in addition to the next-page lookahead entry.
max_keys_plus_one(max_keys, true) + i32::from(has_version_marker)
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum GatherResultsState { enum GatherResultsState {
LimitReached, LimitReached,
@@ -2139,15 +2149,19 @@ fn build_list_versions_next_marker(
// here; advertise it as the literal `null` marker so a resumed listing // here; advertise it as the literal `null` marker so a resumed listing
// parses it back to `VersionMarker::Null` instead of a nil UUID that // parses it back to `VersionMarker::Null` instead of a nil UUID that
// `find_version_index` can never match (issue #6745). // `find_version_index` can never match (issue #6745).
( let version_marker = if last.is_dir && last.mod_time.is_none() {
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), // A CommonPrefix has no version to resume; a version marker would
// make the next page include this same prefix again.
None
} else {
Some( Some(
last.version_id last.version_id
.filter(|v| !v.is_nil()) .filter(|v| !v.is_nil())
.map(|v| v.to_string()) .map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string()), .unwrap_or_else(|| "null".to_string()),
), )
) };
(Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), version_marker)
} else if let Some(last_prefix) = prefixes.last() { } else if let Some(last_prefix) = prefixes.last() {
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None) (Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
} else { } else {
@@ -2866,6 +2880,20 @@ fn listing_entries_supplement_target(
return None; return None;
} }
if let Some(directory) = entries.0.iter().flatten().find(|entry| entry.is_dir()) {
let directory_copies = entries
.0
.iter()
.flatten()
.filter(|entry| entry.is_dir() && entry.name == directory.name)
.count();
// A committed child may have some of its directory copies only on
// fallback disks, just like object metadata in a partial primary sample.
if directory_copies < resolver.dir_quorum {
return Some(directory.name.clone());
}
}
for (idx, entry) in entries.0.iter().enumerate() { for (idx, entry) in entries.0.iter().enumerate() {
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else { let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
continue; continue;
@@ -4018,8 +4046,7 @@ impl ECStore {
None None
}; };
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) }; let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
// Always request max_keys + 1 to detect if there are more results
let mut opts = ListPathOptions { let mut opts = ListPathOptions {
bucket: bucket.to_owned(), bucket: bucket.to_owned(),
prefix: prefix.to_owned(), prefix: prefix.to_owned(),
@@ -5325,7 +5352,7 @@ impl Sets {
None None
}; };
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) }; let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions { let mut opts = ListPathOptions {
bucket: bucket.to_owned(), bucket: bucket.to_owned(),
prefix: prefix.to_owned(), prefix: prefix.to_owned(),
@@ -6034,7 +6061,7 @@ impl SetDisks {
let has_version_marker = version_marker.is_some(); let has_version_marker = version_marker.is_some();
let version_marker = version_marker.map(parse_version_marker).transpose()?; let version_marker = version_marker.map(parse_version_marker).transpose()?;
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) }; let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions { let mut opts = ListPathOptions {
bucket: bucket.to_owned(), bucket: bucket.to_owned(),
prefix: prefix.to_owned(), prefix: prefix.to_owned(),
@@ -6248,7 +6275,7 @@ impl SetDisks {
None None
}; };
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) }; let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions { let mut opts = ListPathOptions {
bucket: bucket.to_owned(), bucket: bucket.to_owned(),
prefix: prefix.to_owned(), prefix: prefix.to_owned(),
@@ -7441,6 +7468,153 @@ mod test {
assert!(cancel.is_cancelled()); assert!(cancel.is_cancelled());
} }
#[test]
fn list_versions_pagination_scan_limit_boundaries() {
for has_version_marker in [false, true] {
assert_eq!(super::list_versions_scan_limit(-1, has_version_marker), 0);
assert_eq!(super::list_versions_scan_limit(0, has_version_marker), 0);
let marker_slot = i32::from(has_version_marker);
assert_eq!(super::list_versions_scan_limit(1, has_version_marker), 2 + marker_slot);
assert_eq!(super::list_versions_scan_limit(MAX_OBJECT_LIST, has_version_marker), 1001 + marker_slot);
assert_eq!(super::list_versions_scan_limit(i32::MAX, has_version_marker), 1001 + marker_slot);
}
}
#[tokio::test]
async fn list_versions_pagination_does_not_require_an_empty_final_page() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "version-pagination-bucket";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("pagination bucket should be created");
let mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
for kind in ["objects", "deletes", "null", "mixed", "delimiter"] {
let count = if kind == "mixed" { 5 } else { 10 };
let mut expected = Vec::new();
for index in 0..count {
let name = if kind == "delimiter" && index % 2 == 1 {
format!("{kind}/testobject-{index:02}/child")
} else {
format!("{kind}/testobject-{index:02}")
};
let entry = match kind {
"deletes" => test_delete_marker_meta_entry(&name, mod_time),
"null" => test_object_meta_entry(&name),
"mixed" => test_object_with_delete_marker_meta_entry(&name, mod_time, mod_time + time::Duration::SECOND),
_ => test_object_meta_entry_with_erasure_versions(&name, &[(mod_time, "etag", 2, 2)]),
};
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(&name);
tokio::fs::create_dir_all(&object_dir)
.await
.expect("pagination object directory should be created");
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), &entry.metadata)
.await
.expect("pagination metadata should be written");
}
if kind == "delimiter" && index % 2 == 1 {
expected.push((name.trim_end_matches("child").to_owned(), None, false));
} else {
let versions = entry.file_info_versions(bucket).expect("fixture versions should decode");
expected.extend(
versions
.versions
.iter()
.map(|version| (name.clone(), version.version_id, version.deleted)),
);
}
}
let prefix = format!("{kind}/");
let delimiter = (kind == "delimiter").then(|| "/".to_owned());
// Exercise each public/internal entry point with the reported page size.
// The store entry point also covers exact and one-over limit boundaries.
for (layer, max_keys) in [(0, 0), (0, 1), (0, 5), (0, 9), (0, 10), (0, 11), (1, 5), (2, 5), (3, 5)] {
if layer == 3 && delimiter.is_some() {
continue;
}
let mut marker = None;
let mut version_marker = None;
let expected_pages = if max_keys == 0 {
1
} else {
10usize.div_ceil(usize::try_from(max_keys).expect("positive page size"))
};
let mut actual = Vec::new();
for page in 0..expected_pages {
let result = match layer {
0 => {
store
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
1 => {
store.pools[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
2 => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
_ => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(
bucket,
&prefix,
marker,
version_marker,
max_keys,
)
.await
}
}
.expect("version page should list successfully");
let page_size = usize::try_from(max_keys).expect("nonnegative page size");
assert_eq!(result.objects.len() + result.prefixes.len(), (10 - page * page_size).min(page_size));
let has_more = page + 1 < expected_pages;
assert_eq!(result.is_truncated, has_more, "{kind}, layer {layer}, max_keys {max_keys}, page {page}");
assert_eq!(
result.next_marker.is_some(),
has_more,
"key marker must exist only when another page exists"
);
if !has_more {
assert!(
result.next_version_idmarker.is_none(),
"the final page must not advertise a version marker"
);
}
actual.extend(
result
.objects
.into_iter()
.map(|object| (object.name, object.version_id, object.delete_marker)),
);
actual.extend(result.prefixes.into_iter().map(|prefix| (prefix, None, false)));
marker = result.next_marker;
version_marker = result.next_version_idmarker;
}
// Objects and CommonPrefixes are serialized separately; compare their
// identities without relying on their relative position in the response.
actual.sort();
let mut expected = if max_keys == 0 { Vec::new() } else { expected.clone() };
expected.sort();
assert_eq!(actual, expected, "{kind}, layer {layer}, max_keys {max_keys}");
}
}
}
#[test] #[test]
fn version_marker_is_applied_only_when_key_marker_entry_is_present() { fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
let version_marker = Some(VersionMarker::Null); let version_marker = Some(VersionMarker::Null);
@@ -9448,6 +9622,71 @@ mod test {
assert!(supplemented.is_latest_delete_marker()); assert!(supplemented.is_latest_delete_marker());
} }
#[tokio::test]
async fn latest_listing_supplement_checks_fallback_disks_for_common_prefix_quorum() {
let mut fallback_disks = Vec::new();
let mut fallback_tempdirs = Vec::new();
for index in 0..4 {
let tempdir = tempfile::tempdir().expect("fallback tempdir should be created");
let endpoint = Endpoint::try_from(tempdir.path().to_str().expect("fallback path should be utf8"))
.expect("fallback endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("fallback disk should be created");
disk.make_volume("bucket").await.expect("fallback bucket should be created");
for copies in [3, 4] {
if index < copies {
let object = format!("quux-{copies}/thud");
let entry = test_object_meta_entry(&object);
disk.write_all("bucket", &format!("{object}/{STORAGE_FORMAT_FILE}"), bytes::Bytes::from(entry.metadata))
.await
.expect("fallback child metadata should be written");
}
}
fallback_disks.push(disk);
fallback_tempdirs.push(tempdir);
}
let supplement = ListingSupplement::new(
ListingSupplementOptions {
bucket: "bucket".to_owned(),
path: String::new(),
recursive: false,
incl_deleted: false,
skip_hidden_prefix_check: false,
filter_prefix: None,
forward_to: None,
per_disk_limit: 100,
skip_total_timeout: true,
walkdir_timeout: None,
walkdir_stall_timeout: None,
},
Arc::new(fallback_disks),
FallbackClaimTracker::default(),
);
// A 16-drive EC:4 set asks 12 primary disks. A committed write may
// exist on eight primary disks and all four remaining fallback disks.
let resolver = list_metadata_resolution_params("bucket".to_owned(), 4, 12, false, 0);
for fallback_copies in [3, 4] {
let prefix = format!("quux-{fallback_copies}/");
let mut primary = vec![Some(test_dir_meta_entry(&prefix)); 8];
primary.extend([None, None, None, None]);
let entry =
resolve_listing_entries_with_supplement(MetaCacheEntries(primary), resolver.clone(), true, supplement.clone())
.await;
assert_eq!(
entry.map(|entry| entry.name),
(fallback_copies == 4).then_some(prefix),
"the common prefix needs all twelve copies, including fallback disks"
);
}
}
#[test] #[test]
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() { fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"); let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
+34 -24
View File
@@ -3079,12 +3079,7 @@ impl ECStore {
let store = Arc::clone(self); let store = Arc::clone(self);
let write = async move { let write = async move {
let object = "buckets/.scanner-pause-backlog.json"; let object = "buckets/.scanner-pause-backlog.json";
let mut opts = ObjectOptions { let mut opts = ObjectOptions::default();
max_parity: true,
http_preconditions: Some(preconditions),
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
};
// Match migration: fixed object namespace -> durable pool metadata -> // Match migration: fixed object namespace -> durable pool metadata ->
// actual replica namespace. The replica need not be the hash-routed set. // actual replica namespace. The replica need not be the hash-routed set.
let object_guard = if store.single_pool() { let object_guard = if store.single_pool() {
@@ -3110,9 +3105,14 @@ impl ECStore {
} else { } else {
None None
}; };
let result = set let result = crate::data_movement::scanner_backlog::persist_native_scanner_pause_backlog_replica(
.put_object(RUSTFS_META_BUCKET, object, &mut PutObjReader::from_vec(data), &opts) set,
.await; data,
preconditions,
opts,
"publish",
)
.await;
drop(capacity_guard); drop(capacity_guard);
drop(object_guard); drop(object_guard);
result result
@@ -3747,29 +3747,37 @@ impl ECStore {
opts: &ObjectOptions, opts: &ObjectOptions,
no_lock: bool, no_lock: bool,
) -> Result<usize> { ) -> Result<usize> {
let capacity_owner = DecommissionCapacityOwner::from_options(opts);
match self match self
.get_pool_info_existing_with_opts(bucket, object, &data_movement_pool_lookup_opts(opts, no_lock)) .get_pool_info_existing_with_opts(bucket, object, &data_movement_pool_lookup_opts(opts, no_lock))
.await .await
{ {
Ok((pinfo, _)) => Ok(pinfo.index), Ok((pinfo, _)) => {
if let Some(owner) = capacity_owner {
if self.is_decommission_capacity_target_reserved(owner, pinfo.index).await? {
return Ok(pinfo.index);
}
} else {
return Ok(pinfo.index);
}
}
Err(err) => { Err(err) => {
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) { if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
return Err(err); return Err(err);
} }
if let Some(owner) = DecommissionCapacityOwner::from_options(opts) {
let expected_data_bytes = opts
.capacity_expected_data_bytes()
.or_else(|| usize::try_from(size).ok())
.unwrap_or_default();
return self
.select_decommission_capacity_target_pool(owner, expected_data_bytes)
.await;
}
self.get_available_pool_idx(bucket, object, size).await.ok_or(Error::DiskFull)
} }
} }
if let Some(owner) = capacity_owner {
let expected_data_bytes = opts
.capacity_expected_data_bytes()
.or_else(|| usize::try_from(size).ok())
.unwrap_or_default();
return self
.select_decommission_capacity_target_pool(owner, expected_data_bytes)
.await;
}
self.get_available_pool_idx(bucket, object, size).await.ok_or(Error::DiskFull)
} }
async fn find_data_movement_target_info( async fn find_data_movement_target_info(
@@ -4251,8 +4259,10 @@ impl ECStore {
} }
/// Return metadata for DELETE preflight, including an explicitly addressed /// Return metadata for DELETE preflight, including an explicitly addressed
/// delete marker. Read APIs must keep using `get_object_info`; authorization /// delete marker. GET/HEAD may also use this metadata-only lookup to enrich
/// and Object Lock enforcement still belong to the caller and locked delete. /// an already failed read with marker headers, never to serve marker data.
/// Normal reads must keep using `get_object_info`; authorization and Object
/// Lock enforcement still belong to the caller and locked delete.
#[instrument(level = "trace", skip_all)] #[instrument(level = "trace", skip_all)]
pub async fn get_object_info_for_delete(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> { pub async fn get_object_info_for_delete(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
self.get_object_info_snapshot(bucket, object, opts, true).await self.get_object_info_snapshot(bucket, object, opts, true).await
+2
View File
@@ -31,6 +31,7 @@ workspace = true
[features] [features]
default = [] default = []
test-util = []
hotpath = [ hotpath = [
"hotpath/hotpath", "hotpath/hotpath",
"hotpath/tokio", "hotpath/tokio",
@@ -104,6 +105,7 @@ walkdir = { workspace = true }
http = { workspace = true } http = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] } temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] } tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
chrono = { workspace = true }
[lib] [lib]
doctest = false doctest = false
+3 -3
View File
@@ -847,7 +847,7 @@ mod tests {
fn create_test_heal_manager() -> Arc<HealManager> { fn create_test_heal_manager() -> Arc<HealManager> {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage); let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
Arc::new(HealManager::new(storage, None)) Arc::new(HealManager::new_without_root_recovery_for_test(storage, None))
} }
#[test] #[test]
@@ -1481,7 +1481,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_process_start_request_returns_admission_result() { async fn test_process_start_request_returns_admission_result() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage); let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = Arc::new(HealManager::new( let manager = Arc::new(HealManager::new_without_root_recovery_for_test(
storage, storage,
Some(HealConfig { Some(HealConfig {
queue_size: 1, queue_size: 1,
@@ -1826,7 +1826,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_process_query_request_reports_displaced_terminal_detail() { async fn test_process_query_request_reports_displaced_terminal_detail() {
let heal_manager = Arc::new(HealManager::new( let heal_manager = Arc::new(HealManager::new_without_root_recovery_for_test(
Arc::new(MockStorage), Arc::new(MockStorage),
Some(HealConfig { Some(HealConfig {
queue_size: 1, queue_size: 1,
+66 -397
View File
@@ -851,7 +851,7 @@ impl ErasureSetHealer {
} }
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 { if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
self.heal_pool_metadata( self.heal_replacement_pool_metadata(
set_disk_id, set_disk_id,
&mut ErasureSetPassCounters { &mut ErasureSetPassCounters {
processed_objects: &mut processed_objects, processed_objects: &mut processed_objects,
@@ -950,43 +950,26 @@ impl ErasureSetHealer {
Ok(()) Ok(())
} }
async fn heal_pool_metadata( async fn heal_replacement_pool_metadata(
&self, &self,
set_disk_id: &str, set_disk_id: &str,
counters: &mut ErasureSetPassCounters<'_>, counters: &mut ErasureSetPassCounters<'_>,
resume_manager: &ResumeManager, resume_manager: &ResumeManager,
checkpoint_manager: &CheckpointManager, checkpoint_manager: &CheckpointManager,
) -> Result<()> { ) -> Result<()> {
let mut metadata_opts = self.heal_opts; let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
metadata_opts.remove = false;
metadata_opts.no_lock = false;
if self.replacement_task_id.is_none() {
let (pool_index, set_index) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
if metadata_opts.pool.is_some_and(|pool| pool != pool_index) || metadata_opts.set.is_some_and(|set| set != set_index)
{
return Err(Error::TaskExecutionFailed {
message: format!("Pool metadata scope does not match resumed set {set_disk_id}"),
});
}
metadata_opts.pool = Some(pool_index);
metadata_opts.set = Some(set_index);
}
let target_endpoints = if self.replacement_task_id.is_some() || self.pool_metadata_target_endpoints.is_empty() {
self.target_endpoints.as_ref() self.target_endpoints.as_ref()
} else { } else {
self.pool_metadata_target_endpoints.as_ref() self.pool_metadata_target_endpoints.as_ref()
}; };
let target_scoped_recreate = !metadata_opts.dry_run && metadata_opts.recreate && !target_endpoints.is_empty(); let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
let ordinary_heal = self.replacement_task_id.is_none() && !target_scoped_recreate; if self.replacement_task_id.is_none() && !target_scoped_recreate {
if !ordinary_heal { return Ok(());
if target_endpoints.is_empty() { }
return Err(Error::TaskExecutionFailed { if target_endpoints.is_empty() {
message: "Replacement pool metadata heal requires target endpoints".to_string(), return Err(Error::TaskExecutionFailed {
}); message: "Replacement pool metadata heal requires target endpoints".to_string(),
} });
if !self.storage.replacement_pool_metadata_applies(&metadata_opts).await? {
return Ok(());
}
} }
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}"); let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
@@ -1005,88 +988,61 @@ impl ErasureSetHealer {
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string())) .set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
.await?; .await?;
let result = if ordinary_heal { let result = match self
match self.storage.heal_pool_metadata(&metadata_opts).await { .storage
Ok(results) if results.is_empty() => return Ok(()), .heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
Ok(results) => { .await
let [result] = results.as_slice() else { {
return Err(Error::TaskExecutionFailed { Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
message: format!("Pool metadata returned multiple replicas for set {set_disk_id}"), let object_size = result_object_size_u64(&result);
}); match self
}; .storage
(result_object_size_u64(result), Ok(())) .replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
.await
{
Ok(true) => (object_size, Ok(())),
Ok(false) => (
object_size,
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
)),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal because target readback failed: {err}"
))),
),
} }
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => {
(0, Err(Error::transient_skip(format!("Pool metadata heal must be retried: {err}"))))
}
HealObjectOutcome::Failed => (0, Err(err)),
},
} }
} else { Ok((result, None)) => (
match self result_object_size_u64(&result),
.storage Err(Error::transient_skip(
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &metadata_opts) "Skipped replacement pool metadata heal because a replacement target was not committed",
.await )),
{ ),
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => { Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result); let object_size = result_object_size_u64(&result);
match self match Self::classify_heal_object_error(&err) {
.storage
.replacement_targets_have_version(
RUSTFS_META_BUCKET,
POOL_META_NAME,
None,
&metadata_opts,
target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(())),
Ok(false) => (
object_size,
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
)),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal because target readback failed: {err}"
))),
),
}
}
Ok((result, None)) => (
result_object_size_u64(&result),
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because a replacement target was not committed",
)),
),
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => ( HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
0, object_size,
Err(Error::transient_skip(format!( Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}" "Skipped replacement pool metadata heal due to transient error: {err}"
))), ))),
), ),
HealObjectOutcome::Failed => (0, Err(err)), HealObjectOutcome::Failed => (object_size, Err(err)),
}, }
} }
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
0,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (0, Err(err)),
},
}; };
let (object_size, result) = result; let (object_size, result) = result;
@@ -1105,7 +1061,7 @@ impl ErasureSetHealer {
bucket = RUSTFS_META_BUCKET, bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME, object = POOL_META_NAME,
state = "healed", state = "healed",
"Pool metadata healed" "Replacement pool metadata healed"
); );
CheckpointObjectOutcome::Processed CheckpointObjectOutcome::Processed
} }
@@ -1122,7 +1078,7 @@ impl ErasureSetHealer {
object = POOL_META_NAME, object = POOL_META_NAME,
state = "transient_skip", state = "transient_skip",
error = %message, error = %message,
"Pool metadata heal skipped due to transient error" "Replacement pool metadata heal skipped due to transient error"
); );
CheckpointObjectOutcome::Skipped CheckpointObjectOutcome::Skipped
} }
@@ -1139,7 +1095,7 @@ impl ErasureSetHealer {
object = POOL_META_NAME, object = POOL_META_NAME,
state = "failed", state = "failed",
error = %err, error = %err,
"Pool metadata heal failed" "Replacement pool metadata heal failed"
); );
CheckpointObjectOutcome::Failed CheckpointObjectOutcome::Failed
} }
@@ -1580,9 +1536,7 @@ impl ErasureSetHealer {
); );
CheckpointObjectOutcome::Processed CheckpointObjectOutcome::Processed
} }
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => { Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
return Err(err);
}
Err(Error::TransientSkip { message }) => { Err(Error::TransientSkip { message }) => {
telemetry_unknown |= !increment_counter(skipped_objects); telemetry_unknown |= !increment_counter(skipped_objects);
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size); telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
@@ -2084,8 +2038,6 @@ mod resume_loop_tests {
#[derive(Clone)] #[derive(Clone)]
enum HealOutcome { enum HealOutcome {
Ok, Ok,
/// The object has no metadata on any disk in the selected set.
FileNotFound,
/// The version vanished before heal ran (deleted mid-heal). /// The version vanished before heal ran (deleted mid-heal).
VersionNotFound, VersionNotFound,
/// A transient infrastructure condition (offline disk / unmet quorum): /// A transient infrastructure condition (offline disk / unmet quorum):
@@ -2111,10 +2063,6 @@ mod resume_loop_tests {
/// Target-specific physical readback evidence per `compose_key`; the /// Target-specific physical readback evidence per `compose_key`; the
/// fake models a healthy backend unless a test explicitly revokes it. /// fake models a healthy backend unless a test explicitly revokes it.
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>, replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
ordinary_pool_metadata_required: AtomicBool,
pool_metadata_opts: Mutex<Vec<HealOpts>>,
pool_metadata_not_applicable: AtomicBool,
fail_pool_metadata_scope: AtomicBool,
lifecycle_expired: Mutex<HashSet<String>>, lifecycle_expired: Mutex<HashSet<String>>,
/// every heal_object call recorded as (name, version_id) /// every heal_object call recorded as (name, version_id)
heal_calls: Mutex<Vec<(String, Option<String>)>>, heal_calls: Mutex<Vec<(String, Option<String>)>>,
@@ -2203,14 +2151,11 @@ mod resume_loop_tests {
} }
async fn heal_object( async fn heal_object(
&self, &self,
bucket: &str, _bucket: &str,
object: &str, object: &str,
version_id: Option<&str>, version_id: Option<&str>,
opts: &HealOpts, _opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> { ) -> Result<(HealResultItem, Option<Error>)> {
if bucket == RUSTFS_META_BUCKET && object == POOL_META_NAME {
self.pool_metadata_opts.lock().expect("metadata options").push(*opts);
}
self.heal_calls self.heal_calls
.lock() .lock()
.unwrap() .unwrap()
@@ -2219,7 +2164,6 @@ mod resume_loop_tests {
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok); let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
match outcome { match outcome {
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)), HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
HealOutcome::FileNotFound => Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileNotFound)))),
HealOutcome::VersionNotFound => { HealOutcome::VersionNotFound => {
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound)))) Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
} }
@@ -2233,30 +2177,6 @@ mod resume_loop_tests {
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> { async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
Ok((HealResultItem::default(), None)) Ok((HealResultItem::default(), None))
} }
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
if !self.ordinary_pool_metadata_required.load(Ordering::SeqCst) {
return Ok(Vec::new());
}
if !self.replacement_pool_metadata_applies(opts).await? {
return Ok(Vec::new());
}
let (result, error) = self.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, opts).await?;
if let Some(error) = error {
return Err(error);
}
Ok(vec![result])
}
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
if self.fail_pool_metadata_scope.load(Ordering::SeqCst) {
return Err(Error::other("injected pool metadata scope failure"));
}
if self.pool_metadata_not_applicable.load(Ordering::SeqCst) {
assert_eq!(opts.pool, Some(0));
assert_eq!(opts.set, Some(1));
return Ok(false);
}
Ok(true)
}
async fn replacement_targets_have_version( async fn replacement_targets_have_version(
&self, &self,
_bucket: &str, _bucket: &str,
@@ -2748,115 +2668,6 @@ mod resume_loop_tests {
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted"); assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
} }
#[tokio::test]
async fn ordinary_set_heals_pool_metadata_without_replacement_generation_or_targets() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("metadata-disk", POOL_META_NAME));
assert!(env.healer.replacement_task_id.is_none());
assert!(env.healer.target_endpoints.is_empty());
env.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("ordinary set recovery should repair metadata even without user buckets");
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
{
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
}
let state = env.resume.get_state().await;
assert!(state.completed);
assert_eq!(state.successful_objects, 1, "metadata must enter durable completion counters");
}
#[tokio::test]
async fn ordinary_set_pool_metadata_respects_non_owner_and_dry_run() {
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.pool_metadata_not_applicable.store(true, Ordering::SeqCst);
env.healer.heal_opts.pool = Some(0);
env.healer.heal_opts.set = Some(1);
env.healer
.execute_heal_with_resume(&[], "pool_0_set_1", &env.resume, &env.checkpoint)
.await
.expect("a valid non-owner set must not invent a metadata replica");
assert!(env.storage.calls().is_empty());
assert_eq!(env.resume.get_state().await.successful_objects, 0);
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.healer.heal_opts.dry_run = true;
env.healer.heal_opts.remove = true;
env.healer.heal_opts.no_lock = true;
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
env.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("ordinary dry-run metadata work must not require a replacement commit");
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert!(opts[0].dry_run);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn ordinary_set_missing_pool_metadata_preserves_retry_state() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("missing required pool metadata must prevent ordinary set completion");
assert!(matches!(error, Error::TransientSkip { .. }));
let state = env.resume.get_state().await;
assert!(!state.completed);
assert_eq!(state.retry_count, 1);
assert!(CheckpointManager::has_checkpoint(&env.healer.disk, &env.task_id).await);
}
#[tokio::test]
async fn ordinary_set_pool_metadata_timeout_keeps_control_error() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::Timeout);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("metadata timeout must abort the ordinary set pass");
assert!(matches!(error, Error::TaskTimeout));
assert!(!env.resume.get_state().await.completed);
}
#[tokio::test]
async fn ordinary_set_pool_metadata_rejects_mismatched_explicit_scope() {
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.healer.heal_opts.pool = Some(1);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("explicit metadata scope must agree with the resumed set");
assert!(matches!(error, Error::TaskExecutionFailed { .. }));
assert!(env.storage.calls().is_empty(), "scope mismatch must fail before metadata mutation");
assert!(!env.resume.get_state().await.completed);
}
#[tokio::test] #[tokio::test]
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() { async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await; let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -2911,111 +2722,6 @@ mod resume_loop_tests {
drop(checkpoint); drop(checkpoint);
} }
#[tokio::test]
async fn replacement_pool_metadata_non_owner_completes_but_missing_owner_retries() {
for owns_pool_metadata in [false, true] {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let replacement_task_id = ResumeUtils::generate_task_id();
let set_index = usize::from(!owns_pool_metadata);
let set_disk_id = format!("pool_0_set_{set_index}");
ResumeManager::new_replacement_intent(
env.healer.disk.clone(),
replacement_task_id.clone(),
set_disk_id.clone(),
vec!["b".to_string()],
vec!["replacement-a".to_string()],
vec![crate::heal::resume::ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("replacement intent should persist");
env.storage
.pool_metadata_not_applicable
.store(!owns_pool_metadata, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
pool: Some(0),
set: Some(set_index),
..Default::default()
},
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
let result = healer.heal_erasure_set(&["b".to_string()], &set_disk_id).await;
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("replacement state must remain until marker cleanup")
.get_state()
.await;
if owns_pool_metadata {
let error = result.expect_err("missing metadata in the owner set must keep replacement incomplete");
assert!(error.to_string().contains("Replacement erasure set heal incomplete"));
assert!(!state.completed);
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)]);
} else {
result.expect("a non-owner set must complete without a pool metadata replica");
assert!(state.completed);
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
assert_eq!(state.retry_count, 0);
assert!(env.storage.calls().is_empty(), "non-owner sets must not attempt pool metadata repair");
}
}
}
#[tokio::test]
async fn replacement_pool_metadata_unknown_scope_cannot_complete() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
env.storage.fail_pool_metadata_scope.store(true, Ordering::SeqCst);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
let mut processed_objects = 0;
let mut successful_objects = 0;
let mut failed_objects = 0;
let mut skipped_objects = 0;
let error = healer
.heal_pool_metadata(
"pool_0_set_0",
&mut super::ErasureSetPassCounters {
processed_objects: &mut processed_objects,
successful_objects: &mut successful_objects,
failed_objects: &mut failed_objects,
skipped_objects: &mut skipped_objects,
},
&env.resume,
&env.checkpoint,
)
.await
.expect_err("unknown metadata placement must keep replacement incomplete");
assert!(error.to_string().contains("injected pool metadata scope failure"));
assert!(env.storage.calls().is_empty());
assert_eq!((processed_objects, successful_objects, failed_objects, skipped_objects), (0, 0, 0, 0));
}
#[tokio::test] #[tokio::test]
async fn replacement_pool_metadata_readback_failure_schedules_retry() { async fn replacement_pool_metadata_readback_failure_schedules_retry() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await; let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -3075,8 +2781,8 @@ mod resume_loop_tests {
env.healer.disk.clone(), env.healer.disk.clone(),
HealOpts { HealOpts {
recreate: true, recreate: true,
remove: true, pool: Some(0),
no_lock: true, set: Some(0),
..Default::default() ..Default::default()
}, },
HealRequestSource::Admin, HealRequestSource::Admin,
@@ -3092,43 +2798,6 @@ mod resume_loop_tests {
assert!(env.resume.get_state().await.completed); assert!(env.resume.get_state().await.completed);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]); assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
assert!(opts[0].recreate);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn admin_recreate_pool_metadata_validates_owner_scope_before_io() {
for (non_owner, unknown_scope, pool) in [(true, false, None), (false, true, None), (false, false, Some(1))] {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.storage.pool_metadata_not_applicable.store(non_owner, Ordering::SeqCst);
env.storage.fail_pool_metadata_scope.store(unknown_scope, Ordering::SeqCst);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
recreate: true,
pool,
..Default::default()
},
HealRequestSource::Admin,
)
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
let set_disk_id = if non_owner { "pool_0_set_1" } else { "pool_0_set_0" };
let result = healer
.execute_heal_with_resume(&[], set_disk_id, &env.resume, &env.checkpoint)
.await;
assert_eq!(result.is_ok(), non_owner, "only a known non-owner may skip metadata: {result:?}");
assert!(env.storage.calls().is_empty(), "scope validation must precede metadata I/O");
assert_eq!(env.resume.get_state().await.completed, non_owner);
}
} }
#[tokio::test] #[tokio::test]
+327 -38
View File
@@ -841,6 +841,10 @@ pub struct HealManager {
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>, replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
/// Set IDs whose durable replacement metadata is corrupt or conflicting. /// Set IDs whose durable replacement metadata is corrupt or conflicting.
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>, replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
/// Durable handoff of interrupted administrator root traversals.
root_recovery: Arc<root_recovery::RootHealRecovery>,
/// Keep forceStart's cancellation side effects inside the shutdown fence.
force_start_shutdown: Mutex<()>,
/// Storage layer interface /// Storage layer interface
storage: Arc<dyn HealStorageAPI>, storage: Arc<dyn HealStorageAPI>,
/// Cancel token /// Cancel token
@@ -876,6 +880,7 @@ struct HealQueueContext<'a> {
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>, retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>, mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>, replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
root_recovery: &'a Arc<root_recovery::RootHealRecovery>,
config: &'a Arc<RwLock<HealConfig>>, config: &'a Arc<RwLock<HealConfig>>,
statistics: &'a Arc<RwLock<HealStatistics>>, statistics: &'a Arc<RwLock<HealStatistics>>,
storage: &'a Arc<dyn HealStorageAPI>, storage: &'a Arc<dyn HealStorageAPI>,
@@ -939,6 +944,10 @@ impl HealManager {
matches!(request.source, HealRequestSource::Admin | HealRequestSource::Internal) matches!(request.source, HealRequestSource::Admin | HealRequestSource::Internal)
} }
fn queued_request_can_be_displaced(request: &HealRequest) -> bool {
!root_recovery::is_admin_heal_recovery(&request.heal_type, request.source)
}
fn request_bypasses_mainline_throttle(request: &HealRequest) -> bool { fn request_bypasses_mainline_throttle(request: &HealRequest) -> bool {
request.force_start request.force_start
|| matches!(request.source, HealRequestSource::Admin | HealRequestSource::Internal) || matches!(request.source, HealRequestSource::Admin | HealRequestSource::Internal)
@@ -1075,11 +1084,15 @@ impl HealManager {
let per_object_request = request.heal_type.is_per_object(); let per_object_request = request.heal_type.is_per_object();
if queue_len >= queue_capacity && !request.force_start { if queue_len >= queue_capacity && !request.force_start {
if Self::can_displace_queued_work(&request) && queue.can_displace_lower_priority(request.priority) { if Self::can_displace_queued_work(&request)
&& queue.can_displace_lower_priority_where(request.priority, Self::queued_request_can_be_displaced)
{
let request_id = request.id.clone(); let request_id = request.id.clone();
let priority = request.priority; let priority = request.priority;
let source = request.source; let source = request.source;
if let Some(displaced) = queue.push_displacing_lower_priority(request) { if let Some(displaced) =
queue.push_displacing_lower_priority_where(request, Self::queued_request_can_be_displaced)
{
publish_heal_queue_length(queue); publish_heal_queue_length(queue);
Self::record_admission_metric(source, HealAdmissionResult::Accepted, context); Self::record_admission_metric(source, HealAdmissionResult::Accepted, context);
demote_to_debug_when!(per_object_request, warn, target: "rustfs::heal::manager", { demote_to_debug_when!(per_object_request, warn, target: "rustfs::heal::manager", {
@@ -1353,6 +1366,51 @@ impl HealManager {
}); });
} }
async fn start_root_recovery_terminal_gc(&self) {
let cancel = self.cancel_token.clone();
let root_recovery = self.root_recovery.clone();
tokio::spawn(async move {
let mut ticker = interval(RESUME_GC_INTERVAL);
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = ticker.tick() => {
match root_recovery.gc_terminal_receipts_once(SystemTime::now()).await {
Ok(report) => {
if report.pending_removed > 0 || report.terminals_removed > 0 || report.budget_exhausted {
debug!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_RESUME_GC,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "root_terminal_gc",
scanned = report.scanned,
retained = report.retained,
pending_removed = report.pending_removed,
terminals_removed = report.terminals_removed,
budget_exhausted = report.budget_exhausted,
"Root heal terminal receipt GC inspected durable state"
);
}
}
Err(error) => {
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_RESUME_GC,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "root_terminal_gc_failed",
error = %error,
"Root heal terminal receipt GC failed"
);
}
}
}
}
}
});
}
/// Create new HealManager /// Create new HealManager
pub fn new(storage: Arc<dyn HealStorageAPI>, config: Option<HealConfig>) -> Self { pub fn new(storage: Arc<dyn HealStorageAPI>, config: Option<HealConfig>) -> Self {
Self::new_with_workload_provider(storage, config, None) Self::new_with_workload_provider(storage, config, None)
@@ -1363,6 +1421,15 @@ impl HealManager {
storage: Arc<dyn HealStorageAPI>, storage: Arc<dyn HealStorageAPI>,
config: Option<HealConfig>, config: Option<HealConfig>,
workload_provider: Option<WorkloadSnapshotProviderRef>, workload_provider: Option<WorkloadSnapshotProviderRef>,
) -> Self {
Self::new_with_root_recovery(storage, config, workload_provider, Arc::new(root_recovery::RootHealRecovery::default()))
}
fn new_with_root_recovery(
storage: Arc<dyn HealStorageAPI>,
config: Option<HealConfig>,
workload_provider: Option<WorkloadSnapshotProviderRef>,
root_recovery: Arc<root_recovery::RootHealRecovery>,
) -> Self { ) -> Self {
let config = config.unwrap_or_default(); let config = config.unwrap_or_default();
Self { Self {
@@ -1377,6 +1444,8 @@ impl HealManager {
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())), mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())), replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())), replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
root_recovery,
force_start_shutdown: Mutex::new(()),
storage, storage,
cancel_token: CancellationToken::new(), cancel_token: CancellationToken::new(),
statistics: Arc::new(RwLock::new(HealStatistics::new())), statistics: Arc::new(RwLock::new(HealStatistics::new())),
@@ -1386,6 +1455,27 @@ impl HealManager {
} }
} }
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn new_without_root_recovery_for_test(storage: Arc<dyn HealStorageAPI>, config: Option<HealConfig>) -> Self {
Self::new_with_root_recovery(storage, config, None, Arc::new(root_recovery::RootHealRecovery::disabled_for_tests()))
}
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn new_with_workload_provider_without_root_recovery_for_test(
storage: Arc<dyn HealStorageAPI>,
config: Option<HealConfig>,
workload_provider: Option<WorkloadSnapshotProviderRef>,
) -> Self {
Self::new_with_root_recovery(
storage,
config,
workload_provider,
Arc::new(root_recovery::RootHealRecovery::disabled_for_tests()),
)
}
/// Start HealManager /// Start HealManager
pub async fn start(&self) -> Result<()> { pub async fn start(&self) -> Result<()> {
let mut state = self.state.write().await; let mut state = self.state.write().await;
@@ -1412,6 +1502,23 @@ impl HealManager {
"Heal manager starting" "Heal manager starting"
); );
// Restore graceful-shutdown root responsibilities before automatic
// repair can admit overlapping work.
if let Err(error) = self.replay_root_heals().await {
// A missing owner or invalid root record must not block existing
// replacement recovery. Keep its file for a later restart after
// the owner is readable or the record has been repaired.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "root_recovery_deferred",
error = %error,
"Root heal restart recovery deferred"
);
}
// start scheduler // start scheduler
self.start_scheduler().await?; self.start_scheduler().await?;
@@ -1421,6 +1528,7 @@ impl HealManager {
// Inspect resume artifacts in a bounded, fail-closed background task. // Inspect resume artifacts in a bounded, fail-closed background task.
self.start_resume_gc().await; self.start_resume_gc().await;
self.start_root_recovery_terminal_gc().await;
// start auto disk scanner to heal unformatted disks // start auto disk scanner to heal unformatted disks
if self.config.read().await.enable_auto_heal { if self.config.read().await.enable_auto_heal {
@@ -1449,6 +1557,7 @@ impl HealManager {
/// Stop HealManager /// Stop HealManager
pub async fn stop(&self) -> Result<()> { pub async fn stop(&self) -> Result<()> {
let _force_start_guard = self.force_start_shutdown.lock().await;
info!( info!(
target: "rustfs::heal::manager", target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE, event = EVENT_HEAL_MANAGER_STATE,
@@ -1458,11 +1567,39 @@ impl HealManager {
"Heal manager stopping" "Heal manager stopping"
); );
// cancel all tasks // Keep scheduler, cancellation, and retry ownership stable until every
self.cancel_token.cancel(); // unfinished admin control-plane heal has a durable successor. A failed
// write must leave the manager running and the shutdown marker unclean.
// wait for all tasks to complete
let mut active_heals = self.active_heals.lock().await; let mut active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for task in active_heals.values() {
if root_recovery::is_admin_heal_recovery(&task.heal_type, task.source) {
if task.get_status().await == HealTaskStatus::Completed {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
} else {
let mut request = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request,
Err(Error::TaskTimeout) => {
let mut request = task.retry_request();
request.options.timeout = Some(Duration::ZERO);
request
}
Err(error) => return Err(error),
};
request.retry_attempts = task.retry_attempts;
self.root_recovery.persist(&request).await?;
}
}
}
for request in queue.requests().chain(retrying.values().map(|retrying| &retrying.request)) {
self.root_recovery.persist(request).await?;
}
self.cancel_token.cancel();
drop(retrying);
drop(queue);
// cancel active workers after the durable handoff
for task in active_heals.values() { for task in active_heals.values() {
if let Err(e) = task.cancel().await { if let Err(e) = task.cancel().await {
warn!( warn!(
@@ -1589,6 +1726,17 @@ impl HealManager {
let admission_start = Instant::now(); let admission_start = Instant::now();
let source = request.source; let source = request.source;
let force_start = request.force_start; let force_start = request.force_start;
// A forceStart must not retire an old durable owner if shutdown will
// reject its replacement. Hold the same gate through final admission.
let _force_start_guard = if source == HealRequestSource::Admin && force_start {
let guard = self.force_start_shutdown.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
Some(guard)
} else {
None
};
// HS-06 forceStart semantics (admin only): MinIO stops the old task // HS-06 forceStart semantics (admin only): MinIO stops the old task
// first and then starts the new one. Cancel any active admin task // first and then starts the new one. Cancel any active admin task
// overlapping this request's path before entering admission, so the // overlapping this request's path before entering admission, so the
@@ -1596,7 +1744,9 @@ impl HealManager {
if request.source == HealRequestSource::Admin && request.force_start { if request.source == HealRequestSource::Admin && request.force_start {
let overlapping: Vec<String> = { let overlapping: Vec<String> = {
let active_heals = self.active_heals.lock().await; let active_heals = self.active_heals.lock().await;
active_heals let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
let mut ids = active_heals
.iter() .iter()
.filter(|(task_id, task)| { .filter(|(task_id, task)| {
task.source == HealRequestSource::Admin task.source == HealRequestSource::Admin
@@ -1604,7 +1754,19 @@ impl HealManager {
&& *task_id != &request.id && *task_id != &request.id
}) })
.map(|(task_id, _)| task_id.clone()) .map(|(task_id, _)| task_id.clone())
.collect() .collect::<Vec<_>>();
ids.extend(
queue
.requests()
.chain(retrying.values().map(|retrying| &retrying.request))
.filter(|pending| {
root_recovery::is_admin_heal_recovery(&pending.heal_type, pending.source)
&& heal_types_overlap(&request.heal_type, &pending.heal_type) != OverlapVerdict::Disjoint
&& pending.id != request.id
})
.map(|pending| pending.id.clone()),
);
ids
}; };
for task_id in overlapping { for task_id in overlapping {
match self.cancel_task(&task_id).await { match self.cancel_task(&task_id).await {
@@ -1618,17 +1780,16 @@ impl HealManager {
result = "force_start_cancelled_overlap", result = "force_start_cancelled_overlap",
"Admin forceStart cancelled an overlapping heal task" "Admin forceStart cancelled an overlapping heal task"
), ),
Err(err) => warn!( Err(err) => return Err(err),
target: "rustfs::heal::manager", }
event = EVENT_HEAL_QUEUE_ADMISSION, }
component = LOG_COMPONENT_HEAL, // A failed or timed-out replay may have only its durable owner
subsystem = LOG_SUBSYSTEM_MANAGER, // left. Cancel only records that overlap this forced start.
request_id = %request.id, for pending in self.root_recovery.pending().await? {
cancelled_task_id = %task_id, if pending.id != request.id
error = %err, && heal_types_overlap(&request.heal_type, &pending.heal_type) != OverlapVerdict::Disjoint
result = "force_start_cancel_failed", {
"Admin forceStart failed to cancel an overlapping heal task" self.cancel_task(&pending.id).await?;
),
} }
} }
} }
@@ -1641,6 +1802,9 @@ impl HealManager {
// active -> retrying transitions can slip between duplicate checks. // active -> retrying transitions can slip between duplicate checks.
let lock_phase_start = Instant::now(); let lock_phase_start = Instant::now();
let active_heals = self.active_heals.lock().await; let active_heals = self.active_heals.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
#[cfg(test)] #[cfg(test)]
pause_duplicate_admission_after_active_lock(&request.id).await; pause_duplicate_admission_after_active_lock(&request.id).await;
let mut queue = self.heal_queue.lock().await; let mut queue = self.heal_queue.lock().await;
@@ -1854,9 +2018,29 @@ impl HealManager {
} }
} }
let durable_handoff = root_recovery::is_admin_heal_recovery(&request.heal_type, request.source);
let durable_handoff_required = durable_handoff
&& (request.force_start
|| queue.len() < config.queue_size
|| (Self::can_displace_queued_work(&request)
&& queue.can_displace_lower_priority_where(request.priority, Self::queued_request_can_be_displaced)));
// An admin receipt is a control-plane responsibility. Persist it before
// queue publication so a crash after admission can replay it.
if durable_handoff_required {
self.root_recovery.persist(&request).await?;
}
let mut task_id = request.id.clone(); let mut task_id = request.id.clone();
let request_id = request.id.clone();
let request_heal_type = request.heal_type.clone();
let request_source = request.source;
let admission_decision = Self::admit_request_to_queue(&mut queue, request, &config, "submit"); let admission_decision = Self::admit_request_to_queue(&mut queue, request, &config, "submit");
let admission = admission_decision.result; let admission = admission_decision.result;
if durable_handoff_required && !admission.is_admitted() {
self.root_recovery
.remove(&request_id, &request_heal_type, request_source)
.await?;
}
if admission == HealAdmissionResult::Merged if admission == HealAdmissionResult::Merged
&& let Some(queued_id) = queue.queued_request_id_for_dedup_key(&dedup_key) && let Some(queued_id) = queue.queued_request_id_for_dedup_key(&dedup_key)
{ {
@@ -1927,7 +2111,7 @@ impl HealManager {
/// Retrying, never Pending — then the queue, and finally a terminal /// Retrying, never Pending — then the queue, and finally a terminal
/// completed entry. `heal_path` additionally constrains the map matches /// completed entry. `heal_path` additionally constrains the map matches
/// the way the `*_for_path` variants always have. /// the way the `*_for_path` variants always have.
async fn lookup_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> TaskStateLookup { async fn lookup_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> Result<TaskStateLookup> {
let matches_path = |heal_type: &HealType| heal_path.is_none_or(|path| heal_type_matches_path(heal_type, path)); let matches_path = |heal_type: &HealType| heal_path.is_none_or(|path| heal_type_matches_path(heal_type, path));
{ {
@@ -1936,7 +2120,7 @@ impl HealManager {
.get(canonical_task_id) .get(canonical_task_id)
.filter(|task| matches_path(&task.heal_type)) .filter(|task| matches_path(&task.heal_type))
{ {
return TaskStateLookup::Active(Arc::clone(task)); return Ok(TaskStateLookup::Active(Arc::clone(task)));
} }
} }
@@ -1946,7 +2130,7 @@ impl HealManager {
.get(canonical_task_id) .get(canonical_task_id)
.filter(|retrying| matches_path(&retrying.request.heal_type)) .filter(|retrying| matches_path(&retrying.request.heal_type))
{ {
return TaskStateLookup::Retrying(retrying.status()); return Ok(TaskStateLookup::Retrying(retrying.status()));
} }
} }
@@ -1959,7 +2143,7 @@ impl HealManager {
prune_completed_heal_statuses(&mut completed_heals); prune_completed_heal_statuses(&mut completed_heals);
if let Some(completed) = completed_heals.get(canonical_task_id).filter(|c| matches_path(&c.heal_type)) { if let Some(completed) = completed_heals.get(canonical_task_id).filter(|c| matches_path(&c.heal_type)) {
if completed_status_is_retrying(&completed.status) { if completed_status_is_retrying(&completed.status) {
return TaskStateLookup::Completed(Arc::clone(completed)); return Ok(TaskStateLookup::Completed(Arc::clone(completed)));
} }
terminal_completed = Some(Arc::clone(completed)); terminal_completed = Some(Arc::clone(completed));
} }
@@ -1972,7 +2156,7 @@ impl HealManager {
None => queue.contains_request_id(canonical_task_id), None => queue.contains_request_id(canonical_task_id),
}; };
if queued { if queued {
return TaskStateLookup::Queued; return Ok(TaskStateLookup::Queued);
} }
} }
@@ -1985,15 +2169,55 @@ impl HealManager {
.cloned(); .cloned();
} }
match terminal_completed { if terminal_completed.is_none()
&& let Some(completed) = self.root_recovery.completed(canonical_task_id).await?
&& matches_path(&completed.heal_type)
{
terminal_completed = Some(Arc::new(completed));
}
Ok(match terminal_completed {
Some(completed) => TaskStateLookup::Completed(completed), Some(completed) => TaskStateLookup::Completed(completed),
None => TaskStateLookup::NotFound, None => TaskStateLookup::NotFound,
} })
}
async fn publish_admin_terminal(
&self,
task_id: &str,
heal_type: &HealType,
source: HealRequestSource,
completed: &CompletedHealStatus,
) -> Result<bool> {
self.root_recovery
.persist_terminal(task_id, heal_type, source, completed)
.await
}
async fn publish_admin_cancelled_terminal(
&self,
task_id: &str,
heal_type: &HealType,
source: HealRequestSource,
) -> Result<bool> {
let completed = CompletedHealStatus {
outcome: None,
progress: None,
retained_bytes: std::sync::OnceLock::new(),
heal_type: heal_type.clone(),
status: HealTaskStatus::Cancelled,
result_items_truncated: false,
completed_at: SystemTime::now(),
seqed_items: Vec::new(),
next_seq: 0,
min_seq: 0,
};
self.publish_admin_terminal(task_id, heal_type, source, &completed).await
} }
pub async fn get_task_status(&self, task_id: &str) -> Result<HealTaskStatus> { pub async fn get_task_status(&self, task_id: &str) -> Result<HealTaskStatus> {
let canonical_task_id = self.canonical_task_id(task_id).await; let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, None).await { match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Ok(task.get_status().await), TaskStateLookup::Active(task) => Ok(task.get_status().await),
TaskStateLookup::Retrying(status) => Ok(status), TaskStateLookup::Retrying(status) => Ok(status),
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()), TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
@@ -2013,7 +2237,7 @@ impl HealManager {
/// full-snapshot semantics. /// full-snapshot semantics.
pub async fn get_task_report_since(&self, task_id: &str, since: Option<u64>) -> Result<HealTaskReport> { pub async fn get_task_report_since(&self, task_id: &str, since: Option<u64>) -> Result<HealTaskReport> {
let canonical_task_id = self.canonical_task_id(task_id).await; let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, None).await { match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await), TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)), TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)),
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)), TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
@@ -2036,7 +2260,7 @@ impl HealManager {
since: Option<u64>, since: Option<u64>,
) -> Result<HealTaskReport> { ) -> Result<HealTaskReport> {
let canonical_task_id = self.canonical_task_id(task_id).await; let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await { match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await), TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)), TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)),
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)), TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
@@ -2059,7 +2283,7 @@ impl HealManager {
/// recently completed task, a different token is invalid for that path. /// recently completed task, a different token is invalid for that path.
pub async fn get_task_status_for_path(&self, heal_path: &str, task_id: &str) -> Result<HealTaskStatus> { pub async fn get_task_status_for_path(&self, heal_path: &str, task_id: &str) -> Result<HealTaskStatus> {
let canonical_task_id = self.canonical_task_id(task_id).await; let canonical_task_id = self.canonical_task_id(task_id).await;
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await { match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await? {
TaskStateLookup::Active(task) => Ok(task.get_status().await), TaskStateLookup::Active(task) => Ok(task.get_status().await),
TaskStateLookup::Retrying(status) => Ok(status), TaskStateLookup::Retrying(status) => Ok(status),
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()), TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
@@ -2113,11 +2337,18 @@ impl HealManager {
} }
drop(completed_heals); drop(completed_heals);
let mut displaced_terminals = lock_displaced_terminals(&self.displaced_terminals); {
prune_completed_heal_statuses(&mut displaced_terminals); let mut displaced_terminals = lock_displaced_terminals(&self.displaced_terminals);
displaced_terminals prune_completed_heal_statuses(&mut displaced_terminals);
.values() if displaced_terminals
.any(|terminal| heal_type_matches_path(&terminal.heal_type, heal_path)) .values()
.any(|terminal| heal_type_matches_path(&terminal.heal_type, heal_path))
{
return true;
}
}
self.root_recovery.completed_matches_path(heal_path).await.unwrap_or(false)
} }
/// Get task progress /// Get task progress
@@ -2129,7 +2360,7 @@ impl HealManager {
pub async fn get_task_progress(&self, task_id: &str) -> Result<HealProgress> { pub async fn get_task_progress(&self, task_id: &str) -> Result<HealProgress> {
let canonical_task_id = self.canonical_task_id(task_id).await; let canonical_task_id = self.canonical_task_id(task_id).await;
let progress = match self.lookup_task_state(&canonical_task_id, None).await { let progress = match self.lookup_task_state(&canonical_task_id, None).await? {
TaskStateLookup::Active(task) => Some(task.get_progress().await), TaskStateLookup::Active(task) => Some(task.get_progress().await),
TaskStateLookup::Completed(completed) => completed.progress.clone(), TaskStateLookup::Completed(completed) => completed.progress.clone(),
_ => None, _ => None,
@@ -2145,8 +2376,11 @@ impl HealManager {
{ {
let mut active_heals = self.active_heals.lock().await; let mut active_heals = self.active_heals.lock().await;
if let Some(task) = active_heals.get(&canonical_task_id) { if let Some(task) = active_heals.get(&canonical_task_id) {
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await; let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
self.publish_admin_terminal(&canonical_task_id, &task.heal_type, task.source, &completed)
.await?;
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await; publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
active_heals.remove(&canonical_task_id); active_heals.remove(&canonical_task_id);
publish_active_heal_count(&active_heals); publish_active_heal_count(&active_heals);
@@ -2168,6 +2402,13 @@ impl HealManager {
{ {
let mut retrying_heals = self.retrying_heals.lock().await; let mut retrying_heals = self.retrying_heals.lock().await;
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
self.publish_admin_cancelled_terminal(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
self.root_recovery
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) { if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
retrying.cancel_token.cancel(); retrying.cancel_token.cancel();
drop(retrying_heals); drop(retrying_heals);
@@ -2188,6 +2429,13 @@ impl HealManager {
} }
let mut queue = self.heal_queue.lock().await; let mut queue = self.heal_queue.lock().await;
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
self.publish_admin_cancelled_terminal(&canonical_task_id, &request.heal_type, request.source)
.await?;
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
if queue.remove_request_id(&canonical_task_id).is_some() { if queue.remove_request_id(&canonical_task_id).is_some() {
publish_heal_queue_length(&queue); publish_heal_queue_length(&queue);
info!( info!(
@@ -2205,6 +2453,10 @@ impl HealManager {
return Ok(()); return Ok(());
} }
drop(queue);
if self.root_recovery.cancel_pending(&canonical_task_id).await? {
return Ok(());
}
Err(Error::TaskNotFound { Err(Error::TaskNotFound {
task_id: task_id.to_string(), task_id: task_id.to_string(),
}) })
@@ -2223,8 +2475,11 @@ impl HealManager {
for task_id in &task_ids { for task_id in &task_ids {
if let Some(task) = active_heals.get(task_id) { if let Some(task) = active_heals.get(task_id) {
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await; let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
self.publish_admin_terminal(task_id, &task.heal_type, task.source, &completed)
.await?;
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await; publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
} }
active_heals.remove(task_id); active_heals.remove(task_id);
@@ -2251,6 +2506,13 @@ impl HealManager {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for task_id in &task_ids { for task_id in &task_ids {
if let Some(retrying) = retrying_heals.get(task_id) {
self.publish_admin_cancelled_terminal(task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
self.root_recovery
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(task_id) { if let Some(retrying) = retrying_heals.remove(task_id) {
retrying.cancel_token.cancel(); retrying.cancel_token.cancel();
cancelled += 1; cancelled += 1;
@@ -2272,6 +2534,21 @@ impl HealManager {
} }
} }
let queued_matches = {
let queue = self.heal_queue.lock().await;
queue
.requests()
.filter(|request| heal_type_matches_path(&request.heal_type, heal_path))
.cloned()
.collect::<Vec<_>>()
};
for request in &queued_matches {
self.publish_admin_cancelled_terminal(&request.id, &request.heal_type, request.source)
.await?;
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
let mut queue = self.heal_queue.lock().await; let mut queue = self.heal_queue.lock().await;
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path)); let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
if !queued_cancelled.is_empty() { if !queued_cancelled.is_empty() {
@@ -2284,6 +2561,17 @@ impl HealManager {
self.remove_mrf_repair_notice_targets_for_task(&request.id); self.remove_mrf_repair_notice_targets_for_task(&request.id);
} }
for pending in self
.root_recovery
.pending()
.await?
.into_iter()
.filter(|pending| heal_type_matches_path(&pending.heal_type, heal_path))
{
if self.root_recovery.cancel_pending(&pending.id).await? {
cancelled += 1;
}
}
if cancelled == 0 { if cancelled == 0 {
return Err(Error::TaskNotFound { return Err(Error::TaskNotFound {
task_id: heal_path.to_string(), task_id: heal_path.to_string(),
@@ -2395,6 +2683,7 @@ impl std::fmt::Debug for HealManager {
mod auto_scan; mod auto_scan;
mod queue; mod queue;
mod root_recovery;
mod scheduler; mod scheduler;
mod unclean_shutdown; mod unclean_shutdown;
+22 -4
View File
@@ -288,16 +288,29 @@ impl PriorityHealQueue {
QueuePushOutcome::Accepted QueuePushOutcome::Accepted
} }
pub(super) fn can_displace_lower_priority(&self, priority: HealPriority) -> bool { pub(super) fn can_displace_lower_priority_where<F>(&self, priority: HealPriority, can_displace: F) -> bool
self.heap.iter().any(|item| item.priority < priority) where
F: Fn(&HealRequest) -> bool,
{
self.heap
.iter()
.any(|item| item.priority < priority && can_displace(&item.request))
} }
pub(super) fn push_displacing_lower_priority(&mut self, request: HealRequest) -> Option<HealRequest> { #[cfg(test)]
pub(super) fn can_displace_lower_priority(&self, priority: HealPriority) -> bool {
self.can_displace_lower_priority_where(priority, |_| true)
}
pub(super) fn push_displacing_lower_priority_where<F>(&mut self, request: HealRequest, can_displace: F) -> Option<HealRequest>
where
F: Fn(&HealRequest) -> bool,
{
let mut retained = BinaryHeap::new(); let mut retained = BinaryHeap::new();
let mut displaced: Option<PriorityQueueItem> = None; let mut displaced: Option<PriorityQueueItem> = None;
while let Some(item) = self.heap.pop() { while let Some(item) = self.heap.pop() {
if item.priority < request.priority { if item.priority < request.priority && can_displace(&item.request) {
let should_displace = displaced let should_displace = displaced
.as_ref() .as_ref()
.map(|current| { .map(|current| {
@@ -337,6 +350,11 @@ impl PriorityHealQueue {
displaced displaced
} }
#[cfg(test)]
pub(super) fn push_displacing_lower_priority(&mut self, request: HealRequest) -> Option<HealRequest> {
self.push_displacing_lower_priority_where(request, |_| true)
}
/// Get statistics about queue contents by priority /// Get statistics about queue contents by priority
pub(super) fn get_priority_stats(&self) -> HashMap<HealPriority, usize> { pub(super) fn get_priority_stats(&self) -> HashMap<HealPriority, usize> {
let mut stats = HashMap::new(); let mut stats = HashMap::new();

Some files were not shown because too many files have changed in this diff Show More