Compare commits

..

85 Commits

Author SHA1 Message Date
overtrue 6de965ae3c test(e2e): register paginator regression in Darwin inventory 2026-10-03 07:07:41 +08:00
overtrue 7c56853067 fix(storage): publish delete rollback backups atomically
Stage rollback metadata outside the rollback directory and publish it only after the full write succeeds. A short write must not leave a backup that quorum rollback can rename over acknowledged version history.

Add an isolated real short-write regression and register the backported ListParts SDK test in the smoke and Linux full inventories.
2026-10-03 06:43:25 +08:00
Hauser 6f61761c7b fix(s3): honor sparse ListParts markers and verify termination
Resume part listings at the first part above the numeric marker, even
when that marker is absent. Use binary search over the sorted part
numbers and retain the existing exact-tail empty-slice path.

Add storage, XML, and real AWS SDK paginator regressions for empty and
terminal pages, sparse markers, and multipart completion integrity.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
(cherry picked from commit 673031eea1)
2026-10-03 06:32:24 +08:00
Chapman 1bae764530 fix: emit NextPartNumberMarker only when ListParts is truncated
ListPartsInfo.next_part_number_marker was a non-optional usize that
defaulted to 0 and was only assigned when the response was truncated.
The S3 serializer then emitted it unconditionally as Some(0), causing
AWS SDK paginators to loop infinitely on part_number_marker=0 instead
of terminating.

Change the field to Option<usize> (None by default) and set it only
inside the is_truncated branch. The S3 output layer now uses
.and_then() so NextPartNumberMarker is absent when IsTruncated=false,
matching AWS S3 behavior.

Fixes #8208

(cherry picked from commit 44de803a38)
2026-10-03 06:32:24 +08:00
overtrue 7150ed2066 ci: remove Docker Hub description sync 2026-10-02 19:59:28 +08:00
overtrue c04d566b26 fix(heal): wait for held legacy responsibility in replay test 2026-10-02 03:13:15 +08:00
Hauser 4dd267940c fix(scanner): diagnose and verify pause backlog recovery (#8293)
* fix(scanner): diagnose and verify pause backlog recovery

Expose the retained replica snapshot and claimed membership in abnormal
admin status responses. Keep diagnostics off metrics updates and verify
single-pool recovery and conflicting-proof preservation across 24 sets.

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

* refactor(scanner): move replica snapshots into diagnostics

Consume the terminal admin read snapshot in a single state match and move
membership, revision, and error buffers into the response. Verify buffer
handoff and the unchanged JSON contract without altering ledger authority.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-02 01:09:00 +08:00
Hauser 2aad57c984 chore: merge upstream release branch
Integrate the updated release/1.0.1 branch before pushing the
dependency and atomic API maintenance changes. Preserve the upstream
removal of async-recursion.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-02 01:00:04 +08:00
Hauser dc79582e40 improve 2026-10-02 00:54:32 +08:00
Hauser 25fb3d2516 chore: refresh dependencies and atomic update APIs
Update workspace dependencies and the lockfile. Replace deprecated
atomic fetch_update aliases with try_update while preserving closures
and memory ordering.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-02 00:46:57 +08:00
Hauser 5c39ed1495 fix(s3): queue bucket operations and restore strict Clippy checks (#8290)
* fix(s3): queue concurrent bucket creation and deletion

Keep eight active bucket transactions and bound admission waiting to 128 requests and 30 seconds. Preserve detached transaction ownership and return Retry-After with overload responses.

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

* fix(ecstore): restore strict Clippy compatibility on Rust 1.99

Use try_update without changing atomic ordering or overflow behavior. Keep
recursive storage futures boxed once at each frame and remove the redundant
async-recursion macro, including its non-recursive SQL planner use. Remove
needless closure borrows and orphaned dependency entries.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-02 00:41:05 +08:00
overtrue da31b53dd6 fix(scanner): pass fence store to checkpoint fixture 2026-10-01 19:52:48 +08:00
Hauser a6bee557e9 fix: add UploadPart OOM validation guardrails (#8287)
* docs(release): validate candidates on release branch

* fix(scanner): validate checkpoints against global cycle fence (#8278)

## Related Issues

Related to rustfs/backlog#2701.

## Summary of Changes

Route scanner checkpoint cycle and leader validation through the global store while retaining the owning set for cache persistence, CAS revisions and publication admission.

## Verification

Two independent final-diff source reviews found no issues across correctness, concurrency and durability, test coverage, compatibility, performance and simplicity on head `8b8fe51d092090b053f552ae283960e2e306be33`. Root approval `5373624714` is bound to that exact head. Regression tests cover real two-pool routing, stale fences, post-save rejection and CAS conflicts; their reported local execution belongs to the PR author, not this merge operation. Current required CI remains pending, and this authorized admin squash does not establish CI or runtime acceptance.

## Impact

Restores checkpoint progress when global cycle and leader state differ from a set-scoped view. No format, retry, timeout, assertion or scanner-policy changes are introduced by this diff. The three prior main scanner failures remain unproved repaired.

## Additional Notes

Full validation must run on the resulting exact main revision. Reverting this patch restores the earlier set-scoped fence lookup and its checkpoint rejection behavior.

* fix(ci): restore E2E membership and pagination timeouts (#8281)

* ci: locate the auto-testing checkout for lanes that run evidence from a subdirectory (#8279)

## Related Issues

Follow-up to #8229.

## Summary of Changes

Locate the private auto-testing checkout from the lane root or the workspace root so the nested security checkout can record functional-chain evidence.

## Verification

The exact PR head b66129ab9f passed one mechanical correctness and simplicity review, nine real-Git layout and provenance checks, and sixteen existing evidence/envelope tests. The baseline sibling layout failed with git exit 128; the corrected layout succeeded while revision mismatches and missing checkouts stayed rejected. Current PR checks are completed with successful or skipped conclusions, including the aggregate.

## Impact

Both lane and private-script revision checks remain intact. No time limits, assertions, production behavior, or evidence validation requirements change. The synthetic layout checks do not execute the actual scheduled security suite; integrated main CI and release acceptance remain separate gates.

## Additional Notes

Approved review 5374559393 is bound to the exact head above. Reverting the single-file change restores the previous checkout lookup.

* fix: add UploadPart OOM validation guardrails

Add a Docker validation harness for backlog#2704 so the ordinary UploadPart
low-concurrency memory workload can be reproduced with comparable case metadata,
process/cgroup sampling, TLS and metrics toggles, cache-env controls, and write
reclaim/direct-write experiments.

Warn when operators set the unrecognized RUSTFS_OBJECT_CACHE_* variables that
appeared in the reporter compose file. The variables are reported but remain
ignored, so startup does not silently change object data cache behavior.

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

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: AL <allan.bednarowski@gmail.com>
Co-authored-by: hector <42570491+majinghe@users.noreply.github.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-01 19:32:52 +08:00
Hauser 7b76c655a9 Gate multipart copy through write admission (#8283)
Make UploadPartCopy acquire the shared foreground write admission permit before lifecycle locks or source readers so server-side multipart copy cannot bypass the same backpressure used by UploadPart. Document the shared queue semantics and add focused coverage for saturation, cancellation, lock ordering, and disabled admission.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-01 19:27:07 +08:00
Hauser 09a5c7b372 [release/1.0.1] Gate multipart copy through write admission (#8284)
Gate multipart copy through write admission

Make UploadPartCopy acquire the shared foreground write admission permit before lifecycle locks or source readers so server-side multipart copy cannot bypass the same backpressure used by UploadPart. Document the shared queue semantics and add focused coverage for saturation, cancellation, lock ordering, and disabled admission.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-01 16:06:03 +08:00
overtrue f37df93df5 fix(test): await HTTP sender readiness in Top RPC fixture 2026-10-01 09:51:54 +08:00
overtrue 4816453cd5 fix(usage): combine identical snapshot retention branches 2026-10-01 08:06:04 +08:00
Hauser f715e88f8a fix(obs): distinguish allocator counters from live memory (#8274)
* fix(obs): distinguish allocator counters from live memory

Preserve count/counter semantics and mark requested-byte attribution unavailable when live statistics or sampling are missing. Document sustained multipart memory diagnosis.

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

* refactor(obs): parse allocator statistics from one node

Resolve each statistic before interpreting its shape, avoiding unsupported-field tree scans and mixing data across wrapper scopes. Preserve unavailable-statistics policy and add precedence regressions.

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

* test(ecstore): isolate late parity recovery from metadata hedges

Use the existing object-scoped hedge timer barrier in exact-count recovery fixtures. Preserve payload and total-read assertions and verify that the omitted parity disk is read only during late refresh.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-01 07:57:22 +08:00
Hauser 6c06b4fe16 test(scanner): verify checkpoint takeover and repair dispatch (#8275)
* test(scanner): cover checkpoint handoff and repair dispatch

Drive runtime budget expiry, partial-cycle persistence, leadership claims,
and stale checkpoint rejection between real disk-backed fixture scans.
Verify that a metadata repair beyond the first bounded prefix is saved in
the scanner ledger and dispatched by the MRF consumer.

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

* ci: isolate scanner fixtures and refresh full e2e membership

Reserve nextest capacity for the real-disk scanner publication and MRF
admission fixtures. Bind both platform membership checks to the reviewed
pagination deadline test added on main.

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

* refactor(scanner): consolidate checkpoint fixture lifecycle

Keep one durable control store across timeout and leadership transitions,
and inject generation advancement into the shared checkpoint scenario.
Check the actual saved metadata path so late-write rejection also proves
that existing checkpoint bytes remain intact.

Centralize MRF fixture isolation and reuse nextest process isolation when
the startup environment already satisfies the test contract.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-01 07:28:37 +08:00
Hauser 49462ae5d4 fix(usage): recover historical counters with generation fencing (#8273)
* fix(usage): recover historical counters during continued writes

Use newer converged scanner snapshots to reconcile stale absolute usage
baselines while preserving concurrent mutation and expiry receipt fences.
Cover durable publication, admin and quota reads, and legacy generations.

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

* refactor(usage): fence snapshots and move preserved cache entries

Apply the cached scanner generation floor before every reconciliation path
and retain it even when an older snapshot happens to match core counts.
Move preserved usage entries instead of cloning their histogram maps under
the cache lock, retaining expiry receipt identity and cancellation fences.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-01 06:32:34 +08:00
overtrue 4b50b98d22 fix(ci): locate security chain scripts in the workspace 2026-10-01 06:12:54 +08:00
overtrue bcf83d4e41 fix(test): supply CPU workload for sampler regression 2026-10-01 04:59:54 +08:00
overtrue 65ce936131 test(e2e): retain startup and shutdown failure diagnostics 2026-10-01 01:46:41 +08:00
Peder Bergan e4d1ade702 fix(usage): reconcile stale counters after lifecycle expiration (#8108)
* fix(usage): reconcile stale counters after lifecycle expiration

* fix(usage): account lifecycle expiry during continuous writes

* test(usage): run lifecycle usage scenarios on one scanner store

* test(usage): use a Windows-representable pre-mutation offset

* fix(usage): harden expiry accounting recovery and quota checks

Borrow expiry receipt bucket names and avoid allocating a map key on cache hits. Cover cancelled receipts, durable snapshot recovery, and legacy quota admission after scanner confirmation. Use representable timestamp offsets in the quota regression.

Refs rustfs/backlog#2689

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

* test(usage): recover stale persisted counts through the scanner

Seed incorrect complete usage for empty and retained-object buckets, then run the real scanner and publication consumer without further object mutations. Verify durable and admin usage over two cycles instead of writing a corrected snapshot in the test.

Refs rustfs/backlog#2689
Refs rustfs/backlog#2691

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

* test(e2e): bound delimiter pagination fixture concurrency

The 120-second smoke timeout expired after 1018 of 1200 serial fixture PUTs, before LIST ran. Prepare the same objects with at most eight concurrent requests and await every PUT. Retain the timeout and strengthen exact prefix, KeyCount, empty Contents, and continuation-token assertions with phase diagnostics.

Refs rustfs/backlog#2689

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

* test(upgrade): establish a persisted previous-release baseline

Seed the pinned previous-release cluster and restart it once with its data intact before replacing any node. Require every old writer to pass the strict readiness probe and preserve the seed through both mixed phases and the final current cluster. Keep InternalError fail-fast behavior and all existing compatibility deadlines and assertions.

Refs rustfs/backlog#2689
Refs rustfs/backlog#2384

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

* test(ecstore): bound cancellation metadata persistence waits

Use the system-bucket incarnation boundary now supplied by main PR #8268. Bound the three cancellation waits that previously hung during pool.bin persistence, retaining their remote-generation, target-cohort, and durable-state assertions.

Refs rustfs/backlog#2697
Refs rustfs/backlog#2689

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

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-30 22:33:00 +08:00
Hauser 86fc35b49f fix(scanner): expose pause backlog replica diagnostics (#8258)
* fix(scanner): expose pause backlog replica diagnostics

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

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

* fix(connect): stabilize runtime profile lease cancellation

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

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

* test(connect): tolerate delayed schedule startup in CI

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

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

* test(e2e): retry quota reads during usage warmup

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

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

* fix(ecstore): avoid meta-bucket incarnation self-deadlock

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

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

* fix(test): use persisted incarnation in heal fixture

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-30 22:25:31 +08:00
overtrue b54b32b8a9 fix(deps): replace yanked yoke-derive release 2026-09-30 22:24:45 +08:00
overtrue aff1c025af fix(ci): include pagination regression in full E2E selection 2026-09-30 22:03:46 +08:00
Chris 2806a80c91 fix: prevent metadata lock reentry and stabilize CI fixtures (#8257)
* fix(test): establish a writable previous-release upgrade baseline

* fix(ci): reserve capacity for durable admin fixtures

* fix(ci): group durable IAM state fixtures by resource needs

* ci: run E2E doctests with the E2E dependency graph

* fix(ci): separate fixture startup from transport deadlines

* fix(ci): bound pagination after seeding and revisit restored copies

* fix(ci): make filesystem fixture timing deterministic

* fix(ecstore): avoid metadata lock reentry during internal mutations

* test: align recovery fixtures with durable ownership contracts
2026-09-30 20:57:07 +08:00
Chris 8655c38f1d fix(rio): preserve bounded retries after peer EOF (#8272) 2026-09-30 20:18:28 +08:00
Chris 88d03d8199 fix(storage): prevent metadata deadlocks and abandoned writes (#8268)
## Related Issues

N/A

## Summary of Changes

Reject system-bucket incarnation lookups before entering the pool metadata owner. Keep each selected scanner backlog publication cohort in one task so waiter cancellation cannot abandon its remaining serialized conditional writes. Bind repair and replay fixtures to persisted bucket incarnations.

## Verification

Head f6d44603fc received an approval from houseme in review 5365588011. This merge does not add a local runtime validation claim. Full main CI remains a separate publication gate.

## Impact

System metadata writes avoid recursive pool locking. Publication retains per-replica conditional writes and stops a cancelled caller from advancing to its next publication phase. Fixture changes supply the identities required by existing repair admission rules.

## Additional Notes

Reverting this change restores the previous behavior.
2026-09-30 19:49:24 +08:00
Hauser eb350ad20d fix(ecstore): purge empty recursive bucket orphans (#8261)
## Related Issues

Related to rustfs/backlog#2688.

## Summary of Changes

Reclaim metadata-less orphan directory trees after a complete, first-page, empty recursive root listing in an authoritative never-versioned bucket. Both listing implementations use the same fail-closed cleanup decision.

## Verification

Head b4a9120173 received an approval from loverustfs in review 5365460152. This merge does not add a local runtime validation claim. Full main CI remains a separate publication gate.

## Impact

Empty recursive root listings can remove otherwise unaddressable physical residue. Versioned buckets and populated listings remain excluded; unreadable disks, object metadata, unknown files, and uncommitted data prevent cleanup.

## Additional Notes

Reverting this change restores the previous orphan-directory behavior.
2026-09-30 19:24:32 +08:00
KarlEmm e573fb087b ci(helm): sync README to root of helm repository on release (#8263)
Include helm/rustfs/README.md in the uploaded helm-package artifact so
publish-helm-package updates the root README.md in rustfs/helm.

The build-helm-package job copies helm/README.md into helm/rustfs/
before running helm package, but upload-artifact previously uploaded
only helm/rustfs/*.tgz. Because both files share the helm/rustfs/
parent directory, actions/upload-artifact places both the chart
archive and README.md at the artifact root, and download-artifact
extracts README.md into the root of rustfs/helm alongside the tarball.
2026-09-30 15:57:24 +08:00
Chris 099a408d8b test(ecstore): isolate dispatch shutdown recovery fixtures (#8265) 2026-09-30 15:56:48 +08:00
Chris 5c3a2ff277 fix(ci): reserve capacity for profile cancellation probe (#8259) 2026-09-30 15:46:20 +08:00
Chris bb04355a3d fix(heal): retry contended local metadata CAS (#8267) 2026-09-30 15:45:59 +08:00
Chris fab2d7e144 fix(scanner): serialize pause backlog replica publication (#8264) 2026-09-30 15:45:37 +08:00
Hauser d60dfbb826 fix(heal): finalize durable MRF legacy responsibility lifecycle (#8254)
## Related Issues

Resolves the corrected lifecycle fixture findings in PR #8254.

## Summary of Changes

Preserve separate incarnation-bound durable MRF responsibilities and their lifecycle audit records, and align replay fixtures with their checkpoint identities.

## Verification

Two independent source reviews and changed-delta reviews are complete. The final review approved 607a0b9bb0 with no remaining supported findings. Local runtime claims were not independently reproduced for this pull request.

## Impact

Keeps storage generation fences and retained responsibility semantics. Test-capacity reservations preserve existing deadlines and assertions.

## Additional Notes

Squash merge of the currently approved fix under the authorized CI-bypass exception. Main CI and release acceptance remain required.
2026-09-30 14:02:32 +08:00
Chris 6bc2e10bc1 fix(tier): keep recovery worker after startup reload failure (#8260) 2026-09-30 12:05:26 +08:00
Chris 3268c42e00 fix(ci): stabilize registration and rolling upgrade readiness (#8244)
## Related Issues

Follow-up to #8233.

## Summary of Changes

Registration runtime fixtures timed out in the workspace CI lane while the same cases passed in the feature lanes. Reserve nextest capacity for the exact `connect_registration` binary, as already done for related inventory and drive fixtures. Keep its existing deadlines, internal concurrency, assertions and zero-retry policy; report the last watch status on failure.

Rolling upgrades could pass `ListBuckets` readiness while restarted peers still lacked write quorum. Before each mixed-version phase, probe writes through every node outside the asserted workload prefixes. A shared 30-second deadline includes requests and sleeps; only HTTP 503 with `ServiceUnavailable` is retryable, with SDK retries disabled for these probes. The actual compatibility writes, reads, multipart operations and listing assertions remain unchanged.

Add four fast regression tests to the existing PR smoke profile, with matching exclusion from the full profile. No new workflow or job is introduced.

## Verification

- `cargo nextest run --locked --profile ci -p rustfs --lib --test connect_registration --test-threads 4 -E 'binary(/^connect_registration$/) | test(=connect::diagnostics::trace_runtime::tests::local_runtime_rejects_non_private_state)' --no-tests fail --status-level pass --final-status-level fail` passed 23/23 twice in 5.088s and 5.097s: 22 macOS registration tests plus one unrelated control. JUnit intervals confirm capacity reservation; no retries or test-process leaks occurred. The Linux-only inventory case remains for CI.
- `cargo nextest run -p e2e_test --lib --profile ci -E 'test(upgrade_write_readiness_tests)' --no-fail-fast --no-tests fail` passed 4/4 twice in 1.068s and 1.072s, with zero retries. Regressions cover metadata readiness followed by write unavailability, recovery through every writer, immediate permanent-error failure despite an incoming SDK retry configuration, and deadlines for repeated 503s and stalled requests. The original failure is recorded in [the mixed-version upgrade job](https://github.com/rustfs/rustfs/actions/runs/36569700716/job/109415806473).
- `cargo fmt --all --check`, `git diff --check`, `python3 scripts/check_test_wiring.py` and compiled smoke/full membership checks passed. Smoke membership changes from 188 to 192 by adding exactly these four tests; full membership is unchanged. The expected Linux smoke digest was derived from the actual prior Linux listing plus those four platform-independent additions and still requires confirmation by this PR's CI.

Local verification covers the exact source committed in `9b2ea836317ed035a91d1fc9fcf725f70c3098e2` on main `380e98a42cb4fcd0994fed79b30c2c7605deb0bc`. An independent final-diff correctness and reliability review found no findings. Fresh Linux workspace and real mixed-version upgrade runs are required before treating the remediation as fully verified; local fake-target tests do not establish that result.

## Impact

Test scheduling and readiness only; no production behavior, API, dependency, test deadline or compatibility assertion changes. Reserving capacity serializes registration fixture processes within a nextest run. The bounded readiness probes may add startup time while peer write health converges; permanent errors still fail immediately.

## Additional Notes

Rollback by reverting this PR. Existing CI restructuring from #8233 is independent of these follow-up fixes.
2026-09-30 10:06:09 +08:00
Chris 9e33d54269 fix(release): package stable preview tags without updating channels (#8256) 2026-09-30 09:49:27 +08:00
Hauser 498080dec4 fix(storage): default new bucket durability to strict (#8253)
* fix(storage): align new bucket durability with strict defaults

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

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

* fix(scanner): defer failure recovery until scan completion

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-30 09:23:32 +08:00
Hauser 296854c3d2 test(heal): cover degraded MRF replay after deletion (#8249)
* test(heal): cover deleted versions during MRF replay

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

* test(heal): exercise degraded MRF replay after delete

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

* test(heal): route quorum errors through test storage API

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-30 09:14:02 +08:00
Jason Kossis 56468fd553 fix(scanner): resume checkpoints across cycle and leader changes (#8252) 2026-09-30 08:18:20 +08:00
Chris 50aa482136 fix(connect): add non-Unix health runtime fallback (#8247) 2026-09-30 08:10:10 +08:00
Chris e870a6d25b fix: separate CPU profile stack lifetime from accumulator (#8250) 2026-09-30 01:36:14 +08:00
Chris 5851d9eb54 fix: filter CPU profile samples to reviewed executable symbols (#8248) 2026-09-30 01:06:24 +08:00
Chris 1e7065101d feat: Capture offline CPU profiles in serving process (#8245)
feat: capture offline CPU profiles in serving process
2026-09-29 22:37:10 +08:00
Chris 380e98a42c fix(test): synchronize top-disk fixture writes with sampling (#8243) 2026-09-29 21:07:45 +08:00
Chris a88225d208 fix(ci): reduce duplicate work and preserve reliable test failures (#8233)
* fix(ci): reduce duplicate work and preserve reliable test failures

* fix(ci): retain protocol evidence and repair stale test fixtures

* test(connect): honor parent deadline during API fixture readiness

* fix(ci): reserve IO capacity for state writer proofs

* test(connect): align RPC fixtures with service capture contracts

* test(connect): cover pinned service capture failures
2026-09-29 21:06:45 +08:00
Chris 1cca0dd25c feat(connect): select offline key for client performance (#8242) 2026-09-29 20:49:59 +08:00
Chris b0a54f0e5c feat(connect): capture offline network performance in server (#8240)
* feat(connect): capture offline network performance in server

* fix(connect): use storage facade in RPC test

* fix(connect): keep RPC test body behind storage facade
2026-09-29 19:41:43 +08:00
Chris 83a2d8b02d feat(connect): capture RPC activity in offline server runtime (#8237) 2026-09-29 18:16:54 +08:00
Chris f661651aad feat(connect): capture API activity in offline server runtime (#8234) 2026-09-29 17:18:26 +08:00
Chris 9298991d14 fix(scanner): restart a finished mixed sweep under its requested plan (#8231)
A bucket sweep that ends mixed clears its position and records the finishing cycle's plan as started. The next cycle requests a new plan whenever the bucket was written in between, so a fresh sweep inherited a stale started plan and ended mixed again. On a continuously written bucket no sweep could ever certify and the census kept the old root.

Start a fresh verification sweep under the requested plan when no durable position remains. Resumed sweeps keep their started plan, so a clean tail still cannot certify an old prefix.

Refs #7108
2026-09-29 16:48:58 +08:00
Chris 3c7d85420c fix(test): control clocks in Connect network fixtures (#8228) 2026-09-29 16:48:34 +08:00
Chris 6a7880c9fc feat(connect): capture offline lock metrics in server (#8230) 2026-09-29 16:29:07 +08:00
hector 0e58bd09d0 fix(ci): run security chain evidence from the lane's own checkout (#8229)
The security workflow checks the repository out into rustfs-repo/ (#7212)
but its chain evidence steps still invoke
scripts/functional_chain_evidence.py relative to the workspace root, which
on the persistent shared runner resolves to a stale checkout left by another
job. The evidence gate then compares that checkout's HEAD against the chain
workflow_sha and rejects the lane before any case runs
("lane checkout differs from chain workflow source").

Run the evidence script from the lane's own checkout so ROOT resolves to
rustfs-repo/, whose HEAD is exactly the chain-pinned workflow_sha.
2026-09-29 16:09:31 +08:00
Hauser fcb60e7322 fix(rpc): preserve walk_dir missing-path errors (#8226)
## Related Issues

rustfs/rustfs#8217 and rustfs/backlog#2683.

## Summary of Changes

Reviewed 539ed275db against d2175d1e1e. No findings. Capable callers requesting missing-path reporting recover explicit pre-output FileNotFound or VolumeNotFound errors; partial-output and other failures continue to fail the stream.

## Verification

Two independent source reviews completed across all lenses below; the frozen diff matches Git and passes diff whitespace checks. Traced the authenticated handler, bounded first-chunk preflight, cancellation ownership, HTTP status/token classification and quorum error handling. The new tests cover ordinary streaming, typed missing errors, byte preservation and mixed missing/I/O failures.

Author-reported focused tests and Docker reproduction were not rerun or independently audited in this review. The reported Docker comparison predates the final report_notfound-only gate; its scope is not distributed acceptance of the final head.

## Impact

Correctness: no findings; missing errors remain typed only before output. Security/trust: no findings; authentication, body digest and operation/status/token restrictions remain. Compatibility: no findings; legacy clean EOF remains, with the documented old-server limitation. Concurrency/durability: no findings; receiver ownership cancels an abandoned producer. Simplicity: no findings. Coverage: no blocking gap identified. Performance: no findings; preflight retains one bounded chunk and ordinary report_notfound=false streams start immediately.

## Additional Notes

This review does not establish that the patch fixes any current main CI failure. Full main CI and release acceptance remain separate gates.
2026-09-29 15:29:59 +08:00
Hauser 0a61f4a81e fix(heal): park healthy legacy MRF intents (#8225)
## Related Issues

rustfs/backlog#2682 and rustfs/rustfs#8192.

## Summary of Changes

Reviewed ca970f55ec against d2175d1e1e. No blocking code findings. The hold requires a completed healthy legacy check and an exact incarnation, lease, object, version and scope; it preserves the durable journal and rechecks on restart.

## Verification

Two independent source reviews completed, covering the lenses below. The frozen diff matches Git and passes diff whitespace checks. Reported Cargo and Docker results were not rerun or independently audited in this review.

One factual correction to the PR description: `legacy_sigkill_replay_repairs_without_releasing_unverified_responsibility` uses the default four-member fixture, with three replicas before restoration and four afterward (`mrf_partial_write_test.rs:559,567`). That named regression exercises the production manager path, but is not EC12+4. Other tests in the file use sixteen disks.

## Impact

Correctness: no findings; proofless legacy health never becomes a verified receipt. Security/trust: no findings; exact identity fences remain. Compatibility: no findings; journal encoding remains unchanged. Concurrency/durability: no findings; replay retains one checkpointed owner and replacement generations become retryable. Simplicity: no findings. Coverage: no blocking gap identified, with the test-scope correction above. Performance: no findings; held entries leave the retry index without adding per-admission queue scans.

## Additional Notes

The current main journal failure concerns DecodeFailure, which follows the separate ECDecode task path. This review does not establish that this PR fixes that failure or the scanner-cycle failure, and does not establish a passing main CI or release gate.
2026-09-29 15:28:46 +08:00
Chris d2175d1e1e fix(test): keep bucket disk faults across reconnects (#8224) 2026-09-29 14:07:30 +08:00
Chris 2019715d1a Fix fresh capacity probes for formatted local disks (#8222) 2026-09-29 12:23:06 +08:00
Chris 31f05a44af fix(test): use expect_err for offline state root rejection (#8220) 2026-09-29 11:22:16 +08:00
Chris a1724f3dbe feat(connect): capture signed offline service health (#8219) 2026-09-29 10:50:23 +08:00
Chris c62979a45b fix(connect): secure new offline state roots (#8218) 2026-09-29 10:13:58 +08:00
Chris e14eacc5ed fix(rpc): keep legacy format reads in their original namespace (#8216) 2026-09-29 09:24:39 +08:00
Chris 47af5565c7 fix(ci): preserve cluster startup failure evidence (#8213) 2026-09-29 08:50:09 +08:00
Chris f0ce628a7f feat(connect): capture offline native threads in service process (#8215) 2026-09-29 08:48:56 +08:00
Chris 32cc1eb76c Capture offline memory profile from the running service (#8214)
feat(connect): capture offline memory from the running service
2026-09-29 08:43:25 +08:00
Chris 84fe13989e fix(ecstore): retain read quota and control reserve test hedging (#8211) 2026-09-29 07:16:31 +08:00
Chris c8f42b8dca fix(ci): isolate scanner deadline and expose test failure details (#8210)
* fix(ci): isolate scanner deadline fixture and expose readiness errors

* test(ecstore): report unexpected capacity reservation errors
2026-09-29 07:16:17 +08:00
Chris c9acf01fd0 fix(tier): reread mutation intents after lock contention (#8209) 2026-09-29 07:16:01 +08:00
Chris 87e9a84a4e fix(ecstore): preserve RPC status when cloning storage errors (#8207)
* fix(ecstore): preserve RPC status when cloning storage errors

* test(heal): retain clone coverage without redundant ownership
2026-09-29 07:15:46 +08:00
Chris af0ae798f8 fix(test): serialize replacement_bucket_metadata heal test under parallel load (#8212) 2026-09-29 07:07:31 +08:00
dependabot[bot] a2bdcf497b build(deps): bump the dependencies group with 14 updates (#8176) 2026-09-29 01:59:07 +08:00
Chris 5564932f38 fix(heal): retry cancelled internode RPC operations (#8205) 2026-09-29 01:58:35 +08:00
Chris 8778d55e46 fix(ci): preserve diagnostics for rio storage test failures (#8204) 2026-09-29 00:15:26 +08:00
Chris 5ebc1e480b test: stabilize heal timing and improve heartbeat replay coverage (#8150)
fix: preserve heartbeat replay and stabilize timing tests
2026-09-28 22:07:15 +08:00
Chris 4d782141d8 fix(test): add missing unavailable_drives field to readiness test initializers (#8200)
Commit 151103a609 added the  field to
StorageReadinessDetails but did not update two test initializers in
readiness.rs, causing E0063 compile errors in --all-targets builds.
2026-09-28 21:26:42 +08:00
Hauser 3bc36acf7f fix(notify): persist Docker queue stores and expose open errors (#8201)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-28 21:24:12 +08:00
Chris 4a91c75152 fix(ci): restore checks and multipart migration retries (#8196)
* fix(ci): restore multipart lint and full E2E membership checks

* fix(ecstore): preserve multipart completion identity during migration
2026-09-28 21:22:49 +08:00
Chris 2648776be7 fix: accept catalog files in health service artifact (#8198)
* fix: accept catalog files in health service artifact

* ci: retain configured health acceptance runner
2026-09-28 20:45:26 +08:00
RustFS 9442e89f5f fix(iam): require an explicit permission for force-delete (#8154)
* fix(iam): require an explicit permission for force-delete

A force-delete header no longer inherits s3:* or consoleAdmin. Bucket
force-delete requires s3:ForceDeleteBucket whenever the header is present,
and recursive object force-delete requires s3:ForceDeleteObject. A plain
delete keeps the existing checks.

Co-authored-by: RustFS <hello@rustfs.com>
Signed-off-by: loverustfs <155562731+loverustfs@users.noreply.github.com>

* test(e2e): keep force-delete header names static

The bucket force-delete helper must pass a static header name into the
SDK request mutator.

Co-authored-by: RustFS <hello@rustfs.com>
Signed-off-by: loverustfs <155562731+loverustfs@users.noreply.github.com>

* test(e2e): move the force-delete header into the request mutator

The SDK request customizer requires a static header name owned by the
closure.

Co-authored-by: RustFS <hello@rustfs.com>
Signed-off-by: loverustfs <155562731+loverustfs@users.noreply.github.com>

* fix(iam): keep force-delete out of NotAction grants

NotAction now uses plain wildcard matching, so NotAction "s3:*" still
excludes force-delete. An Allow statement grants s3:ForceDeleteObject or
s3:ForceDeleteBucket only when its Action list names the action; a
NotAction-only Allow never does. The rule applies to both IAM and bucket
policy statements.

Also build the invalid-header errors with S3Error::with_message to keep
the s3s footprint at its baseline, and fix a clippy single_match.

---------

Signed-off-by: loverustfs <155562731+loverustfs@users.noreply.github.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-28 19:08:33 +08:00
RustFS fc5609bbb0 fix(s3): make retried CompleteMultipartUpload idempotent (#8153)
Record the upload id on the completed object so a lost-response retry
returns that object's ETag instead of NoSuchUpload, while a different
part list or a replaced object keeps the existing errors.

Signed-off-by: loverustfs <155562731+loverustfs@users.noreply.github.com>
Co-authored-by: Hauser <housemecn@gmail.com>
2026-09-28 19:08:10 +08:00
Chris 64bd224b3b fix(connect): accept the health-era heartbeat and measured Top windows (#8195)
Two Connect tests fail on main in every Test and Lint lane.

Heartbeat: #8155 replaced "today's capabilities minus
profile.memory.service@1" with "the pre-health set minus it". That drops
the release that advertised health.check.service@1 but not yet in-service
memory profiling, so a pending heartbeat persisted by that release now
fails validation and the runtime stops. Accept that release as its own
frozen list.

Top disk/net: execute_*_job sets max_duration_millis to the requested
duration, then the capture compared the measured sleep against it. The
timer only overshoots, so a job that ran exactly as requested was
rejected with LimitExceeded whenever the overshoot reached 1 ms. Report
the authorized window, as Top API already does; validate_capture bounds
it by the limit.
2026-09-28 19:07:39 +08:00
Chris 71f8b607ad fix(ci): restore mainline and scheduled test reliability (#8187)
* fix(ci): restore mainline and scheduled test reliability

* fix(ci): provide GitHub CLI for CPU acceptance

* ci: provide Docker for CPU service acceptance

* ci: provision Python and Docker for OIDC validation

* ci: restore hosted runners for Docker validation

* ci: use verified MinIO release packages for interop

* ci: preserve host ownership of MinIO fixtures

* fix(ci): correct diagnostic limits and isolate startup checks

* test(connect): include object CLI failure details

* test(readiness): initialize unavailable drive diagnostics
2026-09-28 19:07:29 +08:00
226 changed files with 17629 additions and 2505 deletions
+24 -19
View File
@@ -1,6 +1,6 @@
---
name: rustfs-release-publish
description: "Run the RustFS console gate, source version bump, preview validation, human confirmation, final-tag publication, and post-release installation and website updates. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
description: "Run the RustFS console gate, source version bump, release-branch CI, preview validation, human confirmation, final-tag publication, and post-release installation and website updates. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
---
# RustFS Release Publish (preview-validated pipeline)
@@ -19,7 +19,9 @@ Pipeline shape:
check console main against its latest Release
-> if ahead: publish console -> wait for Release asset + latest API
-> bump Cargo.toml and Cargo.lock to <target> -> merge
-> tag <preview-tag> at that commit -> CI green
-> cut release/<target> from a selected main commit containing the bump
-> CI green for that exact release-branch commit
-> tag <preview-tag> at that commit
-> verify preview Release assets -> run binary locally + console checks
-> validate with latest rc client
-> report preview acceptance results -> STOP for explicit human confirmation
@@ -29,7 +31,7 @@ check console main against its latest Release
-> update installation references on main -> update rustfs.com announcement
```
On preview validation failure: fix lands on main via normal PR (Cargo versions are already at `<target>`, no new source bump PR), then tag `<preview-tag N+1>` at the new main commit and restart from Phase 2. Installation references remain on the previous published deliverable throughout preview validation.
On candidate or preview validation failure: fix lands on main via normal PR, then backport only the needed fix to `release/<target>` through a reviewed PR. Re-run release-branch CI at the new commit and, if a preview was already tagged, tag `<preview-tag N+1>` there and restart acceptance. Installation references remain on the previous published deliverable throughout preview validation.
## Required inputs
@@ -68,14 +70,14 @@ Rules:
- Preview Release assets are versioned and intentionally visible on the Releases page for the duration of validation. Do not label them Latest or use them to update any latest distribution channel.
- Never delete a preview Release by hand before Phase 6 finishes — Phase 4 downloads its assets and the final Release notes are generated while it still exists. Cleanup is CI's job; only step in manually (`gh release delete "<preview-tag>" --yes`, never `--cleanup-tag`) if `cleanup-preview-releases` failed.
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final. Phase 7 updates installation references on main after publication; neither tag moves to that follow-up commit.
- The release branch is the candidate source during multi-day acceptance. Do not merge main wholesale into it, force-push it, or advance it with unrelated changes. The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD or an advanced release-branch HEAD, and never create an extra version-bump commit between preview and final. Phase 7 updates installation references on main after publication; neither tag moves to that follow-up commit.
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag — cleanup runs after the notes are generated, so the preview Release is still present and would otherwise be picked as the baseline. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
- Phases run in order; a failure blocks dependent steps. For a preview/source acceptance failure before the final tag exists, land the fix, verify Cargo still matches the confirmed target, and restart from Phase 2 with the next preview at the fixed commit. If main has advanced to another target, resolve that source/version mismatch before another preview.
- Phases run in order; a failure blocks dependent steps. For a candidate or preview/source acceptance failure before the final tag exists, land the fix on main, backport it to the release branch, verify Cargo there still matches the confirmed target, and restart from release-branch CI in Phase 2. Use the next preview iteration if any preview was already tagged. Main may advance to another target without changing this release candidate.
- Once the final tag exists, preserve its commit. Retry failed publication jobs for that exact tag; do not recreate the tag or restart preview on newer main. If a source change is required after final-tag publication, report the failure and obtain a new target version. Phase 7 failures resume only the failed follow-up after rechecking artifact availability and whether a newer release has superseded it.
- Completing preview acceptance does not authorize the final tag. After Phases 3–5 pass, report the acceptance evidence and stop until the user explicitly confirms continuation. The original release request, an earlier confirmation, silence, or an automated follow-up does not satisfy this gate.
- Confirmation is scoped to the reported `<target>`, `<preview-tag>`, and `PREVIEW_HASH`. A failed or repeated acceptance cycle, including any new preview iteration, invalidates prior confirmation and requires a new one.
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision. Do not delete the release branch or preview tags as part of abandonment without an explicit decision.
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
## Phase 0 — Preflight
@@ -83,6 +85,7 @@ Rules:
- `git status --short` clean; `git fetch origin main --tags`.
- `gh auth status` works; confirm you can view `gh release list -L 3`.
- Confirm the exact final target version with the user if not explicit.
- Check whether `release/<target>` already exists before creating it; a resumed release reuses the existing branch and its history. For an in-flight release that already has a preview tag but no branch, anchor the new branch to that tag's commit only after verifying its target versions and prior acceptance evidence.
### Console release gate
@@ -90,18 +93,19 @@ Read and complete [the Console gate](references/console-gate.md) before Phase 1.
## Phase 1 — Source version bump to the final target (once)
- If Cargo.toml and all workspace members in Cargo.lock already read `<target>` (e.g. this is a restart after a failed preview), verify both and skip the source bump. Installation versions intentionally differ during preview; do not treat them as leftovers. If a previous preparation advanced installation references to an unavailable target, restore those references to the verified published baseline before selecting the next preview commit.
- Otherwise invoke the `rustfs-release-version-bump` skill with stage `prepare`, the final `<target>` (NOT a preview version), and full GitHub flow (commit/push/PR).
- Get the PR merged into main. Record the resulting main commit:
- If `release/<target>` exists, verify its Cargo.toml and workspace members in Cargo.lock read `<target>` and skip the source bump, even if main has advanced. If the branch versions differ, stop and resolve the mismatch without downgrading main.
- If no branch exists but an unfinished preview tag does, verify its commit has `<target>` in both version files and skip the bump; Phase 2 anchors the branch to that commit. Stop if those files disagree. Otherwise, if main already has `<target>` in both files, skip the bump. Installation references intentionally retain the previous published deliverable during preview; if they were advanced to an unavailable target, restore that baseline before choosing the next candidate.
- Only when neither the release branch, an unfinished preview tag, nor current main already has `<target>`: if main is at a later version, select and verify a historical commit containing `<target>` rather than downgrading main; block if none exists. Otherwise invoke the `rustfs-release-version-bump` skill with stage `prepare`, the final `<target>` (NOT a preview version), and full GitHub flow (commit/push/PR).
- If a bump PR was needed, get it merged into main and record its merge commit. For a new release branch when main already has `<target>`, identify the main commit that introduced those versions. Phase 2 chooses a release candidate containing that commit; an existing release branch keeps its own history and a newer main HEAD is not required.
```bash
git fetch origin main
PREVIEW_HASH=$(git rev-parse origin/main) # must contain the bump PR
```
Do not wait for the moving latest main HEAD to become green. The release-branch CI in Phase 2 validates the chosen commit before a preview tag is created.
`PREVIEW_HASH` is the single source of truth for the rest of the pipeline — report it to the user and reuse it verbatim in Phases 2 and 6. Both the preview tag and the final tag will point at it.
## Phase 2 — Pin and validate the release candidate, then publish the preview tag
## Phase 2 — Publish the preview tag
- Use `release/<target>`. On the first attempt, create it from a selected main commit containing the version bump and intended changes; it need not be the latest main HEAD. On a restart, retain the existing branch and backport only reviewed fixes from main. Verify the branch's Cargo versions still equal `<target>`. If adopting an existing preview, run this branch CI before continuing its acceptance; reuse the tag only when it points to the validated SHA.
- The current `ci.yml` runs automatically on main pushes, not release-branch pushes. Dispatch it explicitly with `gh workflow run ci.yml --ref "release/<target>"`. Locate that dispatch run, verify its `head_sha` equals the release branch's remote HEAD, and require the full expected CI job set to pass, including `Quick Checks`, `Test and Lint`, and `End-to-End Tests (full merge gate)`. Treat a missing or skipped required lane as incomplete. A successful PR check or CI run for a different SHA does not qualify. If the branch advances or a run is cancelled, validate the new SHA again. Do not tag a candidate with failing or incomplete CI.
- Prefer protecting `release/*` against force pushes and deletion and requiring reviewed backport PRs. Check live rulesets rather than assuming the main ruleset covers release branches; report a missing release-branch rule as a gap.
- Set `PREVIEW_HASH` to the CI-validated release-branch commit and recheck the remote branch HEAD immediately before tagging. Report the branch, commit, and CI run URL; do not derive `PREVIEW_HASH` from a later `origin/main` fetch. When adopting an existing preview tag at this SHA, skip tag creation and continue Phase 3.
```bash
git tag -a "<preview-tag>" -m "Release <preview-tag>" "$PREVIEW_HASH"
@@ -112,11 +116,12 @@ Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `d
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Docker may publish exact preview image tags. Latest-channel, R2, and Helm publication must be skipped; installation references and the website announcement stay unchanged.
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
On a restart after a backport, re-run release-branch CI before setting the new `PREVIEW_HASH`. Use the next unused preview iteration when the validated commit differs from an existing preview tag; never move an existing tag.
## Phase 3 — CI and preview Release verification
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
- Confirm the preview tag resolves to the Phase 2 CI-validated `PREVIEW_HASH`. If the release branch advances during acceptance, repeat Phases 2–5 with a new preview tag.
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
@@ -128,13 +133,13 @@ Read and complete [preview acceptance](references/preview-acceptance.md): verify
### Manual confirmation gate
After every Phase 3–5 check passes, report the target, preview tag, `PREVIEW_HASH`, preview Release URL, console result, and rc matrix, then explicitly ask the user whether to publish the final tag. End the turn without creating or pushing `<target>`.
After every Phase 3–5 check passes, verify the release branch still points to `PREVIEW_HASH`. Report the target, release branch, preview tag, `PREVIEW_HASH`, release-branch CI run, preview Release URL, console result, and rc matrix, then explicitly ask the user whether to publish the final tag. End the turn without creating or pushing `<target>`.
Continue to Phase 6 only after a new user reply explicitly confirms the reported target, preview tag, and commit. A clear affirmative reply to that exact report, such as `确认继续`, is sufficient; if the reply is ambiguous or any reported value changed, ask again.
## Phase 6 — Publish the final tag on the validated commit
No second source version bump. The final tag goes on the exact commit the preview validated:
No second source version bump. Before creating the final tag, recheck the remote `release/<target>` HEAD equals `PREVIEW_HASH`. If it moved after confirmation, the approval no longer applies; validate and obtain confirmation for a new preview. The final tag goes on the exact commit the preview validated:
```bash
git fetch origin --tags
@@ -158,7 +163,7 @@ Only after Phase 6 succeeds, complete [post-release updates](references/post-rel
Always report:
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
- Target version, release branch, release-branch CI run and validated SHA, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
- Manual confirmation gate status (`WAITING_FOR_CONFIRMATION` or `CONFIRMED`) and its exact target, preview tag, and `PREVIEW_HASH`.
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, the rc command matrix, and the preview-Release cleanup result (deleted Releases plus surviving tags).
- Post-release installation PR and verification results; website banner target, text, link, PR, and observed deployment state. Report any remaining merge authorization or failed deployment explicitly rather than claiming the banner is live.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=21ab66fd172018682ce825a53f26aa8f286d57f3f687d8616d1850e56474dcd8
sha256-darwin=21ab66fd172018682ce825a53f26aa8f286d57f3f687d8616d1850e56474dcd8
sha256-linux=6fc377fa1f9f06e065f077c7bd53d04efbccf4453f95dde67afaa47778185377
sha256-darwin=6fc377fa1f9f06e065f077c7bd53d04efbccf4453f95dde67afaa47778185377
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=9e9687ded961aa4e619a15def6e1ee0a5153be05423d009ee7a58126624c83e7
sha256-linux=364666693c2bf258997643c9d87e07e7b7501e58175f190d201d53c81946eb20
sha256-darwin=6b8e35ef69456bd244d6a6b19179e408763763e1e163aaaf90055490ae12da4b
sha256-linux=ff4c40f288ab5e91fd28d533c277538c0e3281fa353328ab52f5a8f88e0dc931
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
sha256-darwin=efe68675f4676a0d2901242c5815b1f7af5cdec8231f7cccd408f6dacf5c75db
sha256-linux=003ed6480935a0a95680cc061be143722fbd37bd477b8ff98607b1a064f3aef1
+1 -1
View File
@@ -1 +1 @@
sha256=5de6852b2e606eca4ee0d86dbb3b725a3eb8923143fd00df96c549acc36040bd
sha256=7e3989625e2e6087c2e7db7ee02d30624b566d5a99832122032fb413ff456662
+2
View File
@@ -52,6 +52,7 @@ script-tests: ## Run shell script tests
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
@@ -61,6 +62,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
+105 -16
View File
@@ -82,6 +82,13 @@ setup = 'lifecycle-large-stack'
filter = 'package(rustfs-heal) & test(=replacement_pool_metadata_follows_real_two_set_placement)'
setup = 'ecstore-large-stack'
# These real-disk scanner fixtures assert quiet-workload publication and
# durable MRF admission. Reserve the pool while their bounded CAS/fsync
# handoffs run so unrelated disk fanouts cannot consume that fixture budget.
[[profile.default.overrides]]
filter = 'package(rustfs-scanner) & (test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(=scanner_io::tests::multi_pool_scanner_cycle_publishes_combined_usage) | test(=scanner_io::tests::pending_put_commit_keeps_scanner_walk_live_without_authoritative_usage) | test(=scanner_folder::tests::mrf_ownership::checkpoint_fixture_metadata_heal_reaches_budgeted_tail))'
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
test-group = 'ecstore-serial-flaky'
@@ -107,6 +114,16 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::heal::heal_result_report_tests::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the replacement bucket metadata heal test. It builds a real-disk
# hermetic erasure set, writes bucket metadata, then asserts byte-identical
# xl.meta readback after a rejected delayed repair. Under parallel load,
# concurrent ecstore tests mutate the process-wide disk registry and alter
# the serialized timestamp/disk-identity fields, flipping the assertion.
# Preventive serialization only, no retries.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(replacement_bucket_metadata_respects_pool_ownership_and_required_records)'
test-group = 'ecstore-serial-flaky'
# Serialize the metadata-cache generation-retirement pair. Both carry
# #[serial(metadata_cache_invalidation_probe)] — a no-op across nextest's
# process boundary — and assert get_object_metadata_cache generation
@@ -132,11 +149,37 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(rustfs) & (binary(/^embedded.*_test$/) | binary(admin_diagnostic_capability_e2e))'
test-group = 'embedded-test-ports'
# Inventory delivery and real drive/object probes include durable filesystem IO
# in short deadlines. Reserve capacity so unrelated storage fixtures cannot
# exhaust those budgets. Keep the deadlines and assertions unchanged.
# Registration, inventory delivery, and real drive/object probes include
# durable filesystem IO in short deadlines. Reserve capacity so sibling test
# processes cannot exhaust those budgets. Keep deadlines and assertions intact.
[[profile.default.overrides]]
filter = 'package(rustfs) & (binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
filter = 'package(rustfs) & (binary(/^connect_registration$/) | binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
threads-required = "num-test-threads"
# This cancellation probe hashes the serving executable before acquiring its
# collector lease. Keep competing test processes out of its 30s startup budget.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(=connect::diagnostics::trace_runtime::tests::local_runtime_profile_cancellation_waits_for_lease_release)'
threads-required = "num-test-threads"
# These real IAM/state fixtures keep production lock-acquisition limits while
# peers complete durable IO. Reserve capacity from unrelated test processes;
# the shared fixture's durable_iam_state_ prefix keeps the whole family together.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(/^admin::handlers::site_replication::tests::durable_iam_state_/)'
threads-required = "num-test-threads"
# Real target-repair and rebalance-stop fixtures commit durable metadata while
# peers wait on production locks or bounded cancellation checks. Reserve IO
# capacity for each fixture; its internal races and deadlines remain intact.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(/^admin::handlers::(replication::target_repair_tests::|rebalance::rebalance_handler_tests::real_admin_stop_)/)'
threads-required = "num-test-threads"
# Reserve capacity for these short heartbeat TLS/fsync probes (rustfs#8129).
# Their existing deadlines, assertions, and retry policy remain in force.
[[profile.default.overrides]]
filter = 'package(rustfs) & binary(connect_heartbeat) & test(/^(restart_replays_pending_request_then_advances_sequence|restart_replays_exact_legacy_capabilities_with_and_without_jobs)$/)'
threads-required = "num-test-threads"
# Serialize the durable manual-transition checkpoint test across nextest's
@@ -219,6 +262,13 @@ 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$/))'
test-group = 'e2e-vault'
# These server-heavy smoke cases perform 1k+ sequential S3 writes or run the
# scanner and a remote tier server together. Reserve the full test budget so
# unrelated E2E processes cannot starve their storage and polling deadlines.
[[profile.default.overrides]]
filter = 'package(e2e_test) & (test(/^list_objects_v2_pagination_test::tests::(test_list_objects_v2_delimiter_small_page_traverses_all|test_list_objects_v2_max_keys_above_limit_returns_token|test_list_objects_v2_maxkeys_above_limit_with_delimiter)$/) | test(=reliant::tiering::test_hermetic_transition_restore_failure_expiry_and_retry))'
threads-required = "num-test-threads"
# 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]]
@@ -258,6 +308,13 @@ setup = 'lifecycle-large-stack'
filter = 'package(rustfs-heal) & test(=replacement_pool_metadata_follows_real_two_set_placement)'
setup = 'ecstore-large-stack'
# These real-disk scanner fixtures assert quiet-workload publication and
# durable MRF admission. Reserve the pool while their bounded CAS/fsync
# handoffs run so unrelated disk fanouts cannot consume that fixture budget.
[[profile.ci.overrides]]
filter = 'package(rustfs-scanner) & (test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(=scanner_io::tests::multi_pool_scanner_cycle_publishes_combined_usage) | test(=scanner_io::tests::pending_put_commit_keeps_scanner_walk_live_without_authoritative_usage) | test(=scanner_folder::tests::mrf_ownership::checkpoint_fixture_metadata_heal_reaches_budgeted_tail))'
threads-required = "num-test-threads"
# ===========================================================================
# QUARANTINE — flaky tests granted retries = 2 under the ci profile ONLY.
#
@@ -283,13 +340,6 @@ retries = 2
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes))'
test-group = 'ecstore-serial-flaky'
# QUARANTINE: OPEN rustfs#4690 — walk_dir stall-budget accounting test depends
# on producer/consumer timing windows that stretch past the budget on loaded
# CI runners (regression test for rustfs#4644; failed on a zero-Rust-diff PR).
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
retries = 2
# Serialize the relocated-pool GET resume regression under the ci profile too
# (see the matching default-profile override near the top). No longer a
# quarantine: the fixture race (rustfs#6701/rustfs#6703) was fixed by #6707,
@@ -321,6 +371,11 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::heal::heal_result_report_tests::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the replacement bucket metadata heal test under the ci profile too.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(replacement_bucket_metadata_respects_pool_ownership_and_required_records)'
test-group = 'ecstore-serial-flaky'
# Serialize the metadata-cache generation-retirement pair under the ci
# profile too (see the matching default-profile override near the top). Not a
# quarantine: no retries.
@@ -335,7 +390,30 @@ filter = 'package(rustfs) & (binary(/^embedded.*_test$/) | binary(admin_diagnost
test-group = 'embedded-test-ports'
[[profile.ci.overrides]]
filter = 'package(rustfs) & (binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
filter = 'package(rustfs) & (binary(/^connect_registration$/) | binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
threads-required = "num-test-threads"
# Preserve the cancellation probe's startup budget and assertions in CI too.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(=connect::diagnostics::trace_runtime::tests::local_runtime_profile_cancellation_waits_for_lease_release)'
threads-required = "num-test-threads"
# Keep the same IAM/state fixture capacity reservation in CI without changing the
# production lock deadline, internal concurrency, assertions, or retry policy.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(/^admin::handlers::site_replication::tests::durable_iam_state_/)'
threads-required = "num-test-threads"
# Match the real admin fixture capacity reservation without retries or changes
# to production lock deadlines, cancellation barriers, or assertions.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(/^admin::handlers::(replication::target_repair_tests::|rebalance::rebalance_handler_tests::real_admin_stop_)/)'
threads-required = "num-test-threads"
# Reserve capacity for these short heartbeat TLS/fsync probes (rustfs#8129).
# Their existing deadlines, assertions, and retry policy remain in force.
[[profile.ci.overrides]]
filter = 'package(rustfs) & binary(connect_heartbeat) & test(/^(restart_replays_pending_request_then_advances_sequence|restart_replays_exact_legacy_capabilities_with_and_without_jobs)$/)'
threads-required = "num-test-threads"
# Serialize the durable manual-transition checkpoint test under the ci profile
@@ -469,6 +547,7 @@ default-filter = """
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^reliant::tiering::/)
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
)
"""
@@ -477,13 +556,16 @@ fail-fast = false
[profile.e2e-smoke.junit]
path = "junit.xml"
# The pagination boundary cases can stall when a server/listing regression
# prevents the continuation request from completing. Keep the timeout scoped
# to those known failure modes so legitimate lifecycle/tiering waits retain
# their test-level timing budget.
# Bound the whole pagination fixture, including startup, seeding, and cleanup;
# its LIST deadline begins only after the seed objects have been written.
[[profile.e2e-smoke.overrides]]
filter = 'package(e2e_test) & test(/^list_objects_v2_pagination_test::tests::(test_list_objects_v2_delimiter_small_page_traverses_all|test_list_objects_v2_max_keys_above_limit_returns_token|test_list_objects_v2_maxkeys_above_limit_with_delimiter)$/)'
slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
threads-required = "num-test-threads"
[[profile.e2e-smoke.overrides]]
filter = 'package(e2e_test) & test(=reliant::tiering::test_hermetic_transition_restore_failure_expiry_and_retry)'
threads-required = "num-test-threads"
# ---------------------------------------------------------------------------
# e2e-repl-nightly profile — scheduled full replication e2e lane (repl-1)
@@ -693,6 +775,7 @@ default-filter = """
& !test(/^replication_extension_test::/)
& !test(/^replication_target_matrix_test::/)
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
& !test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
"""
fail-fast = false
@@ -721,3 +804,9 @@ test-group = 'e2e-cluster-nightly'
[[profile.e2e-full.overrides]]
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'
# Keep the storage-heavy smoke cases isolated in the full suite too. They
# start multiple server fixtures and issue many sequential S3 operations.
[[profile.e2e-full.overrides]]
filter = 'package(e2e_test) & (test(/^list_objects_v2_pagination_test::tests::(test_list_objects_v2_delimiter_small_page_traverses_all|test_list_objects_v2_max_keys_above_limit_returns_token|test_list_objects_v2_maxkeys_above_limit_with_delimiter)$/) | test(=reliant::tiering::test_hermetic_transition_restore_failure_expiry_and_retry))'
threads-required = "num-test-threads"
+1 -6
View File
@@ -100,12 +100,7 @@ runs:
- name: Check test wiring
shell: bash
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/test_security_workflow.py
python3 ./scripts/test_nightly_candidate.py
python3 ./scripts/check_test_wiring.py
run: python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
shell: bash
+2
View File
@@ -107,6 +107,8 @@ runs:
- name: Install cargo-nextest
if: inputs.install-test-tools == 'true'
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
with:
tool: cargo-nextest@0.9.138
- name: Setup Rust cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
@@ -1,61 +0,0 @@
# 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.
name: Architecture Migration Rules
on:
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main ]
paths:
- "ARCHITECTURE.md"
- "docs/architecture/**"
- "scripts/check_architecture_migration_rules.sh"
- ".github/workflows/architecture-migration-rules.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
architecture-migration-rules:
name: Architecture Migration Rules
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install ripgrep
uses: taiki-e/install-action@7623a79cdfecb99d681017af368ca353d9f49bb5 # v2
with:
tool: ripgrep@15.2.0
- name: Check architecture migration rules
run: ./scripts/check_architecture_migration_rules.sh
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
# open the Actions tab. Same ci-8 mechanism coverage.yml and
# e2e-replication-nightly.yml already use.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -1167,7 +1167,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+6 -6
View File
@@ -15,16 +15,16 @@
# Sole writer of the Rust dependency caches that ci.yml restores.
#
# Why this is a separate workflow rather than steps inside ci.yml: ci.yml's
# concurrency group cancels in-progress runs on main pushes, and merges land far
# faster than its 70-minute pipeline. Measured over 15 consecutive main pushes:
# concurrency group originally cancelled in-progress main runs, while merges
# landed faster than its 70-minute pipeline. Over 15 consecutive main pushes:
# 12 cancelled, 2 failed, 0 succeeded. A cancelled run never reaches
# Swatinem/rust-cache's post step (cache-on-failure does not cover cancellation),
# so the writer lanes were saving nothing and every PR paid a cold restore —
# 11.8-20.9 minutes of "Setup Rust environment" against 0.7-3.4 warm.
#
# Splitting cache writing out of the test pipeline lets ci.yml keep cancelling
# superseded runs (which is correct — nobody needs test results for a commit
# that is already three merges behind) while the caches still get written.
# Keep cache writing separate from validation: only these jobs publish the
# complete feature closure for each key. Main validation now also finishes its
# running baseline, while superseded PR attempts can still be cancelled.
#
# The group below deliberately does NOT cancel in progress; see the comment on
# it for how that bounds concurrency and why it is scoped by event.
@@ -256,7 +256,7 @@ jobs:
# belonged to it.
warm-ci-uring:
name: Warm ci-uring
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repository
+86 -48
View File
@@ -47,12 +47,12 @@ permissions:
contents: read
# Concurrency groups are scoped per event so different triggers never cancel
# each other: PR pushes cancel the previous run of that PR, main pushes keep
# latest-wins semantics among themselves, and scheduled runs always complete
# (a shared group used to let every merge kill the weekly scheduled run).
# each other. PR pushes cancel superseded attempts; main and scheduled runs
# finish so a busy merge stream cannot starve the complete baseline. For main,
# GitHub retains one running run and replaces the pending run with the latest.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -128,7 +128,7 @@ jobs:
test-and-lint:
name: Workspace Test and Lint
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -146,11 +146,9 @@ jobs:
uses: ./.github/actions/setup
with:
rust-version: stable
# Every lane in this workflow reads its cache and none writes it.
# cache-warm.yml is the sole writer for all four keys: this workflow
# cancels superseded runs on main, and a cancelled run never reaches
# rust-cache's post step, so writing from here saved nothing (12 of 15
# consecutive main-push runs were cancelled). See rustfs/backlog#1600.
# Every lane reads its cache; cache-warm.yml remains the sole writer
# with the matching feature closure. Tests never compete to replace
# shared caches, including when a PR attempt is cancelled.
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
@@ -195,13 +193,10 @@ jobs:
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
# merge path is untouched while the experiment runs.
#
# Dispatch is included because push alone cannot supply the samples:
# this workflow cancels superseded runs on main, and only 4 of the last
# 20 push-triggered Test and Lint jobs reached a terminal state — at
# that rate ten samples would take roughly fifty merges. The
# concurrency group is scoped by event_name, so a dispatched run has
# its own group and is not cancelled by merge traffic, which makes the
# sample collectable on demand rather than by waiting.
# During baseline collection, main pushes cancelled superseded runs:
# only 4 of 20 Test and Lint jobs reached a terminal state. Running
# main validations now finish; dispatch remains an independent group
# for collecting additional samples on demand.
#
# Baseline over 17 samples at 2:
# median nextest/clippy step ratio 1.95, spread 1.85-2.06. The gate-2
@@ -250,11 +245,11 @@ jobs:
mkdir -p artifacts/test-and-lint
set +e
timeout --verbose --signal=TERM --kill-after=30s 15m \
cargo test --all --doc \
cargo test --all --exclude e2e_test --doc \
2>&1 | tee artifacts/test-and-lint/doctest.log
status=${PIPESTATUS[0]}
{
echo "command=cargo test --all --doc"
echo "command=cargo test --all --exclude e2e_test --doc"
echo "exit_status=${status}"
echo "finished_at=$(date --utc --iso-8601=seconds)"
echo
@@ -281,21 +276,10 @@ jobs:
- name: Check log-analyzer rule anchors
run: ./scripts/check_log_analyzer_rules.sh
# Explicit gate for migration-critical suites. These tests already ran in
# the full nextest pass above; a single filtered nextest invocation keeps
# the named gate without rebuilding or re-running them one package at a time.
#
# The gate selects tests by name substring (data_movement / rebalance /
# decommission / source_cleanup / delete_marker), so renames can silently
# thin it. The script owns the filter expression and first verifies the
# selected-test count against the committed floor in
# .config/migration-gate-floor.txt before running the gate; renames or
# removals must update that file consciously (see the script header).
# Kept on the default profile (no --profile ci): a second --profile ci run
# would clobber target/nextest/ci/junit.xml, and none of these tests are
# quarantined so they gain nothing from the ci profile's retry overrides.
- name: Run rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh
# Preserve the migration floor and require successful execution evidence
# from the workspace run, without compiling or executing its subset again.
- name: Verify rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh evidence artifacts/test-and-lint/core-test-listing.json target/nextest/ci/junit.xml
# This gate builds into a fresh target directory to isolate the E2E root.
# Give its cold build a separate budget from workspace tests and migration proofs.
@@ -431,6 +415,7 @@ jobs:
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
- name: Run rio-v2 feature tests
shell: bash
env:
# Match the main nextest lane's #5394 link-I/O guard. A cold feature
# cache otherwise fans out enough rust-lld processes to exhaust this
@@ -440,9 +425,25 @@ jobs:
# --profile ci so the quarantine list (and its junit flaky markers)
# covers this leg too; the default profile is the local no-retry
# profile and silently ignored quarantined flakes here (rustfs#6703).
cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2
mkdir -p artifacts/test-and-lint
rm -f target/nextest/ci/junit.xml
./scripts/ci/resource_sampler.sh start nextest-rio-v2
trap './scripts/ci/resource_sampler.sh stop' EXIT
cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2 \
2>&1 | tee artifacts/test-and-lint/nextest.log
cargo test -p rustfs --doc --features rio-v2
- name: Upload rio-v2 test reports and diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: junit-test-and-lint-rio-v2-${{ github.run_number }}
path: |
target/nextest/ci/junit.xml
artifacts/test-and-lint
retention-days: 3
if-no-files-found: error
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
@@ -508,13 +509,9 @@ jobs:
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
# On a PR, one failing protocol leg is enough to know the PR is not ready,
# so stop the sibling leg instead of paying another ~40 minutes for it.
# Everywhere else (main pushes, the merge queue, the weekly schedule) keep
# the full signal: there we want to know whether swift AND sftp are broken,
# not just whichever failed first. This is the only part of the early-stop
# work that also covers fork PRs, since it needs no token.
fail-fast: ${{ github.event_name == 'pull_request' }}
# Preserve both independent results when one protocol fails, so diagnosis
# and a focused fix do not require rebuilding an interrupted sibling.
fail-fast: false
matrix:
features:
- name: swift
@@ -537,27 +534,50 @@ jobs:
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Protect Connect test home
run: chmod go-w "$(realpath "$HOME")"
- name: Prepare protocol test state
run: |
rm -f target/nextest/ci/junit.xml
chmod go-w "$(realpath "$HOME")"
- name: Run clippy with ${{ matrix.features.name }}
run: |
./scripts/ci/resource_sampler.sh start clippy
trap './scripts/ci/resource_sampler.sh stop' EXIT
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
- name: Run tests with ${{ matrix.features.name }}
id: protocol-tests
shell: bash
env:
# Keep feature-test linking under the same bounded concurrency as the
# main nextest lane; Clippy is metadata-only and needs no such limit.
CARGO_BUILD_JOBS: "2"
SAMPLER_INTERVAL_SECS: "15"
run: |
# --profile ci so the quarantine list (and its junit flaky markers)
# covers this leg too; the default profile is the local no-retry
# profile and silently ignored quarantined flakes here (rustfs#6703).
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
mkdir -p artifacts/protocol-tests
./scripts/ci/resource_sampler.sh start nextest
trap './scripts/ci/resource_sampler.sh stop' EXIT
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }} \
2>&1 | tee artifacts/protocol-tests/nextest.log
- name: Upload protocol test reports and diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: junit-test-and-lint-${{ matrix.features.name }}-${{ github.run_number }}-${{ github.run_attempt }}
path: |
target/nextest/ci/junit.xml
artifacts/protocol-tests
artifacts/test-and-lint
retention-days: 3
if-no-files-found: warn
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 30
@@ -810,6 +830,10 @@ jobs:
python3 ./scripts/check_test_wiring.py --check-profile e2e-smoke "${NEXTEST_LISTING}"
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
# Keep E2E documentation coverage with its already-built package graph.
- name: Run E2E documentation tests
run: cargo test -p e2e_test --doc
# PR smoke subset of the in-repo e2e suite (backlog#1149 ci-4). The
# profile.e2e-smoke default-filter in .config/nextest.toml is the single
# wiring mechanism for e2e tests in CI — extend that filter instead of
@@ -819,18 +843,22 @@ jobs:
env:
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
SAMPLER_INTERVAL_SECS: "15"
run: |
./scripts/ci/resource_sampler.sh start e2e-smoke
trap './scripts/ci/resource_sampler.sh stop' EXIT
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
--status-level all --final-status-level all --failure-output final
- name: Upload e2e smoke diagnostics
if: failure()
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-smoke-diagnostics-${{ github.run_number }}
path: |
${{ runner.temp }}/rustfs-e2e-smoke-logs/
${{ runner.temp }}/rustfs-e2e-smoke-list.json
artifacts/test-and-lint
if-no-files-found: warn
- name: Upload e2e smoke JUnit report
@@ -1005,6 +1033,7 @@ jobs:
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_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
RUSTFS_HEAL_CHAOS_LOG_ROOT: ${{ runner.temp }}/rustfs-heal-chaos-logs
run: python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
- name: Upload junit
@@ -1028,6 +1057,15 @@ jobs:
if-no-files-found: warn
retention-days: 7
- name: Upload heal cluster logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-full-heal-logs-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-heal-chaos-logs
if-no-files-found: warn
retention-days: 7
e2e-tests-rio-v2:
name: End-to-End Tests (rio-v2)
# Inherits the schedule/dispatch-only gate through needs: on every other
@@ -1270,7 +1308,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
@@ -38,7 +38,7 @@ permissions:
jobs:
health-service:
name: Verify bounded health service over mTLS
runs-on: ubuntu-24.04
runs-on: dind-sm-standard-2
timeout-minutes: 45
steps:
- name: Checkout exact RustFS source
@@ -172,7 +172,7 @@ jobs:
assert outer.testzip() is None
with zipfile.ZipFile(io.BytesIO(outer.read(package))) as inner:
entries = inner.infolist()
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs-cli'} and len(entries) == 2
assert {entry.filename for entry in entries} == {'rustfs', 'rustfs-cli', 'rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256'} and len(entries) == 4
assert all(regular(entry) and 0 < entry.file_size <= 1073741824 for entry in entries)
assert inner.testzip() is None
pathlib.Path('binary').mkdir()
@@ -38,7 +38,7 @@ permissions:
jobs:
profile-memory:
name: Verify signed service memory profile over mTLS
runs-on: ubuntu-24.04
runs-on: dind-sm-standard-2
timeout-minutes: 45
steps:
- name: Checkout exact RustFS source
@@ -57,7 +57,7 @@ permissions:
jobs:
profile-threads:
name: Verify Linux x86_64 profile.threads service job
runs-on: ubuntu-24.04
runs-on: dind-sm-standard-2
timeout-minutes: 45
steps:
- name: Checkout acceptance harness
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
name: Connect top.disk proxy service-job acceptance
name: Connect top.disk service-job acceptance
on:
workflow_dispatch:
@@ -38,7 +38,7 @@ on:
required: true
type: string
connect_sha:
description: Exact Connect top.disk proxy service-job acceptance harness commit
description: Exact Connect top.disk service-job acceptance harness commit
required: true
type: string
@@ -48,8 +48,8 @@ permissions:
jobs:
top-disk:
name: Verify native top.disk service job through authenticated proxy
runs-on: ubuntu-24.04
name: Verify native top.disk service job over mTLS
runs-on: dind-sm-standard-2
timeout-minutes: 45
steps:
- name: Checkout exact RustFS source
@@ -197,7 +197,7 @@ jobs:
chmod 0755 binary/rustfs
printf '%s binary/rustfs\n' "$BINARY_SHA256" | sha256sum --check --strict
- name: Run the real top.disk service job through proxy
- name: Run the real top.disk service job
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
@@ -215,8 +215,8 @@ jobs:
make -C connect-harness e2e-connected-dispatch-check
RUSTFS_BINARY="$GITHUB_WORKSPACE/binary/rustfs" \
RUSTFS_WORKTREE="$GITHUB_WORKSPACE/rustfs-source" \
CONNECT_E2E_TOP_DISK_PROXY_EVIDENCE="$GITHUB_WORKSPACE/top-disk-proxy-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=top-disk-proxy
CONNECT_E2E_TOP_DISK_EVIDENCE="$GITHUB_WORKSPACE/top-disk-service-job-evidence.json" \
make -C connect-harness e2e-connected E2E_SCENARIO=top-disk
- name: Bind and validate sanitized evidence
shell: bash
@@ -230,36 +230,22 @@ jobs:
run: |
set -euo pipefail
jq -e --arg source "$SOURCE_SHA" --arg binary "$BINARY_SHA256" --arg artifact "$ARTIFACT_ID" '
.scenario == "top-disk-proxy" and .topDisk.status == "PASS"
and .topDisk.officialArtifactId == $artifact and .topDisk.producerSource == $source
and .topDisk.producerSha256 == $binary
and .topDisk.result.provenance.sourceCommit == $source
and .topDisk.result.provenance.executableSha256 == $binary
and .topology.agentsInternalOnly == true and .topology.proxyOnlyBridge == true
and .topology.proxyCredentialsFromFiles == true
and .probes.direct.connectStatus == "000"
and .probes.noAuthentication.connectStatus == "407"
and .probes.deniedTarget.connectStatus == "403"
and .probes.wrongCa.connectStatus == "200" and .probes.wrongCa.exitCode == 60
and .probes.approved.connectStatus == "200" and .probes.approved.exitCode == 0
and .proxyLogCounts.approvedTunnels >= 3
and .ingress.jobFetchObserved == true
and .ingress.bundleReservationObserved == true
and .ingress.boundBundleCompletionObserved == true
and .ingress.jobResultObserved == true
' top-disk-proxy-service-job-evidence.json >/dev/null
.status == "PASS" and .officialArtifactId == $artifact and .producerSource == $source and .producerSha256 == $binary
and .result.provenance.sourceCommit == $source
and .result.provenance.executableSha256 == $binary
' top-disk-service-job-evidence.json >/dev/null
jq --arg run "$GITHUB_RUN_ID" --arg build "$BUILD_RUN_ID" \
--arg artifact "$ARTIFACT_ID" --arg digest "$ARTIFACT_DIGEST" \
--arg connect "$CONNECT_SHA" --arg source "$SOURCE_SHA" --arg binary "$BINARY_SHA256" \
--arg expiry "$(cat artifact-expires-at)" '
. + {workflowRunId:$run,buildRunId:$build,artifactId:$artifact,artifactDigest:$digest,artifactExpiresAt:$expiry,connectSha:$connect,sourceSha:$source,binarySha256:$binary}
' top-disk-proxy-service-job-evidence.json > top-disk-proxy-service-job-evidence.bound.json
mv top-disk-proxy-service-job-evidence.bound.json top-disk-proxy-service-job-evidence.json
' top-disk-service-job-evidence.json > top-disk-service-job-evidence.bound.json
mv top-disk-service-job-evidence.bound.json top-disk-service-job-evidence.json
- name: Upload only verified sanitized evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: connect-top-disk-proxy-service-job-evidence-${{ github.run_id }}
path: top-disk-proxy-service-job-evidence.json
name: connect-top-disk-service-job-evidence-${{ github.run_id }}
path: top-disk-service-job-evidence.json
retention-days: 14
if-no-files-found: error
+1 -1
View File
@@ -128,7 +128,7 @@ jobs:
# manual workflow_dispatch runs stay quiet so a debugging run never files a
# spurious alert.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
-48
View File
@@ -551,54 +551,6 @@ jobs:
if-no-files-found: error
retention-days: 30
# Docker Hub's overview is a separate `full_description` field that
# `docker push` never touches, so it drifted from README.md and kept
# linking to pages that no longer exist (#7995). Republish it from the
# commit the images were built from, only when images were pushed.
sync-dockerhub-description:
name: Sync Docker Hub Description
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
sparse-checkout: README.md
sparse-checkout-cone-mode: false
- name: Sync Docker Hub description
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.REGISTRY_DOCKERHUB }}
readme-filepath: ./README.md
# README.md links to docs/ and CONTRIBUTING.md by relative path;
# rewrite those to github.com URLs so they resolve on Docker Hub.
enable-url-completion: true
# Docker Hub caps `full_description` at 25,000 bytes and the action
# truncates to fit with only a warning, which would publish a README
# cut off mid-sentence. README.md is already near the cap, so read the
# published overview back and fail if it hit the limit.
- name: Check published description was not truncated
env:
REPOSITORY: ${{ env.REGISTRY_DOCKERHUB }}
run: |
limit=25000
published=$(curl -sSf "https://hub.docker.com/v2/repositories/${REPOSITORY}/" \
| jq -j '.full_description' | wc -c | tr -d ' ')
echo "Published overview: ${published} bytes (Docker Hub limit: ${limit})"
# Truncation lands within one UTF-8 character of the cap.
if [ "$published" -ge $((limit - 4)) ]; then
echo "::error file=README.md::Docker Hub overview was truncated at ${limit} bytes; trim README.md before the next release."
exit 1
fi
# Docker build summary
docker-summary:
name: Docker Build Summary
+2 -1
View File
@@ -38,6 +38,7 @@ on:
- "Cargo.lock"
- "Cargo.toml"
- ".config/nextest.toml"
- ".config/e2e-distributed-selection.txt"
- ".github/workflows/e2e-distributed.yml"
- "crates/audit/**"
- "crates/common/**"
@@ -200,7 +201,7 @@ jobs:
name: Alert on scheduled failure
needs: [distributed]
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
@@ -230,7 +230,7 @@ jobs:
# manual workflow_dispatch runs stay quiet so a debugging run never files a
# spurious alert.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -453,7 +453,7 @@ jobs:
# job fails. Alerts only for scheduled runs (backlog#1149 ci-8) — manual
# dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+62 -17
View File
@@ -51,8 +51,53 @@ env:
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
build:
name: Build upgrade candidate
runs-on: sm-standard-2
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-server
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
run: |
mkdir -p target/debug
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
- name: Build current RustFS binary
run: python3 scripts/e2e_binary.py build
- name: Upload verified upgrade candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-candidate-${{ github.run_id }}
path: |
target/debug/rustfs
target/debug/rustfs.e2e.json
target/debug/${{ env.UPGRADE_SOURCE_ASSET }}
if-no-files-found: error
retention-days: 3
compression-level: 0
overwrite: true
upgrade:
name: ${{ matrix.name }}
needs: build
strategy:
fail-fast: false
matrix:
@@ -63,31 +108,24 @@ jobs:
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
# single source of truth for which release they actually run against.
- name: Direct upgrade from the previous release
cache_key: e2e-direct-upgrade
test: direct_upgrade_from_rc2_preserves_object_contracts
artifact: direct-upgrade
- name: Mixed-version rolling upgrade from the previous release
cache_key: e2e-mixed-version-upgrade
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
artifact: mixed-version-upgrade
- name: Bucket configuration survives the upgrade
cache_key: e2e-bucket-config-upgrade
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
artifact: bucket-config-upgrade
- name: Rollback reads current bucket metadata
cache_key: e2e-bucket-config-rollback
test: rollback_to_previous_release_reads_current_bucket_metadata
artifact: bucket-config-rollback
- name: ODM configuration recovery after rc.5 rollback
cache_key: e2e-odm-config-rollback
test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback
- name: Multipart layouts survive the rc.5 upgrade
cache_key: e2e-multipart-layout-upgrade
test: direct_upgrade_from_rc5_preserves_multipart_layouts
artifact: multipart-layout-upgrade
- name: rc.5 multipart replication baseline
cache_key: e2e-multipart-layout-baseline
test: rc5_baseline_replicates_multipart_layouts
artifact: multipart-layout-baseline
runs-on: sm-standard-2
@@ -103,19 +141,28 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: ${{ matrix.cache_key }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
cache-shared-key: e2e-upgrade-tests
# One main-branch consumer warms the shared test-harness dependencies.
cache-save-if: ${{ github.ref == 'refs/heads/main' && matrix.artifact == 'direct-upgrade' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
- name: Download verified upgrade candidate
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: upgrade-candidate-${{ github.run_id }}
path: target/debug
- name: Restore candidate executable bit
run: chmod +x target/debug/rustfs
- name: Verify and unpack pinned previous release
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
archive="target/debug/$UPGRADE_SOURCE_ASSET"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
@@ -123,14 +170,12 @@ jobs:
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
echo "RUSTFS_E2E_LOG_DIR=$RUNNER_TEMP/rustfs-upgrade-logs" >> "$GITHUB_ENV"
- name: Build current RustFS binary
run: |
python3 scripts/e2e_binary.py build
- name: Run upgrade compatibility test
env:
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
NO_PROXY: 127.0.0.1,localhost,::1
run: |
export no_proxy="$NO_PROXY"
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
"upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture
+1 -1
View File
@@ -240,7 +240,7 @@ jobs:
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
# ci-8); PR and manual dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+3 -1
View File
@@ -145,7 +145,9 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: helm-package
path: helm/rustfs/*.tgz
path: |
helm/rustfs/*.tgz
helm/rustfs/README.md
retention-days: 1
publish-helm-package:
+3 -3
View File
@@ -60,9 +60,9 @@ permissions:
jobs:
minio-interop:
name: MinIO interop (EC + SSE read parity)
# Skip on forks: needs the repo's runners and is not a contributor gate.
# Keep this scheduled interoperability lane scoped to the upstream repo.
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 40
env:
# Fixed 32-byte test KMS key baked into the fixture lab; not a secret.
@@ -131,7 +131,7 @@ jobs:
name: Alert on scheduled failure
needs: [minio-interop]
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -258,7 +258,7 @@ jobs:
# job fails. Alerts only for scheduled runs (backlog#1149 ci-8) — manual
# dispatch failures are already watched by a human.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -498,7 +498,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+7 -2
View File
@@ -51,7 +51,7 @@ concurrency:
jobs:
oidc-keycloak-live:
name: OIDC Keycloak live gate
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -72,6 +72,11 @@ jobs:
- name: Build RustFS
run: cargo build --locked -p rustfs --bin rustfs
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install pinned request signer
run: |
python3 -m pip install --user --upgrade pip "awscurl==0.44"
@@ -95,7 +100,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(needs.oidc-keycloak-live.result == 'failure' || needs.oidc-keycloak-live.result == 'cancelled')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
@@ -69,10 +69,9 @@ env:
jobs:
minio-source:
name: MinIO source (read-through, list-through, backfill)
# Skip on forks: needs this repository's runners and is not a contributor
# gate.
# Keep this scheduled interoperability lane scoped to the upstream repo.
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 90
env:
NO_PROXY: 127.0.0.1,localhost
@@ -108,11 +107,15 @@ jobs:
run: |
set -euo pipefail
mkdir -p artifacts/odm-interop/minio
docker build -t rustfs-odm-interop-minio \
-f crates/rio-v2/tests/minio_fixture_lab/Dockerfile \
crates/rio-v2/tests/minio_fixture_lab
docker run -d --name rustfs-odm-interop-minio \
--entrypoint /usr/local/bin/minio \
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
-p 9100:9000 \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
rustfs-odm-interop-minio server /data
for _ in $(seq 1 120); do
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
sleep 1
@@ -300,7 +303,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+2 -2
View File
@@ -123,7 +123,7 @@ jobs:
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
if [[ -n "$TAG" ]]; then
if [[ "$TAG" =~ ^${semver_core}-${prerelease_id}-preview\.(0|[1-9][0-9]*)$ ]]; then
if [[ "$TAG" =~ ^${semver_core}(-${prerelease_id})?-preview\.(0|[1-9][0-9]*)$ ]]; then
BUILD_TYPE=preview
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
BUILD_TYPE=prerelease
@@ -504,7 +504,7 @@ jobs:
retention-days: 30
- name: Upload packages to Cloudflare R2
if: env.R2_ACCESS_KEY_ID != ''
if: needs.resolve.outputs.build_type != 'preview' && env.R2_ACCESS_KEY_ID != ''
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+1 -1
View File
@@ -366,7 +366,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
# scheduled runs file a tracking issue, manual dispatch stays quiet so
# debugging never produces a spurious alert.
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+2 -2
View File
@@ -106,7 +106,7 @@ jobs:
if: ${{ inputs.chain_manifest != '' }}
env:
CHAIN_MANIFEST: ${{ inputs.chain_manifest }}
run: python3 scripts/functional_chain_evidence.py consume
run: python3 rustfs-repo/scripts/functional_chain_evidence.py consume
- name: Checkout auto-testing scripts
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -389,7 +389,7 @@ jobs:
CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}
CHAIN_REPORT_OUTCOME: ${{ steps.report.outcome }}
run: >-
python3 scripts/functional_chain_evidence.py record --suite security
python3 rustfs-repo/scripts/functional_chain_evidence.py record --suite security
--report "${SECURITY_ARTIFACTS_DIR}/suite-report.md"
--output "${RUNNER_TEMP}/chain-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/security.json"
@@ -58,7 +58,7 @@ jobs:
# Mirrors the consumer wiring, minus the schedule-event guard (this
# workflow is dispatch-only by design).
if: always() && contains(needs.*.result, 'failure')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
+1 -1
View File
@@ -177,7 +177,7 @@ jobs:
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
Generated
+166 -178
View File
@@ -685,9 +685,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.48"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
dependencies = [
"compression-codecs",
"compression-core",
@@ -771,17 +771,6 @@ dependencies = [
"url",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-rs"
version = "0.8.12"
@@ -987,9 +976,9 @@ dependencies = [
[[package]]
name = "aws-sdk-kms"
version = "1.121.0"
version = "1.122.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9febf0461c7441895de457dcff49dd7fa8f013793367f6b81d422c64e939e1cc"
checksum = "ae92186214a60f1316f6e25df2b5bef8f67b5b08b45f8e3eb6e1278b9ddd7b29"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1012,9 +1001,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.149.0"
version = "1.151.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d48257fb0eb1907a02a965ff7d1b910770a6de2c59a2d03461678df989c5cdcd"
checksum = "c82d4b41fe41bfca3c9e1de55645d9a525868ef5ce5e14cee9499ccaf1fc7225"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1048,9 +1037,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.112.0"
version = "1.113.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d59c47556b283bb2d20fc0b416df7d9d9a3d6f5fa0e77867167a3f2efc23b9"
checksum = "87184f36ade012f46cdc773c7be10031b4545a1aea70a9a22a044e8f36f1e5c5"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1073,9 +1062,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.114.0"
version = "1.115.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eceb9f31b46ba6da21f666e456395624824a2a086caed940b13d78832fa3ee02"
checksum = "84edc7495d56e006dff0031cbc9782784902456f7024651c2da902a3d2336262"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1098,9 +1087,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.117.0"
version = "1.118.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9291df9ea767ab5e77d08a53f4f22daa917909a34ed7a22c1a4b82d193a423ad"
checksum = "df7572569d018ca72923073494eac3c4671a0daea90b9e3923c2d27a3a02a2c1"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1193,9 +1182,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.0"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -1215,9 +1204,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8"
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
dependencies = [
"aws-smithy-async",
"aws-smithy-protocol-test",
@@ -1297,9 +1286,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95"
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -1321,9 +1310,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.17.0"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf162725183ec0df77a9dc82ce22d9ba0b6ea7f376e8ec4f6b695727c70698"
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -1360,9 +1349,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.8.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16ecf7989e381d0cbb7ca06104140d940bf38d8eb9004bd3a12d23df03e3d363"
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
dependencies = [
"base64-simd",
"bytes",
@@ -1544,9 +1533,9 @@ dependencies = [
[[package]]
name = "bigdecimal"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695"
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
dependencies = [
"autocfg",
"libm",
@@ -1746,7 +1735,7 @@ checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
"brotli-decompressor 6.0.0",
"brotli-decompressor 6.0.1",
]
[[package]]
@@ -1761,9 +1750,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "6.0.0"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46087801e40b785410c352bee60e9b6226312709cf52d5fff92776cdf5cf6490"
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
@@ -1971,9 +1960,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.7"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2209,11 +2198,11 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.43"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
dependencies = [
"brotli 8.0.4",
"brotli 9.0.0",
"bzip2",
"compression-core",
"flate2",
@@ -4217,13 +4206,12 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.41"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion",
"multiversion_no_op",
"rustversion",
"scopeguard",
@@ -4406,9 +4394,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "findshlibs"
@@ -4756,9 +4744,9 @@ dependencies = [
[[package]]
name = "google-cloud-auth"
version = "1.16.0"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff461519b1a948200f163574be072753bcfb462a323f0eb426629d89872dd685"
checksum = "ac9870b8ea484519aa8601e14886f2efc148ea490034e43d04bef1d246170cc9"
dependencies = [
"async-trait",
"aws-lc-rs",
@@ -4785,9 +4773,9 @@ dependencies = [
[[package]]
name = "google-cloud-gax"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5615cff28ee59cfe52fbb4c11b8b1e77f650296e2ea4f4c2b7757ac6b19e752"
checksum = "fc64ee071c0d22cb68662b454f1bea5424506cafeaddc51260fbc585bac1d29d"
dependencies = [
"bytes",
"futures",
@@ -4805,9 +4793,9 @@ dependencies = [
[[package]]
name = "google-cloud-gax-internal"
version = "0.7.18"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2766757d877a7a8ac23da9884cb0e3f10ed9b75a0ce59801ce6b19bf9d5819e"
checksum = "e873f7e6e39030c07b6d9697bf3b999a269175c8b433e058f56ae10d129f12f7"
dependencies = [
"bytes",
"futures",
@@ -4844,9 +4832,9 @@ dependencies = [
[[package]]
name = "google-cloud-iam-v1"
version = "1.12.0"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f962b40234b1531e6ef73f7558871c96e117231e962086c98804323fb8d2c82"
checksum = "48f0895fa7b7bd70629ae58daaa962677e93a72c251b2f297bc058a035940821"
dependencies = [
"async-trait",
"bytes",
@@ -4862,9 +4850,9 @@ dependencies = [
[[package]]
name = "google-cloud-longrunning"
version = "1.13.0"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c0363c5389ffda2b55cd8a86eef4b19a3481a48467c91dc5d77f626a9572766"
checksum = "e8ea252eee22a4e2187025ea6789c682a0d791a9748ea99de2abec3026a32cb4"
dependencies = [
"async-trait",
"bytes",
@@ -4880,9 +4868,9 @@ dependencies = [
[[package]]
name = "google-cloud-lro"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47af3deef75c14a2983c430898d960c765bddbcc9f9188ca0563108e9227cfe7"
checksum = "dfb938b40ede9ef70c24f49c2687d84f289c9e9104ddfece739985e4b4844b8f"
dependencies = [
"google-cloud-gax",
"google-cloud-gax-internal",
@@ -4909,9 +4897,9 @@ dependencies = [
[[package]]
name = "google-cloud-storage"
version = "1.18.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973399251b245c63f1d02d0768772833dcf1372ee358f593fb9159de8fe9c7d4"
checksum = "570eb8d0784118a5f4ae1219fd76ff3b14e90b7b0f877478c02b1374e9bbcb8c"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -4962,11 +4950,11 @@ dependencies = [
[[package]]
name = "google-cloud-wkt"
version = "1.7.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7"
checksum = "7f3037907e542cf8b8580aa4cf8ad68568d7ac8e7274519a7d0a121867d434da"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"serde",
"serde_json",
@@ -5096,9 +5084,9 @@ dependencies = [
[[package]]
name = "headers"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -5283,9 +5271,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.26.0"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37234f85a8d1bda6b84d486245dd0847c5b33cca10d5f3691375e66704de0f8"
checksum = "d8345e171844f80e5ae4cbed8bc8276e094f6ff2293a6f8e547c200183976674"
dependencies = [
"arc-swap",
"async-channel",
@@ -5297,6 +5285,7 @@ dependencies = [
"futures-core",
"futures-sink",
"hdrhistogram",
"hotpath-drain",
"hotpath-macros",
"http 1.5.0",
"libc",
@@ -5310,15 +5299,22 @@ dependencies = [
"rustc-demangle",
"serde",
"serde_json",
"time",
"tiny_http",
"tokio",
]
[[package]]
name = "hotpath-macros"
version = "0.26.0"
name = "hotpath-drain"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac39b5517f540cab511bdbd5a4dc8cfc62858d6cd26a095463c1d2acfb3f290"
checksum = "e149b660b611650bdf3307941ef3e9b81be3c30ed1dfdbe7f670319674f023a6"
[[package]]
name = "hotpath-macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf73e28fe4a7595c322e4265c1608cb10c215cbbce11e49cfcc258766ba686d6"
dependencies = [
"proc-macro2",
"quote",
@@ -5459,16 +5455,17 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
"http 1.5.0",
"http-body",
"httparse",
"hyper",
"ipnet",
"libc",
@@ -5905,9 +5902,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
@@ -6037,9 +6034,9 @@ dependencies = [
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [
"spin 0.9.9",
]
@@ -6202,9 +6199,9 @@ dependencies = [
[[package]]
name = "libunftp"
version = "0.23.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9f8ff371890db2cf65a0758dba9a79f9cd965de369f6dbdc6581a22780af45e"
checksum = "3dc54288ecbf8beefa5cab3a9921ab03bf1f18a3a0872cbcdb5313fc0baebaeb"
dependencies = [
"async-trait",
"bitflags 2.13.2",
@@ -6213,7 +6210,7 @@ dependencies = [
"dashmap",
"derive_more",
"futures-util",
"getrandom 0.3.4",
"getrandom 0.4.3",
"lazy_static",
"moka",
"nix 0.30.1",
@@ -6699,27 +6696,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "multiversion"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
dependencies = [
"multiversion-macros",
]
[[package]]
name = "multiversion-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 3.0.6",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -7221,12 +7197,21 @@ version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"crc32fast",
"flate2",
"memchr",
"ruzstd",
]
[[package]]
name = "object"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c"
dependencies = [
"crc32fast",
"hashbrown 0.17.1",
"indexmap 2.14.2",
"memchr",
"ruzstd",
]
[[package]]
@@ -7325,9 +7310,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.32.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
dependencies = [
"futures-core",
"futures-sink",
@@ -7339,9 +7324,9 @@ dependencies = [
[[package]]
name = "opentelemetry-appender-tracing"
version = "0.32.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c0080f0dc1d7c786f467cd85a4e395fcab11ee852004f39a29a18ab7c25d837"
checksum = "e2b304e8d8f11326ba366a99ca5c7aaf7cd397b67268a332437175aeb42a40db"
dependencies = [
"opentelemetry",
"tracing",
@@ -7352,9 +7337,9 @@ dependencies = [
[[package]]
name = "opentelemetry-http"
version = "0.32.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
dependencies = [
"async-trait",
"bytes",
@@ -7365,12 +7350,13 @@ dependencies = [
[[package]]
name = "opentelemetry-otlp"
version = "0.32.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35"
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
dependencies = [
"flate2",
"http 1.5.0",
"httpdate",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
@@ -7382,9 +7368,9 @@ dependencies = [
[[package]]
name = "opentelemetry-proto"
version = "0.32.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
@@ -7393,15 +7379,15 @@ dependencies = [
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.32.1"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47"
checksum = "d288b1e1bc3590052fbb8f0f635ef248dd9022cf1fbaeb6582b7ad6081a17b87"
[[package]]
name = "opentelemetry-stdout"
version = "0.32.0"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1b1c6a247d79091f0062a5f4bd058589525cf987a8d4c169440d9c1be72f0ad"
checksum = "9f71803f481d3f5ccbe477edb42a78f473613712b2327835911a9ea0ab069824"
dependencies = [
"chrono",
"opentelemetry",
@@ -7410,9 +7396,9 @@ dependencies = [
[[package]]
name = "opentelemetry_sdk"
version = "0.32.1"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
dependencies = [
"futures-channel",
"futures-executor",
@@ -7907,9 +7893,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113"
[[package]]
name = "piper"
@@ -8441,9 +8427,9 @@ dependencies = [
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.1"
version = "22.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867"
dependencies = [
"pulldown-cmark",
]
@@ -8678,9 +8664,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.18"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -8701,9 +8687,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
@@ -8946,9 +8932,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.7.0"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
dependencies = [
"arc-swap",
"arcstr",
@@ -9384,9 +9370,9 @@ dependencies = [
[[package]]
name = "russh-sftp"
version = "3.0.0"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101"
checksum = "9bb17f72aba4314656b359ffba72c68dcbd70ed9e97497898b75498fe7198042"
dependencies = [
"bitflags 2.13.2",
"bytes",
@@ -9521,6 +9507,7 @@ dependencies = [
"metrics-util",
"mime_guess",
"moka",
"object 0.40.0",
"opentelemetry",
"opentelemetry-proto",
"opentelemetry_sdk",
@@ -9600,6 +9587,7 @@ dependencies = [
"snap",
"socket2",
"subtle",
"symbolic-demangle",
"sysinfo",
"temp-env",
"tempfile",
@@ -9705,7 +9693,7 @@ name = "rustfs-connect-symbol-catalog"
version = "1.0.1"
dependencies = [
"hex-simd",
"object 0.39.1",
"object 0.40.0",
"serde",
"serde_json",
"sha2 0.11.0",
@@ -9763,7 +9751,6 @@ version = "1.0.1"
dependencies = [
"arc-swap",
"async-channel",
"async-recursion",
"async-trait",
"aws-credential-types",
"aws-sdk-s3",
@@ -9964,6 +9951,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tokio-util",
"tonic",
"tracing",
"uuid",
"walkdir",
@@ -10727,7 +10715,6 @@ dependencies = [
name = "rustfs-s3select-query"
version = "1.0.1"
dependencies = [
"async-recursion",
"async-trait",
"bytes",
"datafusion",
@@ -11135,9 +11122,9 @@ dependencies = [
[[package]]
name = "rustls-connector"
version = "0.23.8"
version = "0.23.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1babecfcc65b139b812e74bcc7f9ec7b4e00db659fd42d99567b7e77f0c714c6"
checksum = "524994fd6957827c775abc941f41a146ce351bbd0a9b8cf632ee37f43a13c205"
dependencies = [
"futures-io",
"futures-rustls",
@@ -11172,9 +11159,9 @@ dependencies = [
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
@@ -11193,9 +11180,9 @@ dependencies = [
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
@@ -11656,9 +11643,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
@@ -11677,9 +11664,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -11914,9 +11901,9 @@ dependencies = [
[[package]]
name = "siphasher"
version = "1.0.3"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "sketches-ddsketch"
@@ -11966,9 +11953,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
dependencies = [
"serde",
]
@@ -12248,9 +12235,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "suppaftp"
version = "12.0.1"
version = "12.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba93b140f4d07f1b3f3fb4d7264b2d422d5c8b4e925eb6a2646e1342d5d2070a"
checksum = "b161d71217da0cc91bd09c8f98ac5ad2a5e5b2735c6dfe1c6cb08b4471612f35"
dependencies = [
"async-trait",
"chrono",
@@ -12741,9 +12728,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.5"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
@@ -12763,9 +12750,9 @@ dependencies = [
[[package]]
name = "tokio-test"
version = "0.4.5"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545"
checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2"
dependencies = [
"futures-core",
"tokio",
@@ -13053,9 +13040,9 @@ dependencies = [
[[package]]
name = "tracing-opentelemetry"
version = "0.33.0"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
dependencies = [
"js-sys",
"opentelemetry",
@@ -13416,9 +13403,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -13429,19 +13416,20 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.78"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -13449,9 +13437,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -13462,9 +13450,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
@@ -13484,9 +13472,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -13992,9 +13980,9 @@ dependencies = [
[[package]]
name = "xxhash-rust"
version = "0.8.18"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yansi"
@@ -14025,9 +14013,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
@@ -14037,18 +14025,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.58"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.58"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
+30 -30
View File
@@ -146,8 +146,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.1" }
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.48" }
async-recursion = "1.1.1"
async-compression = { version = "0.4.50" }
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9"
@@ -160,7 +159,7 @@ pulsar = { default-features = false, version = "6.9.0" }
lapin = { default-features = false, version = "4.12.0" }
hyper = { version = "1.11.1" }
hyper-rustls = { default-features = false, version = "0.27.10" }
hyper-util = { version = "0.1.20" }
hyper-util = { version = "0.1.21" }
http = "1.5.0"
http-body = "1.1.0"
http-body-util = "0.1.5"
@@ -170,9 +169,9 @@ reqwest = "0.13.5"
rustfs-kafka-async = { version = "1.3.1" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.1" }
tokio-rustls = { default-features = false, version = "0.26.5" }
tokio-rustls = { default-features = false, version = "0.26.6" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.5"
tokio-test = "0.4.6"
tokio-util = { version = "0.7.19" }
tonic = { version = "0.14.6" }
tonic-prost = { version = "0.14.6" }
@@ -245,13 +244,13 @@ atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.121.0" }
aws-sdk-s3 = { default-features = false, version = "1.149.0" }
aws-sdk-sts = { default-features = false, version = "1.117.0" }
aws-sdk-kms = { default-features = false, version = "1.122.0" }
aws-sdk-s3 = { default-features = false, version = "1.151.0" }
aws-sdk-sts = { default-features = false, version = "1.118.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.2" }
aws-smithy-runtime-api = { version = "1.17.0" }
aws-smithy-types = { version = "1.8.0" }
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
aws-smithy-runtime-api = { version = "1.19.0" }
aws-smithy-types = { version = "1.8.1" }
base64-simd = "0.8.0"
brotli = "9.0.0"
clap = { version = "4.6.7" }
@@ -269,8 +268,8 @@ enumset = "1.1.14"
faster-hex = "1.0.0"
flate2 = "1.1.10"
glob = "0.3.4"
google-cloud-storage = "1.18.0"
google-cloud-auth = "1.16.0"
google-cloud-storage = "1.20.0"
google-cloud-auth = "1.17.0"
hashbrown = { version = "0.17.1" }
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
# every authenticator app expects). Already in the graph transitively.
@@ -279,7 +278,7 @@ hex-simd = "0.8.0"
highway = { version = "1.3.0" }
hostname = "0.4.2"
ipnetwork = { version = "0.21.1" }
lazy_static = "1.5.0"
lazy_static = "1.5.1"
libc = "0.2.189"
libsystemd = "0.7.2"
local-ip-address = "0.6.13"
@@ -293,6 +292,7 @@ moka = { version = "0.12.16" }
netif = "0.1.6"
num_cpus = { version = "1.17.0" }
nvml-wrapper = "0.13.0"
object = { version = "=0.40.0", default-features = false }
parking_lot = "0.12.5"
path-absolutize = "4.0.1"
percent-encoding = "2.3.2"
@@ -309,7 +309,7 @@ rustfs-erasure-codec = { version = "9.0.0" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.7.0" }
redis = { version = "1.7.1" }
rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.5" }
rust-embed = { version = "8.12.0" }
@@ -318,8 +318,8 @@ s3s = { version = "0.17.0", features = ["minio"] }
s3s-sigv4 = "0.17.0"
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
smallvec = { version = "1.16.1" }
siphasher = "1.0.4"
smallvec = { version = "1.16.2" }
compact_str = "0.10.0"
snap = "1.1.2"
starshard = { version = "2.3.0" }
@@ -333,7 +333,7 @@ tracing = { version = "0.1.44" }
tracing-appender = "0.2.5"
tracing-core = "0.1.36"
tracing-error = "0.2.1"
tracing-opentelemetry = { version = "0.33" }
tracing-opentelemetry = { version = "0.34" }
tracing-subscriber = { version = "0.3.23" }
transform-stream = "0.3.1"
url = "2.5.8"
@@ -345,7 +345,7 @@ walkdir = "2.5.0"
winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.18" }
xxhash-rust = { version = "0.8.19" }
zip = "8.6.0"
zstd = "0.14.0"
@@ -353,22 +353,22 @@ zstd = "0.14.0"
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.2", default-features = false }
opentelemetry = { version = "0.32.0" }
opentelemetry-appender-tracing = { version = "0.32.0" }
opentelemetry-otlp = { version = "0.32.0" }
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
opentelemetry_sdk = { version = "0.32.1" }
opentelemetry-semantic-conventions = { version = "0.32.1" }
opentelemetry-stdout = { version = "0.32.0" }
opentelemetry = { version = "0.33.0" }
opentelemetry-appender-tracing = { version = "0.33.0" }
opentelemetry-otlp = { version = "0.33.0" }
opentelemetry-proto = { version = "0.33.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
opentelemetry_sdk = { version = "0.33.0" }
opentelemetry-semantic-conventions = { version = "0.33.0" }
opentelemetry-stdout = { version = "0.33.0" }
pyroscope = { version = "2.1.1" }
# FTP and SFTP
libunftp = { version = "0.23.0" }
libunftp = { version = "0.23.1" }
unftp-core = "0.1.0"
suppaftp = { version = "12.0.1" }
suppaftp = { version = "12.1.1" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.63.3" }
russh-sftp = "3.0.0"
russh-sftp = "3.0.1"
# WebDAV
dav-server = "0.11.0"
@@ -376,7 +376,7 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.6" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.26.0", default-features = false }
hotpath = { version = "0.28.0", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
+3 -1
View File
@@ -106,7 +106,9 @@ RUN chmod +x /usr/bin/rustfs /entrypoint.sh
RUN addgroup -g 10001 -S rustfs && \
adduser -u 10001 -G rustfs -S rustfs -D && \
mkdir -p /data /logs && \
mkdir -p /data/.rustfs/events /data/.rustfs/audit /logs /opt/rustfs && \
ln -s /data/.rustfs/events /opt/rustfs/events && \
ln -s /data/.rustfs/audit /opt/rustfs/audit && \
chown -R rustfs:rustfs /data /logs && \
chmod 0750 /data /logs
+3 -1
View File
@@ -44,7 +44,9 @@ RUN set -eux; \
WORKDIR /app
RUN set -eux; \
mkdir -p /data /logs; \
mkdir -p /data/.rustfs/events /data/.rustfs/audit /logs /opt/rustfs; \
ln -s /data/.rustfs/events /opt/rustfs/events; \
ln -s /data/.rustfs/audit /opt/rustfs/audit; \
chown -R rustfs:rustfs /data /logs /app; \
chmod 0750 /data /logs
+3 -1
View File
@@ -110,7 +110,9 @@ RUN chmod +x /usr/bin/rustfs /entrypoint.sh
RUN groupadd -g 10001 rustfs && \
useradd -u 10001 -g rustfs -m -s /sbin/nologin rustfs && \
mkdir -p /data /logs && \
mkdir -p /data/.rustfs/events /data/.rustfs/audit /logs /opt/rustfs && \
ln -s /data/.rustfs/events /opt/rustfs/events && \
ln -s /data/.rustfs/audit /opt/rustfs/audit && \
chown -R rustfs:rustfs /data /logs && \
chmod 0750 /data /logs
+3 -1
View File
@@ -240,7 +240,9 @@ WORKDIR /app
# Prepare data/log directories with sane defaults
RUN set -eux; \
mkdir -p /data /logs; \
mkdir -p /data/.rustfs/events /data/.rustfs/audit /logs /opt/rustfs; \
ln -s /data/.rustfs/events /opt/rustfs/events; \
ln -s /data/.rustfs/audit /opt/rustfs/audit; \
chown -R rustfs:rustfs /data /logs /app; \
chmod 0750 /data /logs
+1 -1
View File
@@ -195,7 +195,7 @@ docker run -d --name rustfs -p 9000:9000 \
Notes:
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout.
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
+129 -6
View File
@@ -163,6 +163,15 @@ pub struct MrfDurableRepairAnchor {
pub bucket_incarnation_id: Uuid,
}
/// A completed deep check proved that a legacy object is present and healthy,
/// but the format has no independent identity commitment that can discharge a
/// durable partial-write responsibility. Keep the journal entry and pause
/// automatic retries until an operator has repaired the source of truth.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrfUnverifiedLegacyEvent {
pub anchor: MrfDurableRepairAnchor,
}
impl MrfDurableRepairAnchor {
/// Build a dischargeable anchor only when the caller supplies the storage
/// incarnation and the original ingress lease. Legacy replay records lack
@@ -324,6 +333,9 @@ pub enum MrfDurableAdmissionError {
/// cancel repair.
pub struct MrfDurableSubmission {
pub intent: MrfIntent,
/// Bucket incarnation observed by the committed-write caller, when
/// available. Old journal records and legacy callers remain unbound.
pub source_bucket_incarnation_id: Option<Uuid>,
pub response: oneshot::Sender<Result<(), MrfDurableAdmissionError>>,
}
@@ -344,6 +356,19 @@ pub async fn persist_partial_write_intent(
version_id: Option<Uuid>,
scope: MrfScope,
) -> Result<(), MrfDurableAdmissionError> {
persist_partial_write_intent_with_incarnation(bucket, object, version_id, scope, None).await
}
pub async fn persist_partial_write_intent_with_incarnation(
bucket: &str,
object: &str,
version_id: Option<Uuid>,
scope: MrfScope,
source_bucket_incarnation_id: Option<Uuid>,
) -> Result<(), MrfDurableAdmissionError> {
if source_bucket_incarnation_id.is_some_and(|incarnation| incarnation.is_nil()) {
return Err(MrfDurableAdmissionError::InvalidIdentity);
}
let intent = MrfIntent {
bucket: Arc::from(bucket),
object: Arc::from(object),
@@ -355,7 +380,7 @@ pub async fn persist_partial_write_intent(
enqueued_at_ms: unix_now_ms(),
attempts: 0,
};
persist_durable_intent(intent).await
persist_durable_intent(intent, source_bucket_incarnation_id).await
}
pub async fn persist_delete_marker_purge_intent(
@@ -368,6 +393,7 @@ pub async fn persist_delete_marker_purge_intent(
if version_id.is_nil() {
return Err(MrfDurableAdmissionError::InvalidIdentity);
}
let source_bucket_incarnation_id = delete_marker_purge.bucket_incarnation_id;
let intent = MrfIntent {
bucket: Arc::from(bucket),
object: Arc::from(object),
@@ -379,17 +405,23 @@ pub async fn persist_delete_marker_purge_intent(
enqueued_at_ms: unix_now_ms(),
attempts: 0,
};
persist_durable_intent_unconditionally(intent).await
persist_durable_intent_unconditionally(intent, Some(source_bucket_incarnation_id)).await
}
async fn persist_durable_intent(intent: MrfIntent) -> Result<(), MrfDurableAdmissionError> {
async fn persist_durable_intent(
intent: MrfIntent,
source_bucket_incarnation_id: Option<Uuid>,
) -> Result<(), MrfDurableAdmissionError> {
if !mrf_delivery_enabled() {
return Err(MrfDurableAdmissionError::Disabled);
}
persist_durable_intent_unconditionally(intent).await
persist_durable_intent_unconditionally(intent, source_bucket_incarnation_id).await
}
async fn persist_durable_intent_unconditionally(mut intent: MrfIntent) -> Result<(), MrfDurableAdmissionError> {
async fn persist_durable_intent_unconditionally(
mut intent: MrfIntent,
source_bucket_incarnation_id: Option<Uuid>,
) -> Result<(), MrfDurableAdmissionError> {
if intent.bucket.is_empty()
|| intent.object.is_empty()
|| intent.bucket.len() > MRF_MAX_IDENTITY_COMPONENT
@@ -404,7 +436,11 @@ async fn persist_durable_intent_unconditionally(mut intent: MrfIntent) -> Result
}
let (response, receipt) = oneshot::channel();
sender
.try_send(MrfDurableSubmission { intent, response })
.try_send(MrfDurableSubmission {
intent,
source_bucket_incarnation_id,
response,
})
.map_err(|err| match err {
mpsc::error::TrySendError::Full(_) => MrfDurableAdmissionError::Full,
mpsc::error::TrySendError::Closed(_) => MrfDurableAdmissionError::Unavailable,
@@ -782,6 +818,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfVerifiedRepairEvent>>> =
OnceLock::new();
static MRF_UNVERIFIED_LEGACY_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfUnverifiedLegacyEvent>>> =
OnceLock::new();
/// Record a legacy notification for compatibility. This is not an
/// acknowledgement of storage verification or durable repair completion.
@@ -859,6 +897,44 @@ pub fn take_mrf_verified_repair_events_for(bucket: &str) -> Vec<MrfVerifiedRepai
taken
}
/// Record a bounded, exact-identity notice that a legacy durable partial-write
/// was checked but cannot be proven from its on-disk format. This is not a
/// repair proof and must never release the durable responsibility.
pub fn note_mrf_unverified_legacy(event: MrfUnverifiedLegacyEvent) {
let registry = MRF_UNVERIFIED_LEGACY_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
let Ok(mut events) = registry.lock() else {
return;
};
if events.contains(&event) {
return;
}
if events.len() >= MRF_REPAIRED_EVENT_CAP {
events.pop_front();
}
events.push_back(event);
}
/// Take held-legacy notices for one bucket. The durable ledger still verifies
/// the full anchor before changing the journal entry to its held state.
pub fn take_mrf_unverified_legacy_events_for(bucket: &str) -> Vec<MrfUnverifiedLegacyEvent> {
let Some(registry) = MRF_UNVERIFIED_LEGACY_EVENTS.get() else {
return Vec::new();
};
let Ok(mut events) = registry.lock() else {
return Vec::new();
};
let mut taken = Vec::new();
events.retain(|event| {
if event.anchor.bucket.as_ref() == bucket {
taken.push(event.clone());
false
} else {
true
}
});
taken
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1233,6 +1309,53 @@ mod tests {
assert!(take_mrf_verified_repair_events_for("verified-bucket-a").is_empty());
assert_eq!(take_mrf_verified_repair_events_for("verified-bucket-b").len(), 1);
}
#[test]
fn unverified_legacy_events_are_bucket_scoped_and_bounded() {
let bucket = Arc::<str>::from("unverified-legacy-event-bucket");
let _ = take_mrf_unverified_legacy_events_for(bucket.as_ref());
let event = MrfUnverifiedLegacyEvent {
anchor: MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: bucket.clone(),
object: Arc::from("unverified-object"),
version_id: None,
scope: Some(MrfScope {
pool_index: 1,
set_index: 2,
}),
delete_marker_purge: None,
lease: MrfIngressLease::new(912),
bucket_incarnation_id: Uuid::new_v4(),
},
};
note_mrf_unverified_legacy(event.clone());
note_mrf_unverified_legacy(event.clone());
assert_eq!(take_mrf_unverified_legacy_events_for(bucket.as_ref()), vec![event]);
assert!(take_mrf_unverified_legacy_events_for(bucket.as_ref()).is_empty());
let cap_bucket = Arc::<str>::from("unverified-legacy-event-cap-bucket");
for index in 0..MRF_REPAIRED_EVENT_CAP + 4 {
note_mrf_unverified_legacy(MrfUnverifiedLegacyEvent {
anchor: MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: cap_bucket.clone(),
object: Arc::from(format!("object-{index}")),
version_id: None,
scope: Some(MrfScope {
pool_index: 1,
set_index: 2,
}),
delete_marker_purge: None,
lease: MrfIngressLease::new(u64::try_from(index).expect("test index fits")),
bucket_incarnation_id: Uuid::new_v4(),
},
});
}
let events = take_mrf_unverified_legacy_events_for(cap_bucket.as_ref());
assert_eq!(events.len(), MRF_REPAIRED_EVENT_CAP);
assert_eq!(events.first().expect("bounded ring is non-empty").anchor.object.as_ref(), "object-4");
}
}
#[tokio::test]
async fn partial_write_durable_admission_waits_for_persistence_and_renews_lease() {
+8 -7
View File
@@ -168,9 +168,10 @@ Scanner cycle budget controls:
## Foreground write admission environment variables
Large direct `PutObject` requests and multipart `UploadPart` requests share one
per-process permit pool that bounds concurrent body ingest and storage writes.
Small direct PUTs stay on the legacy path.
Large direct `PutObject` requests, multipart `UploadPart` requests, and
server-side multipart `UploadPartCopy` requests share one per-process permit
pool that bounds concurrent body ingest and storage writes. Small direct PUTs
stay on the legacy path.
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE`
- enables the default-on pool; `false` keeps only the soft request counter.
@@ -190,11 +191,11 @@ Small direct PUTs stay on the legacy path.
- smallest `UploadPart` that takes a permit; `0` gates every part.
- default is `0`.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
- default is `10000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool.
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. Keep it with margin below the shortest such timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
- how long an `UploadPart` or `UploadPartCopy` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
- default is `10000`. Parts wait before body ingest or source-object reads, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool.
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. `UploadPartCopy` waits before taking bucket lifecycle locks or opening source readers, and uses the unknown-size weight because the authoritative copy length is available only after source metadata and range validation. Keep the wait with margin below the shortest client/proxy timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
- maximum `UploadPart` and `UploadPartCopy` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
- default is `0`, which derives 16 times the large-write slot count, or the explicit request-count limit (512 at stock settings). Automatic subdivision does not enlarge this queue.
- each queued HTTP/1 part holds whatever unread body the client already pushed into the connection's kernel receive buffer (an HTTP/2 part holds up to its flow-control window in process memory). Autotuned receive buffers can remain large on reused connections; queue depth does not imply a fixed per-connection memory cost.
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
+7 -3
View File
@@ -387,7 +387,9 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 32 * 10
/// Multipart pressure is often many moderate-sized parts rather than one very
/// large request. The default gates every multipart part through the same permit
/// pool as large/unknown-size PutObject while keeping small direct PUTs on the
/// legacy path.
/// legacy path. Server-side UploadPartCopy requests use this same multipart
/// admission path with unknown-size weighting because source metadata and range
/// validation happen after admission.
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str =
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
@@ -415,7 +417,9 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
/// wait with a 10 s request deadline. The wait must leave margin under the
/// shortest of those, not merely fall below an SDK default, so the part
/// receives S3 `SlowDown`/503 for the client to retry instead of losing its
/// connection (issue #7385). `0` rejects immediately when the pool is full.
/// connection (issue #7385). Server-side UploadPartCopy waits in the same queue
/// before taking bucket lifecycle locks or opening source readers. `0` rejects
/// immediately when the pool is full.
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_000;
@@ -427,7 +431,7 @@ pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_0
// the wait past any client timeout.
const _: () = assert!(DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS * 3 <= 30_000);
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
/// Maximum multipart UploadPart or UploadPartCopy requests waiting for a foreground write permit per process.
///
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
/// waiting, so a genuinely saturated node still fails fast instead of holding
+2 -2
View File
@@ -8,7 +8,7 @@ publish = false
[dependencies]
hex-simd.workspace = true
object = { version = "=0.39.1", default-features = false, features = ["read", "std", "elf"] }
object = { workspace = true, features = ["read", "std", "elf"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
@@ -16,7 +16,7 @@ sha2.workspace = true
symbolic-demangle = { version = "=13.9.0", default-features = false, features = ["rust"] }
[dev-dependencies]
object = { version = "=0.39.1", default-features = false, features = ["write", "elf"] }
object = { workspace = true, features = ["write", "elf"] }
[lints]
workspace = true
+3 -3
View File
@@ -384,17 +384,17 @@ pub(crate) fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
}
/// Wait for the restarted cluster to admit the first root heal request.
/// Wait for the restarted cluster to admit a root heal request and return its response.
pub(crate) async fn start_root_heal_when_control_ready(
heal_url: &str,
heal_body: &str,
access_key: &str,
secret_key: &str,
) -> ChaosResult<()> {
) -> ChaosResult<String> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45);
loop {
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
Ok(_) => return Ok(()),
Ok(body) => return Ok(body),
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && tokio::time::Instant::now() < deadline => {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
+84 -3
View File
@@ -22,6 +22,8 @@
//! - Common test constants and utilities
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::list_buckets::ListBucketsError;
use aws_sdk_s3::{Client, Config};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use http::header::{CONTENT_TYPE, HOST};
@@ -61,6 +63,41 @@ const TEST_PORT_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port";
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
fn list_buckets_readiness_error(err: &SdkError<ListBucketsError>) -> String {
// SDK Display reports only the category. Do not expose raw response bodies or headers.
let service_code = err.as_service_error().and_then(|error| error.code()).filter(|code| {
matches!(
*code,
"ServiceUnavailable"
| "ServerNotInitialized"
| "InternalError"
| "AccessDenied"
| "InvalidAccessKeyId"
| "SignatureDoesNotMatch"
)
});
let transport = match err {
SdkError::DispatchFailure(failure) if failure.is_timeout() => Some("timeout"),
SdkError::DispatchFailure(failure) if failure.is_io() => Some("io"),
SdkError::DispatchFailure(failure) if failure.is_user() => Some("user"),
SdkError::DispatchFailure(failure) if failure.is_other() => Some("other"),
_ => None,
};
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
let mut io_error = None;
while let Some(error) = source {
if let Some(error) = error.downcast_ref::<std::io::Error>() {
io_error = Some((error.kind(), error.raw_os_error()));
break;
}
source = error.source();
}
format!(
"{err}; http_status={:?}; service_code={service_code:?}; transport={transport:?}; io_error={io_error:?}",
err.raw_response().map(|response| response.status().as_u16())
)
}
fn capture_log_path(log_dir: &Path, temp_dir: &str) -> Option<PathBuf> {
let temp_name = Path::new(temp_dir).file_name()?.to_string_lossy();
Some(log_dir.join(format!("{temp_name}.log")))
@@ -1575,8 +1612,9 @@ impl RustFSTestClusterEnvironment {
///
/// Verifies service availability by calling the S3 `list_buckets` API against the requested node,
/// retries up to 120 times with a 1-second interval between attempts.
async fn wait_for_node_service_ready(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn wait_for_node_service_ready(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.create_s3_client(node_idx)?;
let mut last_error = None;
for attempt in 0..120 {
match client.list_buckets().send().await {
@@ -1584,13 +1622,28 @@ impl RustFSTestClusterEnvironment {
info!("Cluster node {} service ready after {} attempts", node_idx, attempt + 1);
return Ok(());
}
Err(_) => {
Err(err) => {
last_error = Some(err);
sleep(Duration::from_secs(1)).await;
}
}
}
Err(format!("Cluster node {} service failed to become ready", node_idx).into())
let last_error = last_error.as_ref().map(list_buckets_readiness_error);
// Observe liveness only after the existing readiness budget is exhausted, before cleanup.
let process_status = match self.nodes[node_idx].process.as_mut() {
Some(process) => match process.try_wait() {
Ok(Some(status)) => format!("exited ({status})"),
Ok(None) => "running".to_string(),
Err(error) => format!("unavailable (kind={:?}, os_code={:?})", error.kind(), error.raw_os_error()),
},
None => "not started".to_string(),
};
Err(format!(
"Cluster node {node_idx} service failed to become ready; last ListBuckets error={last_error:?}; process_status={process_status}; capture_log_path={:?}",
self.node_capture_log_paths[node_idx]
)
.into())
}
/// Create an S3 client configured to communicate with a specific cluster node.
@@ -2101,6 +2154,34 @@ mod tests {
verify_awscurl_path(&executable).expect("an available client with a working help command should pass");
}
#[test]
fn readiness_error_reports_transport_without_sensitive_source_text() {
use aws_sdk_s3::error::ConnectorError;
let sensitive = "request credentials must not appear in diagnostics";
for (connector, category) in [
(
ConnectorError::io(std::io::Error::new(ErrorKind::ConnectionReset, sensitive).into()),
"io",
),
(ConnectorError::timeout(sensitive.into()), "timeout"),
(ConnectorError::user(sensitive.into()), "user"),
(ConnectorError::other(sensitive.into(), None), "other"),
] {
let diagnostic = list_buckets_readiness_error(&SdkError::dispatch_failure(connector));
assert!(diagnostic.contains(&format!("transport=Some(\"{category}\")")), "{diagnostic}");
assert!(diagnostic.contains("http_status=None"), "{diagnostic}");
assert!(!diagnostic.contains(sensitive), "{diagnostic}");
if category == "io" {
assert!(diagnostic.contains("io_error=Some((ConnectionReset, None))"), "{diagnostic}");
}
}
let os_error = std::io::Error::from_raw_os_error(13);
let expected = format!("io_error=Some(({:?}, Some(13)))", os_error.kind());
let diagnostic = list_buckets_readiness_error(&SdkError::dispatch_failure(ConnectorError::io(os_error.into())));
assert!(diagnostic.contains(&expected), "{diagnostic}");
}
#[test]
fn capture_log_path_uses_temp_directory_basename() {
assert_eq!(
@@ -282,7 +282,7 @@ async fn sdk_list_bucket_and_list_bucket_versions_permissions_are_independent()
}
#[tokio::test]
async fn console_admin_force_delete_removes_prefix_versions_and_delete_markers() -> TestResult {
async fn explicit_force_delete_permission_removes_prefix_versions_and_delete_markers() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
@@ -300,7 +300,14 @@ async fn console_admin_force_delete_removes_prefix_versions_and_delete_markers()
put(&root, bucket, "folder-sibling/keep.txt").await?;
let keep = versions(&root, bucket, "keep.txt").await?;
let sibling = versions(&root, bucket, "folder-sibling/").await?;
let user = policy_user(&env, "consoleAdmin", None).await?;
let user = policy_user(
&env,
"explicit-force-delete",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")}
]})),
)
.await?;
force_delete(&user, bucket, "folder/").await?;
assert!(
@@ -322,6 +329,126 @@ async fn console_admin_force_delete_removes_prefix_versions_and_delete_markers()
Ok(())
}
#[tokio::test]
async fn force_delete_header_requires_explicit_permission() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let root = env.create_s3_client();
let admin = policy_user(&env, "consoleAdmin", None).await?;
let owner_bucket = "force-owner-bucket";
root.create_bucket().bucket(owner_bucket).send().await?;
put(&root, owner_bucket, "kept-until-force.txt").await?;
force_delete_bucket(&root, owner_bucket, "x-rustfs-force-delete").await?;
assert!(
head_bucket_missing(&root, owner_bucket).await?,
"root force-delete must remove a non-empty bucket"
);
let denied_bucket = "force-admin-denied";
root.create_bucket().bucket(denied_bucket).send().await?;
put(&root, denied_bucket, "folder/child.txt").await?;
put(&root, denied_bucket, "plain.txt").await?;
assert_boxed_denied(force_delete(&admin, denied_bucket, "folder/").await);
assert!(
!versions(&root, denied_bucket, "folder/").await?.is_empty(),
"consoleAdmin without s3:ForceDeleteObject must not recursively delete"
);
assert_boxed_denied(force_delete_bucket(&admin, denied_bucket, "x-rustfs-force-delete").await);
assert_boxed_denied(force_delete_bucket(&admin, denied_bucket, "x-minio-force-delete").await);
assert!(
!head_bucket_missing(&root, denied_bucket).await?,
"consoleAdmin without s3:ForceDeleteBucket must not force-delete a non-empty bucket"
);
let not_empty = admin.delete_bucket().bucket(denied_bucket).send().await;
let not_empty = not_empty.expect_err("plain delete of a non-empty bucket must fail");
assert_eq!(
not_empty.as_service_error().and_then(|error| error.code()),
Some("BucketNotEmpty"),
"plain delete must keep the emptiness check, got {not_empty:?}"
);
admin.delete_object().bucket(denied_bucket).key("plain.txt").send().await?;
assert!(
versions(&root, denied_bucket, "plain.txt").await?.is_empty(),
"plain DeleteObject without the force header must stay allowed for consoleAdmin"
);
let object_bucket = "force-explicit-object";
root.create_bucket().bucket(object_bucket).send().await?;
put(&root, object_bucket, "folder/child.txt").await?;
put(&root, object_bucket, "keep.txt").await?;
let keep = versions(&root, object_bucket, "keep.txt").await?;
let object_user = policy_user(
&env,
"explicit-object-force",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{object_bucket}/*")}
]})),
)
.await?;
force_delete(&object_user, object_bucket, "folder/").await?;
assert!(versions(&root, object_bucket, "folder/").await?.is_empty());
assert_eq!(versions(&root, object_bucket, "keep.txt").await?, keep);
let allowed_bucket = "force-explicit-bucket";
root.create_bucket().bucket(allowed_bucket).send().await?;
put(&root, allowed_bucket, "inside.txt").await?;
let bucket_user = policy_user(
&env,
"explicit-bucket-force",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteBucket","s3:ForceDeleteBucket"],"Resource":[
format!("arn:aws:s3:::{allowed_bucket}"), format!("arn:aws:s3:::{allowed_bucket}/*")
]}
]})),
)
.await?;
force_delete_bucket(&bucket_user, allowed_bucket, "x-minio-force-delete").await?;
assert!(
head_bucket_missing(&root, allowed_bucket).await?,
"an explicit s3:ForceDeleteBucket grant must remove a non-empty bucket"
);
Ok(())
}
async fn force_delete_bucket(
client: &Client,
bucket: &str,
header: &str,
) -> Result<(), Box<SdkError<aws_sdk_s3::operation::delete_bucket::DeleteBucketError>>> {
let header: &'static str = match header {
"x-rustfs-force-delete" => "x-rustfs-force-delete",
"x-minio-force-delete" => "x-minio-force-delete",
other => panic!("unexpected force-delete header {other}"),
};
client
.delete_bucket()
.bucket(bucket)
.customize()
.mutate_request(move |request| {
request.headers_mut().insert(header, "true");
})
.send()
.await
.map(|_| ())
.map_err(Box::new)
}
async fn head_bucket_missing(client: &Client, bucket: &str) -> TestResult<bool> {
match client.head_bucket().bucket(bucket).send().await {
Ok(_) => Ok(false),
Err(error) => {
let code = error.as_service_error().and_then(|service| service.code());
if matches!(code, Some("NotFound" | "NoSuchBucket")) {
Ok(true)
} else {
Err(error.into())
}
}
}
}
#[tokio::test]
async fn force_delete_authorizes_only_its_path_scope_without_list_permissions() -> TestResult {
init_logging();
@@ -339,7 +466,7 @@ async fn force_delete_authorizes_only_its_path_scope_without_list_permissions()
&env,
"delete-only",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject"],"Resource":[
format!("arn:aws:s3:::{bucket}/selected.txt"), format!("arn:aws:s3:::{bucket}/selected.txt/*")
]},
{"Effect":"Deny","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/selected.txt-sibling")}
@@ -376,7 +503,7 @@ async fn force_directory_delete_cannot_remove_an_unauthorized_colliding_parent()
&env,
"parent-denier",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/collision.txt"),
"Condition":{"StringEquals":{"s3:VersionId":protected_parent_version}}}
]})),
@@ -409,7 +536,7 @@ async fn force_unversioned_directory_requires_only_delete_object() -> TestResult
&env,
"unversioned-deleter",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"s3:DeleteObject","Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Allow","Action":["s3:DeleteObject","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/*")}
]})),
)
@@ -438,7 +565,7 @@ async fn force_delete_denied_child_preserves_every_object_despite_bucket_allow()
&env,
"child-denier",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ReplicateDelete"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject","s3:ReplicateDelete"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Deny","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ReplicateDelete"],"Resource":format!("arn:aws:s3:::{bucket}/folder/z-denied.txt")}
]})),
)
@@ -468,7 +595,7 @@ async fn force_delete_denied_child_preserves_every_object_despite_bucket_allow()
&env,
"replica-deleter",
Some(json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"s3:DeleteObject","Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Allow","Action":["s3:DeleteObject","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Allow","Action":"s3:ReplicateDelete","Resource":format!("arn:aws:s3:::{bucket}/*")}
]})),
)
@@ -500,7 +627,7 @@ async fn force_delete_denied_historical_version_preserves_versions_and_markers()
put(&root, bucket, "folder/a-allowed.txt").await?;
let policy = |version: &str| {
json!({"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Deny","Action":"s3:DeleteObjectVersion","Resource":format!("arn:aws:s3:::{bucket}/folder/*"),
"Condition":{"StringEquals":{"s3:VersionId":version}}}
]})
@@ -628,7 +755,7 @@ async fn force_delete_checks_every_version_page_before_mutation() -> TestResult
.try_collect::<Vec<_>>()
.await?;
put(&root, bucket, "folder/z-denied.txt").await?;
let allow = json!({"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion"],"Resource":format!("arn:aws:s3:::{bucket}/*")});
let allow = json!({"Effect":"Allow","Action":["s3:DeleteObject","s3:DeleteObjectVersion","s3:ForceDeleteObject"],"Resource":format!("arn:aws:s3:::{bucket}/*")});
let user = policy_user(
&env,
"paged-deleter",
+219 -25
View File
@@ -34,6 +34,9 @@ use crate::common::{
};
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use http::{Method, StatusCode};
@@ -41,7 +44,7 @@ use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::{Instant, sleep};
use tokio::time::{Instant, sleep, timeout_at};
use uuid::Uuid;
pub(crate) type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
@@ -400,9 +403,9 @@ pub(crate) async fn put_inventory(
Ok(inventory)
}
/// Retry only transport-level service availability failures while a data
/// movement operation changes the pool map. Generic InternalError responses
/// remain fatal because accepting them would hide server defects.
/// Retry only explicit S3 availability responses while a data movement
/// operation changes the pool map. Transport failures and InternalError
/// responses remain fatal because callers already require a ready endpoint.
pub(crate) async fn put_inventory_retrying(
client: &Client,
bucket: &str,
@@ -449,19 +452,48 @@ where
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(50);
loop {
let last_error = match probe().await {
Ok(true) => return Ok(()),
Ok(false) => format!("{label} still false"),
Err(error) => error.to_string(),
};
if Instant::now() >= deadline {
return Err(format!("{label} did not become true within {timeout:?}: {last_error}").into());
return Err(format!("{label} did not become true within {timeout:?}").into());
}
sleep(delay).await;
// A pending request must consume the same budget as unsuccessful probes.
match timeout_at(deadline, probe()).await {
Err(_) => return Err(format!("{label} did not become true within {timeout:?}").into()),
Ok(Err(error)) => return Err(error),
Ok(Ok(true)) => return Ok(()),
Ok(Ok(false)) => {}
}
tokio::time::sleep_until((Instant::now() + delay).min(deadline)).await;
delay = (delay * 2).min(Duration::from_secs(1));
}
}
// Boxed SDK errors retain the operation's service code; Display only says
// "service error" and cannot distinguish convergence from a server defect.
pub(crate) fn s3_probe_error_code<'a>(error: &'a (dyn std::error::Error + Send + Sync + 'static)) -> Option<&'a str> {
error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(|error| error.as_service_error())
.and_then(ProvideErrorMetadata::code)
.or_else(|| {
error
.downcast_ref::<SdkError<PutObjectError>>()
.and_then(|error| error.as_service_error())
.and_then(ProvideErrorMetadata::code)
})
}
pub(crate) fn s3_probe_client(client: &Client) -> Client {
// The probe owns the retry policy. SDK retries must not hide a response
// that the probe would classify as fatal.
Client::from_conf(
client
.config()
.to_builder()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.build(),
)
}
pub(crate) async fn cluster_admin(
cluster: &RustFSTestClusterEnvironment,
method: Method,
@@ -598,15 +630,20 @@ pub(crate) async fn wait_for_replicated_bytes(
expected: &[u8],
timeout: Duration,
) -> TestResult {
let client = s3_probe_client(client);
wait_until(
timeout,
|| async {
match get_object_bytes(client, bucket, key).await {
match get_object_bytes(&client, bucket, key).await {
Ok(got) if got.as_slice() == expected => Ok(true),
Ok(_) => Ok(false),
Ok(got) => Err(format!(
"replicated object {bucket}/{key} bytes mismatch: expected sha256={} got sha256={}",
sha256_hex(expected),
sha256_hex(&got)
)
.into()),
Err(error) => {
let message = error.to_string();
if message.contains("NoSuchKey") || message.contains("NotFound") {
if matches!(s3_probe_error_code(error.as_ref()), Some("NoSuchKey" | "NotFound")) {
Ok(false)
} else {
Err(error)
@@ -988,6 +1025,7 @@ pub(crate) async fn list_pools_json(cluster: &RustFSTestClusterEnvironment) -> T
}
pub(crate) async fn retrying_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, timeout: Duration) -> TestResult {
let client = s3_probe_client(client);
wait_until(
timeout,
|| {
@@ -999,8 +1037,7 @@ pub(crate) async fn retrying_put(client: &Client, bucket: &str, key: &str, body:
match put_object(&client, &bucket, &key, body).await {
Ok(()) => Ok(true),
Err(error) => {
let message = error.to_string();
if message.contains("SlowDown") || message.contains("ServiceUnavailable") || message.contains("503") {
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
@@ -1021,19 +1058,20 @@ pub(crate) async fn retrying_get_equals(
expected: &[u8],
timeout: Duration,
) -> TestResult {
let client = s3_probe_client(client);
wait_until(
timeout,
|| async {
match get_object_bytes(client, bucket, key).await {
match get_object_bytes(&client, bucket, key).await {
Ok(got) if got.as_slice() == expected => Ok(true),
Ok(_) => Ok(false),
Ok(got) => Err(format!(
"object {bucket}/{key} bytes mismatch: expected sha256={} got sha256={}",
sha256_hex(expected),
sha256_hex(&got)
)
.into()),
Err(error) => {
let message = error.to_string();
if message.contains("NoSuchKey")
|| message.contains("SlowDown")
|| message.contains("ServiceUnavailable")
|| message.contains("503")
{
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
@@ -1046,6 +1084,162 @@ pub(crate) async fn retrying_get_equals(
.await
}
#[cfg(test)]
mod retry_tests {
use super::*;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction, Operation, SeedMetadata};
use std::cell::Cell;
fn client(target: &FakeS3Target) -> Client {
Client::from_conf(build_test_s3_config(
target.endpoint(),
FAKE_ACCESS_KEY,
FAKE_SECRET_KEY,
None,
"distributed-retry-test",
))
}
#[tokio::test]
async fn wait_until_preserves_first_error() {
let attempts = Cell::new(0);
let error = wait_until(
Duration::from_secs(5),
|| {
attempts.set(attempts.get() + 1);
std::future::ready(if attempts.get() == 1 {
Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permanent probe failure").into())
} else {
Ok(true)
})
},
"permanent failure",
)
.await
.expect_err("a later success must not hide a permanent failure");
assert_eq!(attempts.get(), 1);
assert_eq!(
error
.downcast_ref::<std::io::Error>()
.expect("preserve the original error")
.kind(),
std::io::ErrorKind::PermissionDenied
);
}
#[tokio::test]
async fn wait_until_retries_explicit_pending_state() -> TestResult {
let attempts = Cell::new(0);
wait_until(
Duration::from_secs(5),
|| {
attempts.set(attempts.get() + 1);
std::future::ready(Ok(attempts.get() == 2))
},
"eventual readiness",
)
.await?;
assert_eq!(attempts.get(), 2);
Ok(())
}
#[tokio::test]
async fn wait_until_bounds_a_pending_probe() {
let error = tokio::time::timeout(
Duration::from_secs(5),
wait_until(Duration::from_millis(10), std::future::pending, "hung probe"),
)
.await
.expect("the probe deadline must finish before the test watchdog")
.expect_err("a permanently pending probe must time out");
assert!(error.to_string().contains("hung probe did not become true"));
}
#[tokio::test]
async fn wait_until_does_not_start_a_probe_after_its_deadline() {
let attempts = Cell::new(0);
wait_until(
Duration::ZERO,
|| {
attempts.set(attempts.get() + 1);
std::future::ready(Ok(true))
},
"expired budget",
)
.await
.expect_err("an expired budget must not admit another probe");
assert_eq!(attempts.get(), 0);
}
#[tokio::test]
async fn retrying_put_does_not_hide_internal_error() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
target.inject(Operation::PutObject, FaultAction::ResponseStatus(500), 1);
let error = retrying_put(&client(&target), "retry-probe", "key", b"body".to_vec(), Duration::from_secs(5))
.await
.expect_err("InternalError must remain fatal even if the next PUT would succeed");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("InternalError"));
assert_eq!(target.requests().len(), 1);
Ok(())
}
#[tokio::test]
async fn retrying_put_allows_transient_availability_errors() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
for status in [429, 503] {
target.inject(Operation::PutObject, FaultAction::ResponseStatus(status), 1);
retrying_put(&client(&target), "retry-probe", "key", b"body".to_vec(), Duration::from_secs(5)).await?;
assert_eq!(target.take_requests().len(), 2);
}
Ok(())
}
#[tokio::test]
async fn object_polls_reject_wrong_object_bytes() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
target.put_seed_object("retry-probe", "key", "wrong bytes", &SeedMetadata::default());
let error = retrying_get_equals(&client(&target), "retry-probe", "key", b"expected bytes", Duration::from_secs(5))
.await
.expect_err("a successful response with wrong bytes must fail immediately");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(target.take_requests().len(), 1);
let error = wait_for_replicated_bytes(&client(&target), "retry-probe", "key", b"expected bytes", Duration::from_secs(5))
.await
.expect_err("replication may lag, but it must not return corrupt bytes");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(target.requests().len(), 1);
Ok(())
}
#[tokio::test]
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("retry-probe");
target.put_seed_object("retry-probe", "key", "expected bytes", &SeedMetadata::default());
let client = client(&target);
for (status, expected_code) in [(404, "NoSuchKey"), (500, "InternalError"), (403, "AccessDenied")] {
target.inject(Operation::GetObject, FaultAction::ResponseStatus(status), 1);
let error = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5))
.await
.expect_err("an acknowledged object must not disappear or fail before a later successful read");
assert_eq!(s3_probe_error_code(error.as_ref()), Some(expected_code));
assert_eq!(target.take_requests().len(), 1);
}
for status in [429, 503] {
target.inject(Operation::GetObject, FaultAction::ResponseStatus(status), 1);
retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await?;
assert_eq!(target.take_requests().len(), 2);
}
target.inject(Operation::GetObject, FaultAction::ResponseStatus(404), 1);
wait_for_replicated_bytes(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await?;
assert_eq!(target.requests().len(), 2);
Ok(())
}
}
#[tokio::test]
async fn append_single_node_pool_extends_ellipses_volumes() {
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1]]))
@@ -26,7 +26,7 @@
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
unique_bucket, wait_until,
s3_probe_client, s3_probe_error_code, sha256_hex, unique_bucket, wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
@@ -111,6 +111,7 @@ async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_na
}
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
let client = s3_probe_client(client);
wait_until(
CREDENTIAL_TIMEOUT,
|| {
@@ -119,8 +120,18 @@ async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, l
let key = key.to_string();
let body = body.clone();
async move {
put_object(&client, &bucket, &key, body).await?;
Ok(true)
match put_object(&client, &bucket, &key, body).await {
Ok(()) => Ok(true),
Err(error)
if matches!(
s3_probe_error_code(error.as_ref()),
Some("AccessDenied" | "InvalidAccessKeyId" | "SlowDown" | "ServiceUnavailable")
) =>
{
Ok(false)
}
Err(error) => Err(error),
}
}
},
label,
@@ -129,6 +140,7 @@ async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, l
}
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
let client = s3_probe_client(client);
wait_until(
CREDENTIAL_TIMEOUT,
|| {
@@ -137,8 +149,24 @@ async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8
let key = key.to_string();
let expected = expected.to_vec();
async move {
let got = get_object_bytes(&client, &bucket, &key).await?;
Ok(got == expected)
match get_object_bytes(&client, &bucket, &key).await {
Ok(got) if got == expected => Ok(true),
Ok(got) => Err(format!(
"{label}: object {bucket}/{key} bytes mismatch: expected sha256={} got sha256={}",
sha256_hex(&expected),
sha256_hex(&got)
)
.into()),
Err(error)
if matches!(
s3_probe_error_code(error.as_ref()),
Some("AccessDenied" | "InvalidAccessKeyId" | "SlowDown" | "ServiceUnavailable")
) =>
{
Ok(false)
}
Err(error) => Err(error),
}
}
},
label,
@@ -146,6 +174,57 @@ async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8
.await
}
#[cfg(test)]
mod retry_tests {
use super::*;
use crate::common::build_test_s3_config;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction, Operation};
#[tokio::test]
async fn iam_polls_only_retry_auth_propagation_and_availability() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket("upgrade-retry-probe");
let client = Client::from_conf(build_test_s3_config(
target.endpoint(),
FAKE_ACCESS_KEY,
FAKE_SECRET_KEY,
None,
"upgrade-retry-test",
));
for status in [403, 503] {
target.inject(Operation::PutObject, FaultAction::ResponseStatus(status), 1);
wait_for_put(&client, "upgrade-retry-probe", "key", b"body".to_vec(), "IAM PUT").await?;
assert_eq!(target.take_requests().len(), 2);
target.inject(Operation::GetObject, FaultAction::ResponseStatus(status), 1);
wait_for_bytes(&client, "upgrade-retry-probe", "key", b"body", "IAM GET").await?;
assert_eq!(target.take_requests().len(), 2);
}
target.inject(Operation::PutObject, FaultAction::ResponseStatus(500), 1);
let error = wait_for_put(&client, "upgrade-retry-probe", "key", b"body".to_vec(), "IAM PUT")
.await
.expect_err("IAM convergence must not hide InternalError on PUT");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("InternalError"));
assert_eq!(target.take_requests().len(), 1);
target.inject(Operation::GetObject, FaultAction::ResponseStatus(500), 1);
let error = wait_for_bytes(&client, "upgrade-retry-probe", "key", b"body", "IAM GET")
.await
.expect_err("IAM convergence must not hide InternalError on GET");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("InternalError"));
assert_eq!(target.take_requests().len(), 1);
let error = wait_for_bytes(&client, "upgrade-retry-probe", "key", b"wrong bytes", "IAM GET")
.await
.expect_err("IAM convergence must not hide a payload mismatch");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(target.requests().len(), 1);
Ok(())
}
}
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
let history_bucket = unique_bucket("upg-hist");
let versioned_bucket = unique_bucket("upg-ver");
@@ -215,6 +215,8 @@ mod tests {
probe_startup_cas_binary(&binary, &nonce, &artifact).await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
// Isolate bootstrap CAS and its S3 readback from periodic usage publication.
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
let mut logs = Vec::new();
let mut disks = Vec::new();
let mut endpoints = Vec::new();
@@ -1450,10 +1450,18 @@ mod tests {
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
// Keep every node's Heal runtime enabled for normal disk registration.
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
// Capture durable handoff and marker cleanup failures during shutdown.
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG").unwrap_or_else(|_| {
"rustfs::heal::task=info,rustfs::heal::manager=info,rustfs::main::handle_shutdown=info,rustfs_heal::heal=warn,rustfs=error"
.to_string()
});
cluster.set_env("RUST_LOG", server_rust_log);
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| {
// Keep explicit directories compatible and isolate parallel suite captures.
std::env::var("RUSTFS_HEAL_CHAOS_LOG_ROOT")
.map(|root| format!("{root}/{interruption_kind}-{}", uuid::Uuid::new_v4()))
.unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir))
});
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
@@ -1799,7 +1807,8 @@ mod tests {
let (mut client_token, mut task_status_url) = if background_rejoin_heal_evidence {
(String::new(), String::new())
} else {
let heal_start_body = signed_admin_post(&heal_url, Some(heal_body), &cluster.access_key, &cluster.secret_key).await?;
let heal_start_body =
start_root_heal_when_control_ready(&heal_url, heal_body, &cluster.access_key, &cluster.secret_key).await?;
let heal_start: serde_json::Value = serde_json::from_str(&heal_start_body)
.map_err(|err| format!("heal start response is not JSON ({err}): {heal_start_body}"))?;
let client_token = heal_start["clientToken"]
@@ -2140,7 +2149,8 @@ mod tests {
&& status["summary"].as_str() == Some("failed")
{
let heal_start_body =
signed_admin_post(&heal_url, Some(heal_body), &cluster.access_key, &cluster.secret_key).await?;
start_root_heal_when_control_ready(&heal_url, heal_body, &cluster.access_key, &cluster.secret_key)
.await?;
let heal_start: serde_json::Value = serde_json::from_str(&heal_start_body)
.map_err(|err| format!("recovery heal start response is not JSON ({err}): {heal_start_body}"))?;
client_token = heal_start["clientToken"]
@@ -886,6 +886,8 @@ fn configure_reader_metric_cluster(cluster: &mut RustFSTestClusterEnvironment, c
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true");
cluster.set_env("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false");
cluster.set_env("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false");
// Keep startup diagnostics available when OTLP log export is disabled.
cluster.set_env("RUSTFS_OBS_LOG_STDOUT_ENABLED", "true");
cluster.set_env("RUSTFS_OBS_METER_INTERVAL", "1");
cluster.set_env("RUSTFS_OBS_USE_STDOUT", "false");
cluster.set_env("RUSTFS_GET_CODEC_STREAMING_ENABLE", "false");
@@ -31,8 +31,19 @@ mod tests {
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use std::collections::HashSet;
use std::future::Future;
use std::time::Duration;
use tokio::time::{Instant, error::Elapsed, timeout_at};
use tracing::info;
const PAGINATION_TIMEOUT: Duration = Duration::from_secs(120);
// Fixture population can require thousands of durable PUTs. Bound the
// listing traversal itself with one deadline shared by every page.
async fn listing_request_before<T>(deadline: Instant, request: impl Future<Output = T>) -> Result<T, Elapsed> {
timeout_at(deadline, request).await
}
/// Helper function to create an S3 client for testing
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
env.create_s3_client()
@@ -57,6 +68,54 @@ mod tests {
}
}
#[tokio::test]
async fn test_list_objects_v2_deadline_rejects_unresponsive_peer() {
use tokio::io::AsyncReadExt;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::timeout;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind unresponsive listing peer");
let endpoint = format!("http://{}", listener.local_addr().expect("listing peer address"));
let (received_tx, received_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept listing request");
let mut bytes = [0; 4096];
assert!(stream.read(&mut bytes).await.expect("read listing request") > 0);
received_tx.send(()).expect("signal received listing request");
std::future::pending::<()>().await;
drop(stream);
});
let client = Client::from_conf(
crate::common::build_test_s3_config(&endpoint, "test-access", "test-secret", None, "pagination-deadline")
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
.build(),
);
let request = client.list_objects_v2().bucket("deadline-test").send();
tokio::pin!(request);
tokio::select! {
response = &mut request => panic!("unresponsive listing peer unexpectedly returned: {response:?}"),
received = timeout(Duration::from_secs(10), received_rx) => {
received.expect("listing request must reach peer").expect("listing peer must remain alive");
}
}
// Expire the same absolute deadline after the real request is observed;
// this tests a stalled response without relying on transport timing.
let result = timeout(Duration::from_secs(1), listing_request_before(Instant::now(), request))
.await
.expect("expired pagination deadline must stop the in-flight request");
server.abort();
let _ = server.await;
assert!(
result.is_err(),
"an unresponsive ListObjectsV2 request must exceed the pagination deadline"
);
}
/// Test for Issue #2775: continuation forwarding must not
/// skip a child directory when the prefix component repeats in the key.
#[tokio::test]
@@ -514,12 +573,12 @@ mod tests {
.expect("Failed to put object");
}
let output = client
.list_objects_v2()
.bucket(bucket)
.max_keys(1001)
.send()
eprintln!("Seeded {object_count} objects in {bucket}; starting ListObjectsV2 pagination");
let deadline = Instant::now() + PAGINATION_TIMEOUT;
let output = listing_request_before(deadline, client.list_objects_v2().bucket(bucket).max_keys(1001).send())
.await
.expect("ListObjectsV2 pagination exceeded its deadline after fixture population")
.expect("Failed to list objects");
assert_eq!(output.contents().len(), 1000);
@@ -535,14 +594,18 @@ mod tests {
.expect("NextContinuationToken should be present when capped response is truncated")
.to_string();
let output = client
.list_objects_v2()
.bucket(bucket)
.max_keys(1001)
.continuation_token(next_token)
.send()
.await
.expect("Failed to list objects with continuation token");
let output = listing_request_before(
deadline,
client
.list_objects_v2()
.bucket(bucket)
.max_keys(1001)
.continuation_token(next_token)
.send(),
)
.await
.expect("ListObjectsV2 continuation exceeded the shared pagination deadline")
.expect("Failed to list objects with continuation token");
assert_eq!(output.contents().len(), 2);
assert!(!output.is_truncated().unwrap_or(false));
@@ -765,6 +828,9 @@ mod tests {
}
}
eprintln!("Seeded {} objects in {bucket}; starting ListObjectsV2 pagination", all_keys.len());
let deadline = Instant::now() + PAGINATION_TIMEOUT;
// Paginate with delimiter="/" and max_keys=50
// Visible per page: up to 50 CommonPrefixes
let mut listed_keys = Vec::new();
@@ -780,7 +846,10 @@ mod tests {
request = request.continuation_token(token);
}
let output = request.send().await.expect("Failed to list objects");
let output = listing_request_before(deadline, request.send())
.await
.expect("ListObjectsV2 delimiter traversal exceeded the shared pagination deadline")
.expect("Failed to list objects");
last_page_is_truncated = output.is_truncated().unwrap_or(false);
for obj in output.contents() {
@@ -960,6 +1029,8 @@ mod tests {
/// tests the non-delimiter case.
#[tokio::test]
async fn test_list_objects_v2_maxkeys_above_limit_with_delimiter() {
use futures::{StreamExt, stream};
init_logging();
info!("Starting test: ListObjectsV2 MaxKeys above limit with delimiter");
@@ -974,29 +1045,44 @@ mod tests {
// 12 dirs × 100 files = 1200 raw keys
let dir_count = 12;
let files_per_dir = 100;
for d in 0..dir_count {
for f in 0..files_per_dir {
let key = format!("dir-{:02}/file{:03}.txt", d, f);
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.expect("Failed to put object");
}
}
const FIXTURE_CONCURRENCY: usize = 8;
let fixture_started = std::time::Instant::now();
eprintln!(
"Preparing {} objects with at most {FIXTURE_CONCURRENCY} concurrent PUTs",
dir_count * files_per_dir
);
// Object creation order is irrelevant to this listing contract. Bound
// fixture I/O and await every PUT before issuing the LIST request.
stream::iter((0..dir_count).flat_map(|d| (0..files_per_dir).map(move |f| (d, f))))
.for_each_concurrent(FIXTURE_CONCURRENCY, |(d, f)| {
let client = &client;
async move {
let key = format!("dir-{d:02}/file{f:03}.txt");
client
.put_object()
.bucket(bucket)
.key(&key)
.body(ByteStream::from_static(b"x"))
.send()
.await
.unwrap_or_else(|err| panic!("Failed to prepare fixture object {key}: {err:?}"));
}
})
.await;
eprintln!(
"Seeded {} objects in {bucket} in {:?}; starting ListObjectsV2 pagination",
dir_count * files_per_dir,
fixture_started.elapsed()
);
let deadline = Instant::now() + PAGINATION_TIMEOUT;
// With delimiter: 12 CommonPrefixes visible, all fit within capped 1000
let output = client
.list_objects_v2()
.bucket(bucket)
.delimiter("/")
.max_keys(2000)
.send()
.await
.expect("Failed to list objects");
let list_started = std::time::Instant::now();
let output =
listing_request_before(deadline, client.list_objects_v2().bucket(bucket).delimiter("/").max_keys(2000).send())
.await
.expect("ListObjectsV2 delimiter listing exceeded its deadline after fixture population")
.expect("Failed to list objects");
assert_eq!(
output.common_prefixes().len(),
@@ -1005,6 +1091,15 @@ mod tests {
dir_count,
output.common_prefixes().len()
);
let prefixes = output
.common_prefixes()
.iter()
.map(|entry| entry.prefix().expect("each CommonPrefix must carry its prefix").to_owned())
.collect::<Vec<_>>();
let expected_prefixes = (0..dir_count).map(|d| format!("dir-{d:02}/")).collect::<Vec<_>>();
assert_eq!(prefixes, expected_prefixes);
assert!(output.contents().is_empty(), "all fixture objects must collapse into prefixes");
assert_eq!(output.key_count(), Some(i32::try_from(dir_count).expect("fixture count fits i32")));
assert_eq!(output.max_keys(), Some(1000));
// 12 visible < 1000 capped MaxKeys → not truncated
assert!(
@@ -1013,6 +1108,11 @@ mod tests {
output.common_prefixes().len()
);
assert!(
output.next_continuation_token().is_none(),
"a complete page must not carry a continuation token"
);
eprintln!("ListObjectsV2 assertions completed in {:?}", list_started.elapsed());
info!("MaxKeys above limit with delimiter test passed");
env.stop_server();
@@ -65,6 +65,130 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn list_parts_sdk_paginator_terminates_and_preserves_completion() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = "multipart-pagination";
let key = "sparse-parts.bin";
env.create_test_bucket(bucket).await?;
let upload = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
let mut empty_pages = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.into_paginator()
.stop_on_duplicate_token(false)
.send();
let empty = tokio::time::timeout(std::time::Duration::from_secs(30), empty_pages.next())
.await?
.ok_or("empty upload must return one page")??;
assert!(empty.parts().is_empty());
assert_eq!(empty.is_truncated(), Some(false));
assert_eq!(empty.next_part_number_marker(), None);
assert!(
tokio::time::timeout(std::time::Duration::from_secs(30), empty_pages.next())
.await?
.is_none()
);
let mut expected_body = Vec::new();
let mut completed_parts = Vec::new();
for (part_number, body) in [(1, vec![b'a'; PART_SIZE]), (3, vec![b'b'; PART_SIZE]), (10, b"tail".to_vec())] {
expected_body.extend_from_slice(&body);
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(part_number)
.body(ByteStream::from(body))
.send()
.await?;
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.set_e_tag(uploaded.e_tag().map(str::to_owned))
.build(),
);
}
for (page_size, expected_pages) in [(1, 3), (2, 2), (3, 1), (1000, 1)] {
let mut pages = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.into_paginator()
.page_size(page_size)
// The server must terminate pagination even without the SDK's duplicate-token safeguard.
.stop_on_duplicate_token(false)
.send();
let mut page_count = 0;
let mut listed_parts = Vec::new();
while let Some(page) = tokio::time::timeout(std::time::Duration::from_secs(30), pages.next()).await? {
assert!(page_count < expected_pages, "paginator must terminate after the final page");
let page = page?;
page_count += 1;
let truncated = page_count < expected_pages;
assert_eq!(page.is_truncated(), Some(truncated));
let last_part = page.parts().last().and_then(|part| part.part_number());
let expected_marker = truncated.then(|| last_part.expect("truncated page must contain parts").to_string());
assert_eq!(page.next_part_number_marker(), expected_marker.as_deref());
listed_parts.extend(
page.parts()
.iter()
.map(|part| part.part_number().expect("part number must be present")),
);
}
assert_eq!(page_count, expected_pages);
assert_eq!(listed_parts, [1, 3, 10]);
}
let mut resumed_pages = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number_marker("2")
.into_paginator()
.page_size(1)
.stop_on_duplicate_token(false)
.send();
for (part_number, next_marker) in [(3, Some("3")), (10, None)] {
let page = tokio::time::timeout(std::time::Duration::from_secs(30), resumed_pages.next())
.await?
.ok_or("parts above a missing marker must still be listed")??;
assert_eq!(page.parts().len(), 1);
assert_eq!(page.parts()[0].part_number(), Some(part_number));
assert_eq!(page.is_truncated(), Some(next_marker.is_some()));
assert_eq!(page.next_part_number_marker(), next_marker);
}
assert!(
tokio::time::timeout(std::time::Duration::from_secs(30), resumed_pages.next())
.await?
.is_none()
);
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
.send()
.await?;
assert_completed_object(&client, bucket, key, "STANDARD", &expected_body).await?;
env.stop_server();
Ok(())
}
#[tokio::test]
async fn multipart_upload_preserves_standard_and_rrs_across_retry_and_resume()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+11 -1
View File
@@ -1056,7 +1056,17 @@ async fn test_hermetic_transition_restore_failure_expiry_and_retry() -> TestResu
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
let mut hot = RustFSTestEnvironment::new().await?;
start_tier_source(&mut hot, &[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_ILM_DEBUG_DAY_SECS", "5")]).await?;
// Revisit the restored key every cycle; the default 16-cycle sampling is
// independent of the accelerated lifecycle clock used by this fixture.
start_tier_source(
&mut hot,
&[
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES", "1"),
("RUSTFS_ILM_DEBUG_DAY_SECS", "5"),
],
)
.await?;
let hot_client = hot.create_s3_client();
add_rustfs_tier(&hot, &cold).await?;
@@ -7194,13 +7194,30 @@ async fn test_site_replication_state_edit_fresh_and_stale_real_dual_node() -> Re
assert!(source_info.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
assert!(target_info.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
let source_deployment_id = source_info
.sites
.iter()
.find(|peer| peer.endpoint == source_env.url)
.map(|peer| peer.deployment_id.clone())
.ok_or("source site missing from its own replication info")?;
let target_deployment_id = target_info
.sites
.iter()
.find(|peer| peer.endpoint == target_env.url)
.map(|peer| peer.deployment_id.clone())
.ok_or("target site missing from its own replication info")?;
let target_status =
wait_for_site_replication_status(&target_env, "peer-state=true", |status| status.peer_states.len() == 2).await?;
let current_updated_at = target_status
.peer_states
.values()
.find_map(|state| state.updated_at)
.get(&target_deployment_id)
.and_then(|state| state.updated_at)
.ok_or("missing target site replication updated_at")?;
let source_updated_at = target_status
.peer_states
.get(&source_deployment_id)
.and_then(|state| state.updated_at)
.ok_or("missing source site replication updated_at")?;
let mut stale_peers = BTreeMap::new();
for peer in target_info.sites {
@@ -7244,16 +7261,23 @@ async fn test_site_replication_state_edit_fresh_and_stale_real_dual_node() -> Re
.await?;
assert!(target_after_fresh.sites.iter().all(|peer| peer.replicate_ilm_expiry));
// State edits apply only to the addressed site; status reports each site's own state.
let target_status_after_fresh = wait_for_site_replication_status(&target_env, "peer-state=true", |status| {
status.peer_states.len() == 2
&& status.peer_states.values().all(|state| {
state.updated_at == Some(fresh_updated_at) && state.peers.values().all(|peer| peer.replicate_ilm_expiry)
&& status.peer_states.get(&target_deployment_id).is_some_and(|state| {
state.updated_at == Some(fresh_updated_at)
&& state.peers.len() == 2
&& state.peers.values().all(|peer| peer.replicate_ilm_expiry)
})
})
.await?;
assert!(target_status_after_fresh.peer_states.values().all(|state| {
state.updated_at == Some(fresh_updated_at) && state.peers.values().all(|peer| peer.replicate_ilm_expiry)
}));
let source_state_after_fresh = target_status_after_fresh
.peer_states
.get(&source_deployment_id)
.ok_or("missing source site replication state after target edit")?;
assert_eq!(source_state_after_fresh.updated_at, Some(source_updated_at));
assert_eq!(source_state_after_fresh.peers.len(), 2);
assert!(source_state_after_fresh.peers.values().all(|peer| !peer.replicate_ilm_expiry));
let source_after_fresh = site_replication_info(&source_env).await?;
assert!(source_after_fresh.sites.iter().all(|peer| !peer.replicate_ilm_expiry));
@@ -51,6 +51,10 @@ const PLAIN_BUCKET: &str = "upgrade-plain-data";
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
const MIXED_NODE_COUNT: usize = 4;
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
// Written by the "previous-seed" readiness probe through node 0 before any
// current binary starts; every later phase must still read it.
const PREVIOUS_RELEASE_SEED_KEY: &str = ".upgrade-readiness/previous-seed/node-0";
const MULTIPART_WORKERS: usize = 16;
const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
// Peers keep a restarted node's drive in Suspect/Returning for roughly
@@ -67,6 +71,7 @@ const CONFIG_REPLICA_BUCKET: &str = "upgrade-config-replica";
const ROLLBACK_BUCKET: &str = "rollback-config-data";
const ROLLBACK_REPLICA_BUCKET: &str = "rollback-config-replica";
const BUCKET_QUOTA_BYTES: u64 = 64 * 1024 * 1024;
const QUOTA_USAGE_READINESS_TIMEOUT: Duration = Duration::from_secs(90);
const LIFECYCLE_RULE_ID: &str = "upgrade-expire-logs";
const LIFECYCLE_PREFIX: &str = "logs/";
const LIFECYCLE_DAYS: i32 = 30;
@@ -395,6 +400,7 @@ async fn exercise_mixed_cluster(
previous_node: usize,
) -> TestResult {
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
let current_client = &clients[current_node];
let previous_client = &clients[previous_node];
@@ -423,6 +429,11 @@ async fn exercise_mixed_cluster(
let multipart_keys = write_multipart_load(&clients, phase).await?;
let expected_count = multipart_keys.len() + 2;
for (label, client) in [("current", current_client), ("previous", previous_client)] {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
UPGRADE_READINESS_BODY,
"{phase}: the {label} node must retain the previous-release seed"
);
wait_for_phase_listing(
client,
phase,
@@ -441,6 +452,231 @@ async fn exercise_mixed_cluster(
Ok(())
}
fn upgrade_probe_client(client: &Client) -> Client {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
.build(),
)
}
async fn wait_for_upgrade_write_readiness(clients: &[Client], phase: &str, budget: Duration) -> TestResult {
// ListBuckets can succeed before peers recover a restarted disk. Cluster
// health also accepts Returning disks whose write health is still FAULTY.
// Probe every writer outside the asserted phase prefix; compatibility
// writes still execute once and retain their original assertions.
let deadline = Instant::now() + budget;
for (node, client) in clients.iter().enumerate() {
let client = upgrade_probe_client(client);
let key = format!(".upgrade-readiness/{phase}/node-{node}");
let mut last_response = "no response".to_string();
loop {
if Instant::now() >= deadline {
return Err(
format!("{phase}: node {node} write readiness deadline exceeded; last response: {last_response}").into(),
);
}
let response = tokio::time::timeout_at(
deadline,
client
.put_object()
.bucket(MIXED_BUCKET)
.key(&key)
.body(ByteStream::from_static(UPGRADE_READINESS_BODY))
.send(),
)
.await;
match response {
Ok(Ok(_)) => break,
Ok(Err(error)) => {
if error.raw_response().map(|response| response.status().as_u16()) != Some(503)
|| error.as_service_error().and_then(ProvideErrorMetadata::code) != Some("ServiceUnavailable")
{
return Err(format!("{phase}: node {node} write readiness failed: {error:?}").into());
}
last_response = format!("{error:?}");
}
Err(_) => {
return Err(format!(
"{phase}: node {node} write readiness deadline exceeded during PutObject; last response: {last_response}"
)
.into());
}
}
tokio::time::sleep_until(deadline.min(Instant::now() + Duration::from_millis(500))).await;
}
}
Ok(())
}
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
// rc.5 can latch a non-elected node's write fence while its first startup
// waits for pool metadata (#7473). First prove the elected writer can
// persist data, then restart each old process once with three peers still
// readable. This preparation ends before any current binary is started.
wait_for_upgrade_write_readiness(&clients[..1], "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
for node in [1, 2, 3, 0] {
cluster.stop_node(node)?;
cluster.start_node_from_binary(node, previous_binary).await?;
wait_for_upgrade_write_readiness(
std::slice::from_ref(&clients[node]),
&format!("previous-restart-{node}"),
LISTING_CONVERGENCE_TIMEOUT,
)
.await?;
}
wait_for_upgrade_write_readiness(&clients, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
for (reader, client) in clients.iter().enumerate() {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
UPGRADE_READINESS_BODY,
"previous-release node {reader} must retain the seed across its rolling restart"
);
for writer in 0..clients.len() {
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
assert_eq!(
read_object(client, MIXED_BUCKET, &key, None).await?.1,
UPGRADE_READINESS_BODY,
"previous-release node {reader} must read baseline data from writer {writer}"
);
}
}
Ok(())
}
#[cfg(test)]
mod upgrade_write_readiness_tests {
use super::*;
use crate::fake_s3_target::FaultAction;
#[tokio::test]
async fn waits_for_each_writer_after_metadata_is_ready() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket(MIXED_BUCKET);
let client = fake_source_client(&target);
client.head_bucket().bucket(MIXED_BUCKET).send().await?;
let phase = "one-previous-node";
let first_key = format!(".upgrade-readiness/{phase}/node-0");
let second_key = format!(".upgrade-readiness/{phase}/node-1");
target.inject_for_key(
FakeTargetOperation::PutObject,
&first_key,
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
2,
);
target.inject_for_key(
FakeTargetOperation::PutObject,
&second_key,
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
1,
);
// Metadata readiness does not prove that a data write can succeed.
let premature = client
.put_object()
.bucket(MIXED_BUCKET)
.key(&first_key)
.body(ByteStream::from_static(b"upgrade write readiness"))
.send()
.await
.expect_err("metadata readiness does not prove write readiness");
assert_eq!(premature.raw_response().map(|response| response.status().as_u16()), Some(503));
wait_for_upgrade_write_readiness(&[client.clone(), client.clone()], phase, Duration::from_secs(5)).await?;
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &first_key), 3);
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &second_key), 2);
assert!(target.has_object(MIXED_BUCKET, &first_key));
assert!(target.has_object(MIXED_BUCKET, &second_key));
assert!(
client
.list_objects_v2()
.bucket(MIXED_BUCKET)
.prefix(format!("{phase}/"))
.send()
.await?
.contents()
.is_empty()
);
target.shutdown().await;
Ok(())
}
#[tokio::test]
async fn rejects_permanent_errors_without_sdk_retries() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket(MIXED_BUCKET);
let client = Client::from_conf(
fake_source_client(&target)
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(3))
.build(),
);
for status in [
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::FORBIDDEN,
StatusCode::NOT_FOUND,
] {
let phase = format!("permanent-{}", status.as_u16());
let key = format!(".upgrade-readiness/{phase}/node-0");
target.inject_for_key(FakeTargetOperation::PutObject, &key, FaultAction::Status(status), 1);
let error = wait_for_upgrade_write_readiness(std::slice::from_ref(&client), &phase, Duration::from_secs(5))
.await
.expect_err("a permanent error must not be retried into success");
assert!(error.to_string().contains("node 0 write readiness failed"), "{error}");
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &key), 1);
assert!(!target.has_object(MIXED_BUCKET, &key));
}
target.shutdown().await;
Ok(())
}
#[tokio::test]
async fn transient_errors_stop_at_the_deadline() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket(MIXED_BUCKET);
let client = fake_source_client(&target);
client.head_bucket().bucket(MIXED_BUCKET).send().await?;
let phase = "deadline";
let key = format!(".upgrade-readiness/{phase}/node-0");
target.inject_for_key(
FakeTargetOperation::PutObject,
&key,
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
10,
);
let error = wait_for_upgrade_write_readiness(&[client], phase, Duration::from_secs(1))
.await
.expect_err("persistent unavailability must exhaust the shared deadline");
// Transport scheduling consumes the same budget; do not require a
// response to reach the fake target before the deadline on a busy host.
assert!(error.to_string().contains("deadline exceeded"), "{error}");
target.shutdown().await;
Ok(())
}
#[tokio::test]
async fn in_flight_requests_are_bounded_by_the_deadline() -> TestResult {
let target = FakeS3Target::start().await?;
target.create_bucket(MIXED_BUCKET);
let client = fake_source_client(&target);
client.head_bucket().bucket(MIXED_BUCKET).send().await?;
let phase = "stalled";
let key = format!(".upgrade-readiness/{phase}/node-0");
target.inject_for_key(FakeTargetOperation::PutObject, &key, FaultAction::Stall(Duration::from_secs(30)), 1);
let error = wait_for_upgrade_write_readiness(&[client], phase, Duration::from_secs(1))
.await
.expect_err("a stalled request must not outlive the readiness deadline");
assert!(error.to_string().contains("deadline exceeded during PutObject"), "{error}");
target.shutdown().await;
Ok(())
}
}
/// Pins the published old writer's limitation and the supported recovery
/// procedure. This is not a promise that mixed-version ODM is supported.
/// Replace the loss assertion when ODM gains independent persistence;
@@ -690,6 +926,7 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
configure_cluster_logs(&mut cluster)?;
cluster.start_with_binary(&previous_binary).await?;
cluster.create_test_bucket(MIXED_BUCKET).await?;
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
cluster.stop_node(0)?;
cluster.start_node_from_binary(0, &current_binary).await?;
@@ -705,6 +942,11 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
cluster.start_node_from_binary(3, &current_binary).await?;
for (node_idx, client) in cluster.create_all_clients()?.iter().enumerate() {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
UPGRADE_READINESS_BODY,
"current node {node_idx} must retain the previous-release seed"
);
for phase in ["one-current-node", "one-previous-node"] {
wait_for_phase_listing(
client,
@@ -732,7 +974,8 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
"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"
"all nodes list every mixed-version object after homogeneous-current convergence",
"the previous-release seed survives the baseline restart and every upgrade phase"
],
}),
)?;
@@ -837,12 +1080,23 @@ async fn put_object_through_quota_warmup(client: &Client, bucket: &str, key: &st
async fn get_bucket_quota(env: &RustFSTestEnvironment, bucket: &str) -> Result<Option<u64>, BoxError> {
let path = format!("/rustfs/admin/v3/quota/{bucket}");
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
if status != StatusCode::OK {
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
let deadline = Instant::now() + QUOTA_USAGE_READINESS_TIMEOUT;
loop {
let (status, response) = tokio::time::timeout_at(
deadline,
admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key),
)
.await
.map_err(|_| format!("reading the quota of {bucket} exceeded the usage-readiness timeout"))??;
if status == StatusCode::OK {
let quota: serde_json::Value = serde_json::from_str(&response)?;
return Ok(quota.get("quota").and_then(serde_json::Value::as_u64));
}
if status != StatusCode::SERVICE_UNAVAILABLE || Instant::now() >= deadline {
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
}
sleep(Duration::from_millis(500)).await;
}
let quota: serde_json::Value = serde_json::from_str(&response)?;
Ok(quota.get("quota").and_then(serde_json::Value::as_u64))
}
/// `GET /rustfs/admin/v3/list-remote-targets?bucket=...`.
-1
View File
@@ -208,7 +208,6 @@ aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a",
urlencoding = { workspace = true }
smallvec = { workspace = true, features = ["serde"] }
shadow-rs = { workspace = true, default-features = false }
async-recursion.workspace = true
aws-credential-types = { workspace = true }
aws-smithy-types = { workspace = true }
aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
+1 -1
View File
@@ -448,7 +448,7 @@ pub mod disk {
pub mod error {
pub use crate::error::{
Error, PoolMetadataError, PoolMetadataFailure, Result, StorageError, classify_system_path_failure_reason,
is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
is_err_bucket_not_found, is_err_invalid_upload_id, is_err_object_not_found, is_err_version_not_found,
};
}
@@ -495,6 +495,193 @@ mod tests {
assert!(rate > 0.0);
}
// Tokio's paused clock does not advance ratelimit's StdClock. Keep natural
// refill below one whole token for years and supply each exact budget here.
fn supply_reader_test_tokens(throttle: &BucketThrottle, available: u64) {
let rate = u64::try_from(throttle.node_bandwidth_per_sec).expect("test throttle rate should be positive");
let burst = throttle.burst();
*throttle.limiter.lock().expect("test throttle should not poison") = Ratelimiter::builder(rate)
.max_tokens(burst)
.initial_available(available)
.period(Duration::from_nanos(u64::MAX))
.build()
.expect("controlled token budget should be valid");
}
#[tokio::test(start_paused = true)]
async fn test_monitored_reader_retains_partial_tokens_across_waits() {
use crate::bucket::bandwidth::reader::{MonitorReaderOptions, MonitoredReader};
use futures_util::task::noop_waker_ref;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, ReadBuf};
for header_size in [0, 25] {
let monitor = Monitor::new(1);
let opts = BucketOptions {
name: "reader-budget".to_string(),
replication_arn: "reader-budget-arn".to_string(),
};
monitor.set_bandwidth_limit(&opts.name, &opts.replication_arn, 100);
let throttle = monitor.throttle(&opts).expect("test throttle should exist");
supply_reader_test_tokens(&throttle, 25);
let data = [0xAB; 100];
let mut reader = MonitoredReader::new(
monitor,
data.as_slice(),
MonitorReaderOptions {
bucket_options: opts,
header_size,
},
);
let mut output = [0; 100];
let mut buf = ReadBuf::new(&mut output);
let mut cx = Context::from_waker(noop_waker_ref());
assert!(Pin::new(&mut reader).poll_read(&mut cx, &mut buf).is_pending());
assert!(buf.filled().is_empty(), "partial payment must not authorize payload reads");
assert_eq!(throttle.limiter.lock().expect("test throttle").available(), 0);
supply_reader_test_tokens(&throttle, 75);
tokio::time::advance(Duration::from_secs(1)).await;
assert!(
matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut buf), Poll::Ready(Ok(()))),
"25 paid tokens plus 75 later tokens must fund the original read with header_size={header_size}"
);
assert_eq!(buf.filled(), &data[..100 - header_size]);
assert_eq!(throttle.limiter.lock().expect("test throttle").available(), 0);
}
}
#[tokio::test(start_paused = true)]
async fn test_monitored_reader_preserves_paid_read_across_cancellation() {
use crate::bucket::bandwidth::reader::{MonitorReaderOptions, MonitoredReader};
use futures_util::task::noop_waker_ref;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, ReadBuf};
let monitor = Monitor::new(1);
let opts = BucketOptions {
name: "reader-pending".to_string(),
replication_arn: "reader-pending-arn".to_string(),
};
monitor.set_bandwidth_limit(&opts.name, &opts.replication_arn, 100);
let throttle = monitor.throttle(&opts).expect("test throttle should exist");
supply_reader_test_tokens(&throttle, 100);
let (mut writer, inner) = tokio::io::duplex(100);
let mut reader = MonitoredReader::new(
monitor,
inner,
MonitorReaderOptions {
bucket_options: opts,
header_size: 0,
},
);
let mut cx = Context::from_waker(noop_waker_ref());
let mut first_output = [0; 100];
let mut first_read = Box::pin(reader.read(&mut first_output));
assert!(first_read.as_mut().poll(&mut cx).is_pending());
assert_eq!(throttle.limiter.lock().expect("test throttle").available(), 0);
drop(first_read);
writer
.write_all(&[0xAB; 100])
.await
.expect("duplex writer should supply the pending read");
// A canceled read future keeps its reader alive. Resume in a smaller
// buffer without charging the already-paid allowance a second time.
let mut short_output = [0; 40];
let mut short_buf = ReadBuf::new(&mut short_output);
assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut short_buf), Poll::Ready(Ok(()))));
assert_eq!(short_buf.filled(), &[0xAB; 40]);
assert_eq!(throttle.limiter.lock().expect("test throttle").available(), 0);
// Completing the short read discards unused allowance, matching the
// existing precharge policy. The next read must buy a fresh budget.
let mut remaining_output = [0; 100];
let mut remaining_buf = ReadBuf::new(&mut remaining_output);
assert!(Pin::new(&mut reader).poll_read(&mut cx, &mut remaining_buf).is_pending());
supply_reader_test_tokens(&throttle, 100);
tokio::time::advance(Duration::from_secs(1)).await;
assert!(matches!(
Pin::new(&mut reader).poll_read(&mut cx, &mut remaining_buf),
Poll::Ready(Ok(()))
));
assert_eq!(remaining_buf.filled(), &[0xAB; 60]);
assert_eq!(throttle.limiter.lock().expect("test throttle").available(), 0);
drop(writer);
let mut eof_output = [0; 100];
let mut eof_buf = ReadBuf::new(&mut eof_output);
assert!(Pin::new(&mut reader).poll_read(&mut cx, &mut eof_buf).is_pending());
supply_reader_test_tokens(&throttle, 100);
tokio::time::advance(Duration::from_secs(1)).await;
assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut eof_buf), Poll::Ready(Ok(()))));
assert!(eof_buf.filled().is_empty());
assert_eq!(throttle.limiter.lock().expect("test throttle").available(), 0);
assert!(Pin::new(&mut reader).poll_read(&mut cx, &mut eof_buf).is_pending());
}
#[tokio::test(start_paused = true)]
async fn test_monitored_reader_shrinks_unpaid_payload_after_bandwidth_reduction() {
use crate::bucket::bandwidth::reader::{MonitorReaderOptions, MonitoredReader};
use futures_util::task::noop_waker_ref;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, ReadBuf};
for header_size in [0, 25] {
let monitor = Monitor::new(1);
let opts = BucketOptions {
name: "reader-reduced-budget".to_string(),
replication_arn: "reader-reduced-budget-arn".to_string(),
};
monitor.set_bandwidth_limit(&opts.name, &opts.replication_arn, 100);
let initial_throttle = monitor.throttle(&opts).expect("initial throttle should exist");
supply_reader_test_tokens(&initial_throttle, 25);
let data = [0xAB; 100];
let mut reader = MonitoredReader::new(
monitor.clone(),
data.as_slice(),
MonitorReaderOptions {
bucket_options: opts.clone(),
header_size,
},
);
let mut output = [0; 100];
let mut buf = ReadBuf::new(&mut output);
let mut cx = Context::from_waker(noop_waker_ref());
assert!(Pin::new(&mut reader).poll_read(&mut cx, &mut buf).is_pending());
assert!(buf.filled().is_empty());
assert_eq!(initial_throttle.limiter.lock().expect("initial throttle").available(), 0);
monitor.set_bandwidth_limit(&opts.name, &opts.replication_arn, 10);
let reduced_throttle = monitor.throttle(&opts).expect("reduced throttle should exist");
assert_eq!(reduced_throttle.burst(), 10);
supply_reader_test_tokens(&reduced_throttle, 0);
tokio::time::advance(Duration::from_secs(1)).await;
let result = Pin::new(&mut reader).poll_read(&mut cx, &mut buf);
let expected_payload = if header_size == 0 {
assert!(
matches!(result, Poll::Ready(Ok(()))),
"already-paid payload must not wait for the old burst's unpaid remainder"
);
25
} else {
assert!(result.is_pending(), "the initial 25 tokens paid only the header");
assert!(buf.filled().is_empty(), "payload still requires its own reduced budget");
supply_reader_test_tokens(&reduced_throttle, 10);
tokio::time::advance(Duration::from_secs(1)).await;
assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut buf), Poll::Ready(Ok(()))));
10
};
assert_eq!(buf.filled(), &data[..expected_payload]);
assert_eq!(reduced_throttle.limiter.lock().expect("reduced throttle").available(), 0);
}
}
#[test]
fn test_consume_refills_continuously() {
let clock = TestClock::new();
+26 -7
View File
@@ -41,6 +41,8 @@ pub struct MonitoredReader<R> {
m: Arc<Monitor>,
opts: MonitorReaderOptions,
wait_state: std::sync::Mutex<Option<WaitState>>,
// Payload limit and unpaid tokens for the read retained across Pending polls.
pending_read: Option<(usize, u64)>,
temp_buf: Vec<u8>,
}
@@ -63,6 +65,7 @@ impl<R> MonitoredReader<R> {
m,
opts,
wait_state: std::sync::Mutex::new(None),
pending_read: None,
temp_buf: Vec::new(),
}
}
@@ -90,17 +93,30 @@ impl<R: AsyncRead + Unpin> AsyncRead for MonitoredReader<R> {
let throttle = match this.m.throttle(&this.opts.bucket_options) {
Some(t) => t,
None => return Pin::new(&mut this.r).poll_read(cx, buf),
None => {
this.pending_read = None;
return Pin::new(&mut this.r).poll_read(cx, buf);
}
};
let b = throttle.burst();
debug_assert!(b >= 1, "burst must be at least 1");
let (need, tokens) = calc_need_and_tokens(b, buf.remaining(), &mut this.opts.header_size);
let (deficit, rate, consumed) = throttle.consume(tokens);
let need = need.min(consumed as usize);
let (need, tokens) = match this.pending_read {
Some(reservation) => reservation,
None => calc_need_and_tokens(b, buf.remaining(), &mut this.opts.header_size),
};
// A lower limit may discard unpaid payload, but must retain paid
// payload and any header tokens still owed by this pending read.
let excess_payload = need.saturating_sub(usize::try_from(b).unwrap_or(usize::MAX));
let reduction = tokens.min(u64::try_from(excess_payload).unwrap_or(u64::MAX));
let need = need.saturating_sub(usize::try_from(reduction).unwrap_or(usize::MAX));
let tokens = tokens - reduction;
let (deficit, rate, _) = throttle.consume(tokens);
this.pending_read = Some((need, deficit));
if deficit > 0 && rate > 0.0 {
let duration = std::time::Duration::from_secs_f64(deficit as f64 / rate);
// A changed limit may reduce the burst while this read is pending.
let duration = std::time::Duration::from_secs_f64(deficit.min(b) as f64 / rate);
debug!(
tokens = tokens,
deficit = deficit,
@@ -115,14 +131,17 @@ impl<R: AsyncRead + Unpin> AsyncRead for MonitoredReader<R> {
warn!("MonitoredReader wait_state mutex poisoned, recovering");
e.into_inner()
}) = Some(WaitState { sleep });
return Poll::Pending;
}
Poll::Ready(()) => {}
Poll::Ready(()) => cx.waker().wake_by_ref(),
}
return Poll::Pending;
}
let filled_before = buf.filled().len();
let result = poll_limited_read(&mut this.r, cx, buf, need, &mut this.temp_buf);
if result.is_ready() {
this.pending_read = None;
}
if let Poll::Ready(Ok(())) = result {
let read_bytes = buf.filled().len().saturating_sub(filled_before) as u64;
if read_bytes > 0 {
+28 -13
View File
@@ -64,25 +64,40 @@ impl BucketDurabilityConfig {
}
}
/// Default durability tier seeded into a newly created bucket's metadata
/// (rustfs/backlog#1811). `relaxed` aligns new buckets with MinIO's default
/// posture: object data is still fdatasynced, while xl.meta and directory-entry
/// fsyncs follow the relaxed durability gate.
/// Default durability policy for newly created buckets. An unset value must
/// inherit the process-wide mode so a default single-node deployment remains
/// strict. Relaxed and none remain explicit operator choices.
pub const ENV_NEW_BUCKET_DURABILITY_MODE: &str = "RUSTFS_NEW_BUCKET_DURABILITY_MODE";
pub const DEFAULT_NEW_BUCKET_DURABILITY_MODE: &str = BUCKET_DURABILITY_MODE_RELAXED;
pub const DEFAULT_NEW_BUCKET_DURABILITY_MODE: &str = "inherit";
const EVENT_NEW_BUCKET_DURABILITY_MODE: &str = "new_bucket_durability_mode";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_BUCKET_DURABILITY: &str = "bucket_durability";
/// The `durability.json` bytes to seed into a freshly created bucket's metadata.
/// Empty means "no override" (the bucket then follows the global
/// `RUSTFS_DURABILITY_MODE`); otherwise the serialized chosen tier. Operators
/// can set `inherit` to disable the new-bucket override. Invalid values also
/// fail closed to inherit the global mode instead of seeding a surprising tier.
pub fn new_bucket_durability_config_json() -> Vec<u8> {
/// `RUSTFS_DURABILITY_MODE`); otherwise the serialized chosen tier. Invalid
/// values also fail closed to inherit the global mode instead of seeding a
/// surprising tier.
pub fn new_bucket_durability_config_json(bucket: &str) -> Vec<u8> {
let raw = std::env::var(ENV_NEW_BUCKET_DURABILITY_MODE).unwrap_or_else(|_| DEFAULT_NEW_BUCKET_DURABILITY_MODE.to_string());
let mode = raw.trim();
if mode.eq_ignore_ascii_case("inherit") || mode.is_empty() || !BucketDurabilityConfig::is_valid_mode(mode) {
return Vec::new();
}
serde_json::to_vec(&BucketDurabilityConfig::new(mode)).expect("BucketDurabilityConfig serialization cannot fail")
let config = BucketDurabilityConfig::new(mode);
if let Some(mode) = config.normalized_mode().filter(|mode| mode != BUCKET_DURABILITY_MODE_STRICT) {
tracing::warn!(
event = EVENT_NEW_BUCKET_DURABILITY_MODE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_BUCKET_DURABILITY,
state = "non_strict_new_bucket_override",
bucket = %bucket,
mode = %mode,
"New bucket durability is explicitly configured below strict"
);
}
serde_json::to_vec(&config).expect("BucketDurabilityConfig serialization cannot fail")
}
#[cfg(test)]
@@ -90,7 +105,7 @@ mod tests {
use super::*;
fn new_bucket_seeded_mode() -> Option<String> {
let json = new_bucket_durability_config_json();
let json = new_bucket_durability_config_json("test-bucket");
if json.is_empty() {
return None;
}
@@ -132,9 +147,9 @@ mod tests {
}
#[test]
fn new_bucket_default_seeds_relaxed_when_unset() {
fn new_bucket_default_inherits_when_unset() {
temp_env::with_var_unset(ENV_NEW_BUCKET_DURABILITY_MODE, || {
assert_eq!(new_bucket_seeded_mode().as_deref(), Some(BUCKET_DURABILITY_MODE_RELAXED));
assert_eq!(new_bucket_seeded_mode(), None);
});
}
@@ -5606,6 +5606,11 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
//debug!("lc_event.action: {:?}", lc_event.action);
debug!("expiry_on_non_transitioned_objects opts: {:?}", opts);
let usage_accounting = if lc_event.action == IlmAction::DeleteAction && !versioned && !version_suspended && !oi.is_dir {
Some(crate::data_usage::begin_expiry_usage_accounting(&oi.bucket).await)
} else {
None
};
let mut dobj = match api
.delete_object_with_tier_delete_journal(&oi.bucket, &encode_dir_object(&oi.name), opts)
.await
@@ -5625,6 +5630,14 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
return false;
}
};
if let Some(accounting) = usage_accounting
&& !dobj.name.is_empty()
&& !dobj.delete_marker
&& !dobj.is_dir
&& let Ok(size) = crate::data_usage::quota_object_size(&dobj)
{
accounting.commit(size).await;
}
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
// The object (or all its versions, for delete_all) was expired; evict any
@@ -12891,6 +12904,130 @@ mod tests {
);
}
#[tokio::test]
#[serial]
async fn ordinary_expiry_accounts_the_committed_receipt_once() {
use crate::bucket::quota::{QuotaOperation, checker::QuotaChecker};
use crate::data_usage::{apply_bucket_usage_memory_overlay, replace_bucket_usage_memory_from_info};
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
let (_disk_paths, ecstore) = setup_test_env().await;
for size in [0usize, 42] {
let bytes = u64::try_from(size).expect("fixture size fits");
let bucket = format!("expiry-accounting-{}", Uuid::new_v4().simple());
create_test_bucket(&ecstore, &bucket).await;
let mut reader = PutObjReader::from_vec(vec![0; size]);
let mut queued = ecstore
.put_object(&bucket, "expire", &mut reader, &ObjectOptions::default())
.await
.expect("create expiring object");
let mut reader = PutObjReader::from_vec(vec![0; size]);
ecstore
.put_object(&bucket, "keep", &mut reader, &ObjectOptions::default())
.await
.expect("create retained object");
let mut baseline = DataUsageInfo {
last_update: Some(std::time::SystemTime::now()),
usage_snapshot_complete: true,
buckets_count: 1,
..Default::default()
};
baseline.buckets_usage.insert(
bucket.clone(),
BucketUsageInfo {
objects_count: 2,
versions_count: 2,
size: bytes * 2,
..Default::default()
},
);
baseline.bucket_sizes.insert(bucket.clone(), bytes * 2);
baseline.calculate_totals();
replace_bucket_usage_memory_from_info(&baseline).await;
// Legacy quota admission consumes the same conservative overlay.
// Durable reservations have their own storage-commit accounting.
metadata_sys::update(
&bucket,
rustfs_config::QUOTA_CONFIG_FILE,
format!(r#"{{"quota":{},"quota_type":"Hard"}}"#, bytes * 2).into_bytes(),
)
.await
.expect("configure the legacy quota");
let checker =
QuotaChecker::new(Arc::new(tokio::sync::RwLock::new(metadata_sys::BucketMetadataSys::new(ecstore.clone()))));
let attempted_size = bytes.max(1);
let quota = checker
.check_quota(&bucket, QuotaOperation::PutObject, attempted_size)
.await
.expect("check quota before expiration");
assert!(!quota.allowed);
assert!(!quota.uses_durable_reservations);
assert_eq!(quota.current_usage, Some(bytes * 2));
// Queued metadata is not the accounting receipt from the storage commit.
queued.size = 1;
let event = lifecycle::Event {
action: IlmAction::DeleteAction,
..Default::default()
};
let incarnation = ecstore
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("bucket incarnation");
for (attempt_incarnation, expected) in [(Uuid::new_v4(), false), (incarnation, true), (incarnation, false)] {
assert_eq!(
super::apply_expiry_on_non_transitioned_objects(
ecstore.clone(),
&queued,
&event,
&LcEventSrc::Scanner,
attempt_incarnation,
)
.await,
expected
);
let mut response = baseline.clone();
apply_bucket_usage_memory_overlay(&mut response).await;
let remaining = if attempt_incarnation == incarnation { 1 } else { 2 };
assert_eq!(response.buckets_usage[&bucket].objects_count, remaining);
assert_eq!(response.buckets_usage[&bucket].size, remaining * bytes);
let quota = checker
.check_quota(&bucket, QuotaOperation::PutObject, attempted_size)
.await
.expect("check quota before scanner confirmation");
assert!(!quota.allowed, "expiry must not release unconfirmed quota credit");
assert_eq!(quota.current_usage, Some(bytes * 2));
}
let mut confirmed = baseline.clone();
confirmed.last_update = Some(std::time::SystemTime::now());
confirmed.scanner_epoch = Some(7);
confirmed.scanner_cycle = Some(10);
let usage = confirmed.buckets_usage.get_mut(&bucket).expect("bucket is in the snapshot");
usage.size = bytes;
usage.objects_count = 1;
usage.versions_count = 0; // The scanner does not count unversioned objects as versions.
confirmed.bucket_sizes.insert(bucket.clone(), bytes);
confirmed.calculate_totals();
replace_bucket_usage_memory_from_info(&confirmed).await;
let quota = checker
.check_quota(&bucket, QuotaOperation::PutObject, attempted_size)
.await
.expect("check quota after scanner confirmation");
assert_eq!(quota.allowed, bytes > 0);
assert_eq!(quota.current_usage, Some(bytes));
let too_large = checker
.check_quota(&bucket, QuotaOperation::PutObject, bytes + 1)
.await
.expect("check the first byte above the quota limit");
assert!(!too_large.allowed);
assert!(
ecstore
.get_object_info(&bucket, "keep", &ObjectOptions::default())
.await
.is_ok()
);
}
}
#[tokio::test]
#[serial]
async fn queued_delete_all_rechecks_a_same_id_rule_moved_into_the_future() {
+4 -10
View File
@@ -593,7 +593,7 @@ impl BucketMetadata {
/// durability posture.
pub fn new_with_default_durability(name: &str) -> Self {
let mut metadata = Self::new(name);
metadata.durability_config_json = super::durability::new_bucket_durability_config_json();
metadata.durability_config_json = super::durability::new_bucket_durability_config_json(name);
metadata
}
@@ -1732,21 +1732,15 @@ mod test {
}
#[test]
fn new_bucket_metadata_constructor_seeds_default_durability() {
fn new_bucket_metadata_constructor_inherits_default_durability() {
temp_env::with_var_unset(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, || {
let metadata = BucketMetadata::new_with_default_durability("new-user-bucket");
assert_eq!(
metadata.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
);
assert!(metadata.durability_config().is_none());
let encoded = metadata.marshal_msg().expect("marshal metadata");
let decoded = BucketMetadata::unmarshal(&encoded).expect("unmarshal metadata");
assert_eq!(decoded.durability_config_json, metadata.durability_config_json);
assert_eq!(
decoded.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
);
assert!(decoded.durability_config().is_none());
});
}
@@ -1206,6 +1206,17 @@ mod tests {
assert_eq!(mixed_timeout, DiskError::Timeout);
}
#[test]
fn mixed_missing_and_body_io_errors_remain_an_actionable_quorum_failure() {
let failures = [
DiskError::FileNotFound,
DiskError::Io(std::io::Error::other("remote body stream aborted")),
];
assert!(!is_benign_not_found_listing_failure(&failures));
assert_eq!(classify_listing_quorum_failure(&failures), DiskError::ErasureReadQuorum);
}
#[test]
fn missing_path_error_classification_excludes_actionable_failures() {
assert!(is_missing_path_error(&DiskError::FileNotFound));
@@ -901,6 +901,7 @@ pub fn build_internode_data_transport_from_env() -> Result<Arc<dyn InternodeData
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::io::AsyncWriteExt;
use tokio::sync::{Barrier, Notify};
async fn wait_for_capability_flight_waiters(entry: &PutFileCapabilityCacheEntry, waiters: usize) {
@@ -1026,6 +1027,45 @@ mod tests {
assert!(matches!(open_err, Error::MethodNotAllowed));
}
#[tokio::test]
async fn walk_dir_restores_explicit_remote_missing_error() {
let _ = rustfs_credentials::set_global_rpc_secret("walk-dir-error-test-secret".to_string());
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => listener,
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => return,
Err(err) => panic!("test listener should bind: {err}"),
};
let addr = listener.local_addr().expect("test listener address should be available");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("test server should accept the request");
let mut request = [0_u8; 2048];
let _ = socket.read(&mut request).await.expect("request should be readable");
socket
.write_all(
b"HTTP/1.1 500 Internal Server Error\r\nx-rustfs-disk-error: file-not-found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
)
.await
.expect("typed disk error response should be writable");
});
let endpoint = format!("http://{addr}");
let error = match TcpHttpInternodeDataTransport
.open_walk_dir(WalkDirStreamRequest {
endpoint: endpoint.clone(),
disk: "disk-a".to_string(),
body: b"{}".to_vec(),
stall_timeout: Some(Duration::from_secs(2)),
})
.await
{
Ok(_) => panic!("an explicit remote missing-path response must fail the open"),
Err(error) => error,
};
assert!(matches!(error, Error::FileNotFound));
server.await.expect("test server task should finish");
}
#[test]
fn tcp_http_capabilities_are_behavior_preserving() {
let transport = TcpHttpInternodeDataTransport;
@@ -56,7 +56,7 @@ impl DiagnosticPacing {
// Reservation is never refunded: a failed or dropped RPC may have sent some payload.
let previous = self
.charged_bytes
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |charged| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |charged| {
charged.checked_add(bytes).filter(|total| *total <= MAX_NETWORK_PROBE_BYTES)
})
.map_err(|_| NetworkPeerProbeError::LimitExceeded)?;
+1
View File
@@ -3154,6 +3154,7 @@ mod tests {
version_purge_status: Default::default(),
replication_decision: String::new(),
checksum: None,
multipart_completion_replayed: false,
}
}
}
+11 -7
View File
@@ -12976,7 +12976,7 @@ impl ECStore {
.targets
.iter()
.find(|target| target.pool_index == permitted_target_pool_index)
.and_then(&candidate)
.and_then(candidate)
{
return Ok(pool_index);
}
@@ -19542,7 +19542,10 @@ mod tests {
.await
.expect("second-node activation should not panic")
.expect_err("the second reservation must observe and reject the committed first reservation");
assert!(err.to_string().contains("requires 60 bytes, but 40 bytes are available"));
assert!(
err.to_string().contains("requires 60 bytes, but 40 bytes are available"),
"the second reservation must report the committed capacity rejection, got {err:?}"
);
let mut persisted = PoolMeta::default();
persisted
@@ -19855,9 +19858,9 @@ mod tests {
.reload_pool_meta()
.await
.expect("load the active generation on the remote node");
other_store
.decommission_cancel(0)
tokio::time::timeout(std::time::Duration::from_secs(30), other_store.decommission_cancel(0))
.await
.expect("remote cancel must persist pool metadata without re-entering its state locks")
.expect("the remote node should cancel the old generation");
other_store
.clear_decommission(0)
@@ -20122,9 +20125,9 @@ mod tests {
.expect("the stale operation should have a valid terminal fence plan")
.expect("the stale operation should have an active reservation");
first_node
.decommission_cancel(0)
tokio::time::timeout(std::time::Duration::from_secs(30), first_node.decommission_cancel(0))
.await
.expect("cancel must persist pool metadata without re-entering its state locks")
.expect("cancel the first durable operation");
first_node
.clear_decommission(0)
@@ -20627,8 +20630,9 @@ mod tests {
.await
.expect("second target mutation task should not panic")
.expect("second target mutation should finalize");
cancel
tokio::time::timeout(std::time::Duration::from_secs(30), cancel)
.await
.expect("cancel must finish after target mutations release their gates")
.expect("decommission cancellation task should not panic")
.expect("decommission cancellation should finish after target mutations");
+77 -21
View File
@@ -2288,7 +2288,7 @@ mod decommission_lock_order_tests {
let (lossy_store, refresh_calls) = store_with_capacity_lease_loss(&other_store).await;
set_decommission_capacity_info_overrides_for_test(lossy_store.id, (0..40).map(|_| capacity_snapshot()).collect());
let barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::AfterObjectPublication);
let migration = tokio::spawn({
let mut migration = tokio::spawn({
let migration_store = Arc::clone(&lossy_store);
let migration_bucket = bucket.clone();
async move {
@@ -2304,7 +2304,12 @@ mod decommission_lock_order_tests {
.await
}
});
barrier.wait_until_paused().await;
tokio::select! {
() = barrier.wait_until_paused() => {}
result = &mut migration => {
panic!("initial multipart migration completed before reaching the publication barrier: {result:?}");
}
}
tokio::time::pause();
let published = lossy_store.pools[2]
.get_object_info(
@@ -2321,6 +2326,18 @@ mod decommission_lock_order_tests {
.expect("the multipart target must publish before capacity progress save");
assert!(published.is_multipart(), "published target must retain multipart identity");
assert_eq!(published.version_id.map(|version| version.to_string()), Some(source_version.clone()));
for prefix in [
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
rustfs_utils::http::MINIO_INTERNAL_PREFIX,
] {
assert_eq!(
published
.user_defined
.get(&format!("{prefix}{}", rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID)),
Some(&source_upload.upload_id),
"migration must preserve the source completion identity under both internal prefixes"
);
}
let mut published_reader = lossy_store.pools[2]
.get_object_reader(
&bucket,
@@ -6851,7 +6868,7 @@ mod decommission_lock_order_tests {
let migration_store = Arc::clone(&store);
let migration_bucket = bucket.clone();
async move {
data_movement::migrate_decommission_object(
let result = data_movement::migrate_decommission_object(
migration_store,
0,
migration_bucket,
@@ -6860,16 +6877,19 @@ mod decommission_lock_order_tests {
"decommission_object_lock_order",
Some(owner),
)
.await
.await;
(result, std::time::Instant::now())
}
});
barrier.wait_until_owner_paused().await;
let ordinary_launched_at = std::time::Instant::now();
let mut ordinary_mutation = tokio::spawn({
let ordinary_store = Arc::clone(&other_store);
let ordinary_bucket = bucket.clone();
async move {
match mutation {
let ordinary_started_at = std::time::Instant::now();
let result = match mutation {
ExternalObjectMutation::Put => {
let mut data = PutObjReader::from_vec(b"ordinary replacement".to_vec());
ordinary_store
@@ -6941,7 +6961,8 @@ mod decommission_lock_order_tests {
.restore_transitioned_object(&ordinary_bucket, object, &opts)
.await
}
}
};
(result, ordinary_started_at, std::time::Instant::now())
}
});
if let Some(get_barrier) = restore_get_barrier.as_ref() {
@@ -6989,8 +7010,10 @@ mod decommission_lock_order_tests {
} else {
barrier.wait_until_external_capacity_released().await;
}
let ordinary_lock_phase_observed_at = std::time::Instant::now();
assert!(!ordinary_mutation.is_finished());
let migration_result = if pause_restore_tail {
let owner_released_at = std::time::Instant::now();
let (migration_result, migration_finished_at) = if pause_restore_tail {
barrier.release_owner();
tokio::time::timeout(std::time::Duration::from_secs(30), &mut migration)
.await
@@ -7005,7 +7028,7 @@ mod decommission_lock_order_tests {
};
migration_result.expect("migration should commit to its reserved target");
let ordinary_result = if pause_restore_tail {
let (ordinary_result, ordinary_started_at, ordinary_finished_at) = if pause_restore_tail {
let handoff_barrier = PutObjectCommitBarrier::install(&bucket, object, PutObjectCommitPause::AfterRenameHandoff);
let tail_barrier =
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
@@ -7077,7 +7100,20 @@ mod decommission_lock_order_tests {
.expect("ordinary object mutation task should join")
};
drop(barrier);
ordinary_result.expect("ordinary object mutation should commit to the unreserved target");
ordinary_result.unwrap_or_else(|error| {
panic!(
"ordinary object mutation should commit to the unreserved target: {error:?}; \
ordinary_start_after_launch={:?}, lock_phase_observed_after_launch={:?}, \
owner_release_after_launch={:?}, migration_after_owner_release={:?}, \
ordinary_finish_after_launch={:?}, ordinary_finished_before_migration={}",
ordinary_started_at.duration_since(ordinary_launched_at),
ordinary_lock_phase_observed_at.duration_since(ordinary_launched_at),
owner_released_at.duration_since(ordinary_launched_at),
migration_finished_at.duration_since(owner_released_at),
ordinary_finished_at.duration_since(ordinary_launched_at),
ordinary_finished_at < migration_finished_at,
);
});
store.pools[1]
.get_object_info(
&bucket,
@@ -7252,7 +7288,7 @@ mod decommission_lock_order_tests {
let migration_store = Arc::clone(&store);
let migration_bucket = bucket.clone();
async move {
data_movement::migrate_decommission_object(
let result = data_movement::migrate_decommission_object(
migration_store,
0,
migration_bucket,
@@ -7261,11 +7297,13 @@ mod decommission_lock_order_tests {
"same_object_copy_lock_order",
Some(owner),
)
.await
.await;
(result, std::time::Instant::now())
}
});
barrier.wait_until_owner_paused().await;
let ordinary_launched_at = std::time::Instant::now();
let copy_task = tokio::spawn({
let copy_store = Arc::clone(&other_store);
let copy_bucket = bucket.clone();
@@ -7280,7 +7318,8 @@ mod decommission_lock_order_tests {
..Default::default()
};
async move {
copy_store
let ordinary_started_at = std::time::Instant::now();
let result = copy_store
.copy_object(
&copy_bucket,
object,
@@ -7291,27 +7330,44 @@ mod decommission_lock_order_tests {
&destination_opts,
)
.await
.map(|_| ())
.map(|_| ());
(result, ordinary_started_at, std::time::Instant::now())
}
});
barrier.wait_until_external_capacity_released().await;
let ordinary_lock_phase_observed_at = std::time::Instant::now();
assert!(
!copy_task.is_finished(),
"historical same-object CopyObject must wait behind the migration object fence"
);
let owner_released_at = std::time::Instant::now();
barrier.release_owner();
drop(barrier);
tokio::time::timeout(std::time::Duration::from_secs(30), migration)
let (migration_result, migration_finished_at) = tokio::time::timeout(std::time::Duration::from_secs(30), migration)
.await
.expect("migration must not deadlock with historical same-object CopyObject")
.expect("migration task should join")
.expect("migration should commit to its reserved target");
tokio::time::timeout(std::time::Duration::from_secs(30), copy_task)
.await
.expect("historical same-object CopyObject must finish after the migration releases its object fence")
.expect("CopyObject task should join")
.expect("CopyObject should preserve the expected current-version precondition");
.expect("migration task should join");
migration_result.expect("migration should commit to its reserved target");
let (ordinary_result, ordinary_started_at, ordinary_finished_at) =
tokio::time::timeout(std::time::Duration::from_secs(30), copy_task)
.await
.expect("historical same-object CopyObject must finish after the migration releases its object fence")
.expect("CopyObject task should join");
ordinary_result.unwrap_or_else(|error| {
panic!(
"CopyObject should preserve the expected current-version precondition: {error:?}; \
ordinary_start_after_launch={:?}, lock_phase_observed_after_launch={:?}, \
owner_release_after_launch={:?}, migration_after_owner_release={:?}, \
ordinary_finish_after_launch={:?}, ordinary_finished_before_migration={}",
ordinary_started_at.duration_since(ordinary_launched_at),
ordinary_lock_phase_observed_at.duration_since(ordinary_launched_at),
owner_released_at.duration_since(ordinary_launched_at),
migration_finished_at.duration_since(owner_released_at),
ordinary_finished_at.duration_since(ordinary_launched_at),
ordinary_finished_at < migration_finished_at,
);
});
}
#[tokio::test]
@@ -306,7 +306,7 @@ pub mod test_util {
let Some(fault) = fault else { return Ok(None) };
if fault
.remaining
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1))
.try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1))
!= Ok(1)
{
return Ok(None);
+658 -31
View File
@@ -43,7 +43,7 @@ use std::{
future::Future,
sync::{
Arc, LazyLock, OnceLock,
atomic::{AtomicU64, Ordering},
atomic::{AtomicU64, AtomicUsize, Ordering},
},
time::{Duration, SystemTime},
};
@@ -67,12 +67,14 @@ struct CachedBucketUsage {
usage_updated_at: SystemTime,
// Set by request-path mutations until a scanner snapshot catches up to the same core counts.
dirty: bool,
// Set when a newer scanner snapshot was observed but did not include the dirty counts yet.
// Set when an observed snapshot cannot replace this absolute baseline.
stale_snapshot_pending: bool,
// First complete scanner generation observed after an unknown-baseline
// mutation. A strictly later generation is required before the mutation
// evidence can be discarded.
pending_scanner_position: Option<(u64, u64)>,
// Generation that supplied this absolute baseline, retained across mutations.
scanner_position: Option<(u64, u64)>,
// Deletes are visible to admin immediately, but quota admission keeps
// them pending until a complete scanner generation reconciles the set.
// This marker intentionally remains process-local: the delete request
@@ -83,6 +85,79 @@ struct CachedBucketUsage {
// necessarily a complete scanner reconciliation and therefore creates a
// fresh cache entry with no pending hold.
pending_negative_delta: u64,
pending_negative_updated_at: Option<SystemTime>,
pending_negative_reconciliation: Option<PendingDeleteReconciliation>,
in_flight_expirations: Arc<AtomicUsize>,
}
#[derive(Debug, Clone)]
struct PendingDeleteReconciliation {
bytes: u64,
deleted_at: SystemTime,
observed: Option<((u64, u64), SystemTime)>,
}
impl CachedBucketUsage {
fn reconcile_pending_deletes(&mut self, position: Option<(u64, u64)>, updated_at: SystemTime) {
let Some(position) = position else { return };
let Some(pending) = self.pending_negative_reconciliation.as_mut() else { return };
match pending.observed {
None if updated_at >= pending.deleted_at => pending.observed = Some((position, updated_at)),
Some((previous, observed_at)) if position.0 >= previous.0 && position.1 > previous.1 && updated_at >= observed_at => {
self.pending_negative_delta = self.pending_negative_delta.saturating_sub(pending.bytes);
self.pending_negative_reconciliation = self
.pending_negative_updated_at
.filter(|_| self.pending_negative_delta > 0)
.map(|deleted_at| PendingDeleteReconciliation {
bytes: self.pending_negative_delta,
deleted_at,
observed: (updated_at >= deleted_at).then_some((position, updated_at)),
});
}
_ => {}
}
}
}
/// Keeps a scanner refresh from including a delete before its memory receipt.
pub(crate) struct ExpiryUsageAccounting<'a> {
bucket: &'a str,
in_flight: Arc<AtomicUsize>,
}
impl ExpiryUsageAccounting<'_> {
pub(crate) async fn commit(self, deleted_size: u64) {
let mut cache = memory_cache().write().await;
if let Some(entry) = cache.get_mut(self.bucket)
&& Arc::ptr_eq(&entry.in_flight_expirations, &self.in_flight)
{
apply_bucket_object_delete_memory(entry, deleted_size, true);
}
}
}
impl Drop for ExpiryUsageAccounting<'_> {
fn drop(&mut self) {
self.in_flight.fetch_sub(1, Ordering::AcqRel);
}
}
pub(crate) async fn begin_expiry_usage_accounting(bucket: &str) -> ExpiryUsageAccounting<'_> {
ensure_bucket_usage_cached(bucket).await;
let mut cache = memory_cache().write().await;
// The caller owns the bucket name through commit. Avoid allocating either
// a receipt name or a map key for every expiry in an already cached bucket.
let in_flight = match cache.get(bucket) {
Some(entry) => Arc::clone(&entry.in_flight_expirations),
None => {
let entry = cached_bucket_usage_now(BucketUsageInfo::default());
let in_flight = Arc::clone(&entry.in_flight_expirations);
cache.insert(bucket.to_owned(), entry);
in_flight
}
};
in_flight.fetch_add(1, Ordering::AcqRel);
ExpiryUsageAccounting { bucket, in_flight }
}
type UsageMemoryCache = Arc<RwLock<HashMap<String, CachedBucketUsage>>>;
@@ -1944,7 +2019,11 @@ fn cached_bucket_usage_from_backend(usage: BucketUsageInfo, updated_at: SystemTi
dirty: false,
stale_snapshot_pending: false,
pending_scanner_position: None,
scanner_position: None,
pending_negative_delta: 0,
pending_negative_updated_at: None,
pending_negative_reconciliation: None,
in_flight_expirations: Arc::new(AtomicUsize::new(0)),
}
}
@@ -1958,7 +2037,11 @@ fn cached_bucket_usage_now(usage: BucketUsageInfo) -> CachedBucketUsage {
dirty: false,
stale_snapshot_pending: false,
pending_scanner_position: None,
scanner_position: None,
pending_negative_delta: 0,
pending_negative_updated_at: None,
pending_negative_reconciliation: None,
in_flight_expirations: Arc::new(AtomicUsize::new(0)),
}
}
@@ -1974,27 +2057,26 @@ fn bucket_usage_counts_match(left: &BucketUsageInfo, right: &BucketUsageInfo) ->
}
fn preserve_unknown_dirty_usage(
existing: &CachedBucketUsage,
existing: &mut CachedBucketUsage,
snapshot_position: Option<(u64, u64)>,
snapshot_update: SystemTime,
) -> Option<CachedBucketUsage> {
) -> bool {
if existing.authoritative || !existing.dirty {
return None;
return false;
}
if existing
.pending_scanner_position
.zip(snapshot_position)
.is_some_and(|(previous, current)| current.0 > previous.0 || (current.0 == previous.0 && current.1 > previous.1))
{
return None;
return false;
}
let mut preserved = existing.clone();
preserved.stale_snapshot_pending = true;
if preserved.pending_scanner_position.is_none() && snapshot_update >= existing.usage_updated_at {
preserved.pending_scanner_position = snapshot_position;
existing.stale_snapshot_pending = true;
if existing.pending_scanner_position.is_none() && snapshot_update >= existing.usage_updated_at {
existing.pending_scanner_position = snapshot_position;
}
Some(preserved)
true
}
#[cfg(any(test, feature = "test-util"))]
@@ -2110,6 +2192,10 @@ pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64,
.entry(bucket.to_string())
.or_insert_with(|| cached_bucket_usage_now(BucketUsageInfo::default()));
apply_bucket_object_delete_memory(entry, deleted_size, removed_current_object);
}
fn apply_bucket_object_delete_memory(entry: &mut CachedBucketUsage, deleted_size: u64, removed_current_object: bool) {
entry.usage.size = entry.usage.size.saturating_sub(deleted_size);
entry.pending_negative_delta = entry.pending_negative_delta.saturating_add(deleted_size);
if removed_current_object {
@@ -2118,6 +2204,16 @@ pub async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64,
}
let now = SystemTime::now();
entry.pending_negative_updated_at = Some(now);
// Freeze this tranche even before its first observation. Later deletes
// must not keep moving the timestamp that the scanner needs to catch up to.
if entry.pending_negative_reconciliation.is_none() && entry.pending_negative_delta > 0 {
entry.pending_negative_reconciliation = Some(PendingDeleteReconciliation {
bytes: entry.pending_negative_delta,
deleted_at: now,
observed: None,
});
}
entry.refreshed_at = now;
entry.usage_updated_at = now;
entry.dirty = true;
@@ -2245,23 +2341,48 @@ async fn replace_bucket_usage_memory_from_info_if_generation(data_usage_info: &D
let usage_updated_at = data_usage_info_updated_at(data_usage_info);
let snapshot_position = data_usage_info.scanner_epoch.zip(data_usage_info.scanner_cycle);
let snapshot_converged = data_usage_info.usage_snapshot_converged == Some(true) && !data_usage_info.usage_snapshot_partial;
let mut next_cache = HashMap::with_capacity(data_usage_info.buckets_usage.len());
for (bucket, bucket_usage) in data_usage_info.buckets_usage.iter() {
next_cache.insert(
bucket.clone(),
cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at, true),
);
let mut entry = cached_bucket_usage_from_backend(bucket_usage.clone(), usage_updated_at, true);
entry.scanner_position = snapshot_position;
next_cache.insert(bucket.clone(), entry);
}
let mut cache = memory_cache().write().await;
if expected_generation.is_some_and(|expected| usage_memory_generation() != expected) {
return;
}
for (bucket, existing) in cache.iter() {
match next_cache.entry(bucket.clone()) {
// Move preserved entries instead of cloning their histograms under the lock.
for (bucket, mut existing) in std::mem::take(&mut *cache) {
let advances_baseline = match (existing.scanner_position, snapshot_position) {
(Some(previous), Some(current)) if current.0 >= previous.0 && current.1 >= previous.1 => current.1 > previous.1,
(None, _) => true,
_ => {
existing.stale_snapshot_pending = true;
next_cache.insert(bucket, existing);
continue;
}
};
existing.reconcile_pending_deletes(snapshot_position, usage_updated_at);
if existing.in_flight_expirations.load(Ordering::Acquire) > 0 {
next_cache.insert(bucket, existing);
continue;
}
// Both convergence and the two-observation fallback must advance the
// baseline's cycle. Epoch-only changes cannot prove a new scan, and
// the timestamp check below still protects post-scan mutations.
let reconciled_dirty_usage = advances_baseline
&& snapshot_position.is_some_and(|current| {
snapshot_converged
|| existing
.pending_scanner_position
.is_some_and(|previous| current.0 >= previous.0 && current.1 > previous.1)
});
match next_cache.entry(bucket) {
Entry::Occupied(mut candidate) => {
if let Some(preserved) = preserve_unknown_dirty_usage(existing, snapshot_position, usage_updated_at) {
candidate.insert(preserved);
if preserve_unknown_dirty_usage(&mut existing, snapshot_position, usage_updated_at) {
candidate.insert(existing);
continue;
}
if existing.authoritative && existing.dirty && bucket_usage_counts_match(&existing.usage, &candidate.get().usage)
@@ -2272,37 +2393,44 @@ async fn replace_bucket_usage_memory_from_info_if_generation(data_usage_info: &D
continue;
}
if existing.authoritative && existing.usage_updated_at > usage_updated_at {
candidate.insert(existing.clone());
candidate.insert(existing);
continue;
}
if existing.authoritative && existing.dirty && !bucket_usage_counts_match(&existing.usage, &candidate.get().usage)
if existing.authoritative
&& existing.dirty
&& !reconciled_dirty_usage
&& !bucket_usage_counts_match(&existing.usage, &candidate.get().usage)
{
// A scanner snapshot can be saved after newer writes but still miss them if it listed the bucket earlier.
let mut preserved = existing.clone();
let mut preserved = existing;
if preserved.pending_negative_delta > 0 && preserved.usage.size == candidate.get().usage.size {
// The complete scanner snapshot has reconciled the
// post-delete byte total. Keep the request-path object
// counts until they also converge, but release the
// conservative quota hold.
preserved.pending_negative_delta = 0;
preserved.pending_negative_reconciliation = None;
}
preserved.stale_snapshot_pending = true;
preserved.pending_scanner_position = preserved.pending_scanner_position.or(snapshot_position);
candidate.insert(preserved);
}
}
Entry::Vacant(candidate) => {
if let Some(preserved) = preserve_unknown_dirty_usage(existing, snapshot_position, usage_updated_at) {
candidate.insert(preserved);
} else if existing.authoritative && existing.usage_updated_at > usage_updated_at {
candidate.insert(existing.clone());
} else if existing.authoritative && existing.dirty {
let mut preserved = existing.clone();
if preserve_unknown_dirty_usage(&mut existing, snapshot_position, usage_updated_at)
|| (existing.authoritative && existing.usage_updated_at > usage_updated_at)
{
candidate.insert(existing);
} else if existing.authoritative && existing.dirty && !reconciled_dirty_usage {
let mut preserved = existing;
if preserved.pending_negative_delta > 0 && preserved.usage.size == 0 {
// Omitting the bucket from a complete snapshot confirms
// that its post-delete byte total is zero.
preserved.pending_negative_delta = 0;
preserved.pending_negative_reconciliation = None;
}
preserved.stale_snapshot_pending = true;
preserved.pending_scanner_position = preserved.pending_scanner_position.or(snapshot_position);
candidate.insert(preserved);
}
}
@@ -4352,6 +4480,7 @@ mod tests {
let mut first_snapshot = data_usage_info_for_test(bucket, 10, 420, mutation_update + Duration::from_nanos(1));
first_snapshot.scanner_epoch = Some(7);
first_snapshot.scanner_cycle = Some(10);
first_snapshot.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&first_snapshot).await;
let mut first_response = first_snapshot.clone();
@@ -5405,11 +5534,12 @@ mod tests {
.expect("delete usage should remain cached")
.usage_updated_at;
let stale = data_usage_info_for_test("bucket-a", 2, 768, mutation_update + Duration::from_nanos(1));
// Windows SystemTime has 100 ns resolution; retain strict ordering there.
let stale = data_usage_info_for_test("bucket-a", 2, 768, mutation_update + Duration::from_micros(1));
replace_bucket_usage_memory_from_info(&stale).await;
assert_eq!(get_bucket_usage_memory("bucket-a").await, Some(768));
let mut too_old_matching = data_usage_info_for_test("bucket-a", 1, 256, mutation_update - Duration::from_nanos(1));
let mut too_old_matching = data_usage_info_for_test("bucket-a", 1, 256, mutation_update - Duration::from_micros(1));
too_old_matching
.buckets_usage
.get_mut("bucket-a")
@@ -5423,7 +5553,7 @@ mod tests {
"a matching byte total from before the delete cannot release the hold"
);
let mut reconciled = data_usage_info_for_test("bucket-a", 1, 256, mutation_update + Duration::from_nanos(2));
let mut reconciled = data_usage_info_for_test("bucket-a", 1, 256, mutation_update + Duration::from_micros(2));
reconciled
.buckets_usage
.get_mut("bucket-a")
@@ -5759,6 +5889,503 @@ mod tests {
);
}
#[tokio::test]
#[serial]
async fn complete_scanner_cycles_reconcile_expired_dirty_usage() {
for (remaining_objects, omit_bucket) in [(0, false), (1, false), (0, true)] {
clear_usage_memory_cache_for_test().await;
let bucket = "expired-dirty-usage";
let baseline = data_usage_info_for_test(bucket, 0, 0, SystemTime::now());
replace_bucket_usage_memory_from_info(&baseline).await;
record_bucket_object_write_memory(bucket, None, 42).await;
record_bucket_object_write_memory(bucket, None, 42).await;
// Lifecycle deletion bypasses request-layer memory accounting.
// Repeated complete scans must eventually replace that dirty overlay.
for cycle in 1..=3 {
let mut snapshot = data_usage_info_for_test(bucket, remaining_objects, remaining_objects * 42, SystemTime::now());
snapshot.scanner_epoch = Some(7);
snapshot.scanner_cycle = Some(cycle);
if omit_bucket {
snapshot.buckets_usage.clear();
snapshot.bucket_sizes.clear();
snapshot.buckets_count = 0;
snapshot.calculate_totals();
}
replace_bucket_usage_memory_from_info(&snapshot).await;
if cycle == 3 {
apply_bucket_usage_memory_overlay_if_authoritative(&mut snapshot, true).await;
assert_eq!(snapshot.objects_total_count, remaining_objects);
assert_eq!(snapshot.objects_total_size, remaining_objects * 42);
if omit_bucket {
assert!(!snapshot.buckets_usage.contains_key(bucket));
} else {
assert_eq!(snapshot.buckets_usage[bucket].objects_count, remaining_objects);
assert_eq!(snapshot.buckets_usage[bucket].size, remaining_objects * 42);
}
}
}
}
}
#[tokio::test]
#[serial]
async fn expiry_receipts_exclude_refresh_until_committed_or_cancelled() {
clear_usage_memory_cache_for_test().await;
let bucket = "expiry-receipt";
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 2, 84, SystemTime::now())).await;
let first = begin_expiry_usage_accounting(bucket).await;
let cancelled = begin_expiry_usage_accounting(bucket).await;
let mut after_delete = data_usage_info_for_test(bucket, 1, 42, SystemTime::now());
after_delete.scanner_epoch = Some(7);
after_delete.scanner_cycle = Some(10);
after_delete.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&after_delete).await;
assert_eq!(memory_cache().read().await[bucket].usage.size, 84);
first.commit(42).await;
assert_eq!(memory_cache().read().await[bucket].usage.size, 42);
let mut omitted = data_usage_info_for_test(bucket, 0, 0, SystemTime::now());
omitted.buckets_usage.clear();
omitted.bucket_sizes.clear();
omitted.buckets_count = 0;
omitted.calculate_totals();
replace_bucket_usage_memory_from_info(&omitted).await;
assert_eq!(memory_cache().read().await[bucket].usage.size, 42);
drop(cancelled);
assert_eq!(
memory_cache().read().await[bucket]
.in_flight_expirations
.load(Ordering::Acquire),
0
);
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 1, 42, SystemTime::now())).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(42));
}
#[tokio::test]
#[serial]
async fn cancelled_expiry_receipt_reconciles_without_another_mutation() {
clear_usage_memory_cache_for_test().await;
let bucket = "expiry-cancelled-after-delete";
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 0, 0, SystemTime::now())).await;
record_bucket_object_write_memory(bucket, None, 42).await;
record_bucket_object_write_memory(bucket, None, 42).await;
let receipt = begin_expiry_usage_accounting(bucket).await;
// Storage has deleted one object, but the task has not committed its
// memory receipt. Even two complete scans must not replace its baseline.
let mut scanned = data_usage_info_for_test(bucket, 1, 42, SystemTime::now());
scanned.scanner_epoch = Some(7);
for cycle in [10, 11] {
scanned.scanner_cycle = Some(cycle);
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(84));
}
drop(receipt);
for (cycle, expected) in [(12, 84), (13, 42)] {
scanned.scanner_cycle = Some(cycle);
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(expected));
let mut displayed = scanned.clone();
apply_bucket_usage_memory_overlay(&mut displayed).await;
assert_eq!(displayed.buckets_usage[bucket].size, expected);
assert_eq!(displayed.buckets_usage[bucket].objects_count, expected / 42);
}
}
#[tokio::test]
#[serial]
async fn expiry_restart_reloads_durable_usage_without_persisting_the_overlay() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "expiry-restart";
clear_usage_memory_cache_for_test().await;
let baseline = data_usage_info_for_test(bucket, 2, 84, SystemTime::now());
store_data_usage_in_backend(baseline.clone(), store.clone())
.await
.expect("persist the pre-expiration scanner snapshot");
replace_bucket_usage_memory_from_info(&baseline).await;
begin_expiry_usage_accounting(bucket).await.commit(42).await;
let mut displayed = baseline.clone();
apply_bucket_usage_memory_overlay(&mut displayed).await;
assert_eq!(displayed.buckets_usage[bucket].size, 42);
assert_eq!(get_bucket_usage_memory(bucket).await, Some(84));
// Recreate only process-local usage state, then use the real backend
// loader. This is a restart-state regression, not a process restart E2E.
clear_usage_memory_cache_for_test().await;
invalidate_data_usage_snapshot_cache().await;
let recovered = load_data_usage_from_backend_cached(store.clone())
.await
.expect("reload the durable pre-expiration snapshot");
assert_eq!(recovered.buckets_usage[bucket].size, 84);
replace_bucket_usage_memory_from_info(&recovered).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(84));
let confirmed = data_usage_info_for_test(bucket, 1, 42, SystemTime::now());
store_data_usage_in_backend(confirmed, store.clone())
.await
.expect("persist scanner confirmation without another object mutation");
let recovered = load_data_usage_from_backend_cached(store.clone())
.await
.expect("reload the confirmed snapshot");
replace_bucket_usage_memory_from_info(&recovered).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(42));
// Historical overlay drift must not contaminate the persisted scan.
record_bucket_object_write_memory(bucket, None, 42).await;
clear_usage_memory_cache_for_test().await;
invalidate_data_usage_snapshot_cache().await;
let mut recovered = load_data_usage_from_backend_cached(store)
.await
.expect("reload the confirmed snapshot after losing the stale overlay");
replace_bucket_usage_memory_from_info(&recovered).await;
apply_bucket_usage_memory_overlay(&mut recovered).await;
assert_eq!(recovered.buckets_usage[bucket].objects_count, 1);
assert_eq!(recovered.buckets_usage[bucket].size, 42);
assert_eq!(get_bucket_usage_memory(bucket).await, Some(42));
clear_usage_memory_cache_for_test().await;
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
#[serial]
async fn expiry_receipt_cannot_credit_a_replaced_bucket_cache() {
clear_usage_memory_cache_for_test().await;
let bucket = "expiry-recreated";
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 1, 42, SystemTime::now())).await;
let receipt = begin_expiry_usage_accounting(bucket).await;
clear_usage_memory_cache_for_test().await;
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 2, 84, SystemTime::now())).await;
receipt.commit(42).await;
assert_eq!(memory_cache().read().await[bucket].usage.objects_count, 2);
assert_eq!(get_bucket_usage_memory(bucket).await, Some(84));
}
#[tokio::test]
#[serial]
async fn quota_delete_tranches_advance_despite_newer_mutations() {
clear_usage_memory_cache_for_test().await;
let bucket = "expiry-busy-quota";
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 10, 420, SystemTime::now())).await;
record_bucket_object_delete_memory(bucket, 42, true).await;
let mut observation = data_usage_info_for_test(bucket, 9, 378, SystemTime::now());
observation.scanner_epoch = Some(7);
observation.scanner_cycle = Some(10);
record_bucket_object_write_memory(bucket, None, 42).await;
replace_bucket_usage_memory_from_info(&observation).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(462));
for (epoch, cycle, partial, old_time) in [
(8, 10, false, false),
(6, 11, false, false),
(7, 11, true, false),
(7, 11, false, true),
] {
let mut rejected = observation.clone();
rejected.scanner_epoch = Some(epoch);
rejected.scanner_cycle = Some(cycle);
rejected.usage_snapshot_complete = !partial;
rejected.usage_snapshot_partial = partial;
if old_time {
rejected.last_update = Some(SystemTime::UNIX_EPOCH);
}
replace_bucket_usage_memory_from_info(&rejected).await;
assert_eq!(memory_cache().read().await[bucket].pending_negative_delta, 42);
}
observation.last_update = Some(SystemTime::now());
observation.scanner_cycle = Some(11);
// A newer delete must stay charged, without delaying the older receipt.
record_bucket_object_delete_memory(bucket, 42, true).await;
record_bucket_object_write_memory(bucket, None, 42).await;
replace_bucket_usage_memory_from_info(&observation).await;
assert_eq!(memory_cache().read().await[bucket].pending_negative_delta, 42);
assert_eq!(get_bucket_usage_memory(bucket).await, Some(462));
for cycle in [12, 13] {
observation.last_update = Some(SystemTime::now());
observation.scanner_cycle = Some(cycle);
record_bucket_object_write_memory(bucket, None, 42).await;
replace_bucket_usage_memory_from_info(&observation).await;
}
assert_eq!(memory_cache().read().await[bucket].pending_negative_delta, 0);
assert_eq!(get_bucket_usage_memory(bucket).await, Some(504));
}
#[tokio::test]
#[serial]
async fn quota_delete_hold_stays_bounded_with_deletes_after_every_observation() {
clear_usage_memory_cache_for_test().await;
let bucket = "expiry-continuous-quota";
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 10, 420, SystemTime::now())).await;
record_bucket_object_delete_memory(bucket, 42, true).await;
for cycle in 1..=12 {
let mut observed = data_usage_info_for_test(bucket, 9, 378, SystemTime::now());
observed.scanner_epoch = Some(7);
observed.scanner_cycle = Some(cycle);
record_bucket_object_write_memory(bucket, None, 42).await;
record_bucket_object_delete_memory(bucket, 42, true).await;
replace_bucket_usage_memory_from_info(&observed).await;
let cache = memory_cache().read().await;
assert_eq!(cache[bucket].usage.size, 378);
assert!(
cache[bucket].pending_negative_delta <= 126,
"confirmed older deletes must leave the quota hold"
);
}
}
#[tokio::test]
#[serial]
async fn dirty_usage_reconciliation_requires_a_later_complete_observation() {
clear_usage_memory_cache_for_test().await;
let bucket = "dirty-observation-fence";
let baseline = data_usage_info_for_test(bucket, 0, 0, SystemTime::now());
replace_bucket_usage_memory_from_info(&baseline).await;
record_bucket_object_write_memory(bucket, None, 42).await;
let mutation_at = memory_cache().read().await[bucket].usage_updated_at;
let mut first = data_usage_info_for_test(bucket, 0, 0, mutation_at);
first.scanner_epoch = Some(7);
first.scanner_cycle = Some(10);
let mut partial = first.clone();
partial.usage_snapshot_partial = true;
partial.usage_snapshot_complete = false;
replace_bucket_usage_memory_from_info(&partial).await;
assert_eq!(memory_cache().read().await[bucket].pending_scanner_position, None);
replace_bucket_usage_memory_from_info(&first).await;
for (epoch, cycle, partial, update) in [
(Some(7), Some(10), false, mutation_at),
(Some(8), Some(10), false, mutation_at),
(Some(6), Some(11), false, mutation_at),
(Some(7), Some(9), false, mutation_at),
(Some(7), Some(11), true, mutation_at),
(None, Some(11), false, mutation_at),
(Some(7), None, false, mutation_at),
// Windows `SystemTime` stores 100 ns intervals, so a 1 ns offset is a no-op there.
(Some(7), Some(11), false, mutation_at - Duration::from_micros(1)),
] {
let mut snapshot = first.clone();
snapshot.scanner_epoch = epoch;
snapshot.scanner_cycle = cycle;
snapshot.usage_snapshot_partial = partial;
snapshot.usage_snapshot_complete = !partial;
snapshot.last_update = Some(update);
replace_bucket_usage_memory_from_info(&snapshot).await;
apply_bucket_usage_memory_overlay_if_authoritative(&mut snapshot, true).await;
assert_eq!(snapshot.objects_total_count, 1, "epoch={epoch:?}, cycle={cycle:?}, partial={partial}");
assert_eq!(snapshot.objects_total_size, 42);
}
let mut successor = first;
successor.scanner_epoch = Some(8);
successor.scanner_cycle = Some(11);
replace_bucket_usage_memory_from_info(&successor).await;
apply_bucket_usage_memory_overlay_if_authoritative(&mut successor, true).await;
assert_eq!(successor.objects_total_count, 0);
assert_eq!(successor.objects_total_size, 0);
}
#[tokio::test]
#[serial]
async fn a_new_authoritative_mutation_restarts_reconciliation() {
clear_usage_memory_cache_for_test().await;
let bucket = "dirty-mutation-fence";
replace_bucket_usage_memory_from_info(&data_usage_info_for_test(bucket, 0, 0, SystemTime::now())).await;
record_bucket_object_write_memory(bucket, None, 42).await;
let mut snapshot = data_usage_info_for_test(bucket, 0, 0, SystemTime::now());
snapshot.scanner_epoch = Some(7);
snapshot.scanner_cycle = Some(10);
replace_bucket_usage_memory_from_info(&snapshot).await;
record_bucket_object_write_memory(bucket, None, 42).await;
snapshot.last_update = Some(SystemTime::now());
snapshot.scanner_cycle = Some(11);
replace_bucket_usage_memory_from_info(&snapshot).await;
let mut response = snapshot.clone();
apply_bucket_usage_memory_overlay_if_authoritative(&mut response, true).await;
assert_eq!(response.objects_total_count, 2);
assert_eq!(response.objects_total_size, 84);
snapshot.scanner_cycle = Some(12);
replace_bucket_usage_memory_from_info(&snapshot).await;
apply_bucket_usage_memory_overlay_if_authoritative(&mut snapshot, true).await;
assert_eq!(snapshot.objects_total_count, 0);
assert_eq!(snapshot.objects_total_size, 0);
}
#[tokio::test]
#[serial]
async fn converged_scans_repair_historical_usage_during_continuing_writes() {
let bucket = "historical-busy";
for position in [None, Some((7, 10))] {
clear_usage_memory_cache_for_test().await;
let mut baseline = data_usage_info_for_test(bucket, 436, 4_587_688_512, SystemTime::now());
baseline.scanner_epoch = position.map(|value| value.0);
baseline.scanner_cycle = position.map(|value| value.1);
replace_bucket_usage_memory_from_info(&baseline).await;
for objects in 1..=6 {
record_bucket_object_write_memory(bucket, None, 42).await;
let mut scanned = data_usage_info_for_test(bucket, objects, objects * 42, SystemTime::now());
scanned.scanner_epoch = Some(7);
scanned.scanner_cycle = Some(10 + objects);
scanned.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(objects * 42));
apply_bucket_usage_memory_overlay_if_authoritative(&mut scanned, true).await;
assert_eq!(scanned.objects_total_count, objects);
assert_eq!(scanned.objects_total_size, objects * 42);
}
}
}
#[tokio::test]
#[serial]
async fn converged_usage_refresh_requires_a_new_valid_scanner_generation() {
let bucket = "converged-generation";
for (converged, epoch, cycle, partial) in [
(None, Some(7), Some(11), false),
(Some(false), Some(7), Some(11), false),
(Some(true), None, Some(11), false),
(Some(true), Some(7), None, false),
(Some(true), Some(7), Some(10), false),
(Some(true), Some(8), Some(10), false),
(Some(true), Some(6), Some(11), false),
(Some(true), Some(7), Some(9), false),
(Some(true), Some(7), Some(11), true),
] {
clear_usage_memory_cache_for_test().await;
let mut baseline = data_usage_info_for_test(bucket, 436, 4_587_688_512, SystemTime::now());
baseline.scanner_epoch = Some(7);
baseline.scanner_cycle = Some(10);
replace_bucket_usage_memory_from_info(&baseline).await;
record_bucket_object_write_memory(bucket, None, 42).await;
let mut scanned = data_usage_info_for_test(bucket, 1, 42, SystemTime::now());
scanned.usage_snapshot_converged = converged;
scanned.scanner_epoch = epoch;
scanned.scanner_cycle = cycle;
scanned.usage_snapshot_partial = partial;
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(
get_bucket_usage_memory(bucket).await,
Some(4_587_688_554),
"converged={converged:?}, epoch={epoch:?}, cycle={cycle:?}, partial={partial}"
);
scanned.usage_snapshot_converged = Some(true);
scanned.scanner_epoch = Some(8);
scanned.scanner_cycle = Some(11);
scanned.usage_snapshot_partial = false;
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(42));
}
}
#[tokio::test]
#[serial]
async fn converged_usage_refresh_preserves_mutations_after_the_scan() {
clear_usage_memory_cache_for_test().await;
let bucket = "converged-write-race";
let mut baseline = data_usage_info_for_test(bucket, 0, 0, SystemTime::now());
baseline.scanner_epoch = Some(7);
baseline.scanner_cycle = Some(10);
replace_bucket_usage_memory_from_info(&baseline).await;
record_bucket_object_write_memory(bucket, None, 42).await;
let mut scanned = data_usage_info_for_test(bucket, 1, 42, SystemTime::now());
scanned.scanner_epoch = Some(7);
scanned.scanner_cycle = Some(11);
scanned.usage_snapshot_converged = Some(true);
record_bucket_object_write_memory(bucket, None, 42).await;
let after_scan = scanned.last_update.expect("scanner timestamp") + Duration::from_secs(1);
memory_cache()
.write()
.await
.get_mut(bucket)
.expect("dirty cache")
.usage_updated_at = after_scan;
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(84));
apply_bucket_usage_memory_overlay_if_authoritative(&mut scanned, true).await;
assert_eq!(scanned.objects_total_count, 2);
assert_eq!(scanned.objects_total_size, 84);
let mut successor = data_usage_info_for_test(bucket, 2, 84, after_scan);
successor.scanner_epoch = Some(7);
successor.scanner_cycle = Some(12);
successor.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&successor).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(84));
}
#[tokio::test]
#[serial]
async fn fallback_observations_cannot_bypass_the_cached_scanner_generation() {
let bucket = "historical-generation-floor";
for (converged, epoch, first_cycle) in [(None, 7, 10), (Some(true), 7, 10), (None, 6, 21), (Some(true), 6, 21)] {
clear_usage_memory_cache_for_test().await;
let mut baseline = data_usage_info_for_test(bucket, 436, 4_587_688_512, SystemTime::now());
baseline.scanner_epoch = Some(7);
baseline.scanner_cycle = Some(20);
replace_bucket_usage_memory_from_info(&baseline).await;
record_bucket_object_write_memory(bucket, None, 42).await;
// Wall clocks may step back; a timestamp alone cannot establish
// that an asynchronously loaded generation includes this write.
let update = memory_cache().read().await[bucket].usage_updated_at + Duration::from_secs(1);
for cycle in first_cycle..first_cycle + 3 {
let mut scanned = data_usage_info_for_test(bucket, 0, 0, update);
scanned.scanner_epoch = Some(epoch);
scanned.scanner_cycle = Some(cycle);
scanned.usage_snapshot_converged = converged;
replace_bucket_usage_memory_from_info(&scanned).await;
assert_eq!(
get_bucket_usage_memory(bucket).await,
Some(4_587_688_554),
"older epoch/cycle cannot release quota: converged={converged:?}, epoch={epoch}, cycle={cycle}"
);
apply_bucket_usage_memory_overlay_if_authoritative(&mut scanned, true).await;
assert_eq!(scanned.objects_total_count, 437);
assert_eq!(scanned.objects_total_size, 4_587_688_554);
}
let mut successor = data_usage_info_for_test(bucket, 1, 42, update);
successor.scanner_epoch = Some(8);
successor.scanner_cycle = Some(21);
successor.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&successor).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(42));
}
}
#[tokio::test]
#[serial]
async fn matching_counts_cannot_discard_the_cached_scanner_generation() {
let bucket = "matching-generation-floor";
for position in [None, Some((7, 10))] {
clear_usage_memory_cache_for_test().await;
let mut baseline = data_usage_info_for_test(bucket, 2, 84, SystemTime::now());
baseline.scanner_epoch = Some(7);
baseline.scanner_cycle = Some(20);
replace_bucket_usage_memory_from_info(&baseline).await;
let mut matching = baseline.clone();
matching.scanner_epoch = position.map(|value| value.0);
matching.scanner_cycle = position.map(|value| value.1);
replace_bucket_usage_memory_from_info(&matching).await;
record_bucket_object_write_memory(bucket, None, 42).await;
let update = memory_cache().read().await[bucket].usage_updated_at + Duration::from_secs(1);
let mut older = data_usage_info_for_test(bucket, 0, 0, update);
older.scanner_epoch = Some(7);
older.scanner_cycle = Some(12);
older.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&older).await;
assert_eq!(get_bucket_usage_memory(bucket).await, Some(126));
apply_bucket_usage_memory_overlay_if_authoritative(&mut older, true).await;
assert_eq!(older.objects_total_count, 3);
assert_eq!(older.objects_total_size, 126);
}
}
#[tokio::test]
#[serial]
async fn authoritative_refresh_can_reduce_memory_usage() {
+55 -1
View File
@@ -2085,7 +2085,9 @@ impl DiskAPI for LocalDiskWrapper {
}
let result = self.disk.disk_info(opts).await?;
if let Some(current_disk_id) = *self.disk_id.read().await
// Fresh capacity snapshots omit the disk ID; the stale-disk precheck above already verified it.
if !opts.fresh_capacity
&& let Some(current_disk_id) = *self.disk_id.read().await
&& Some(current_disk_id) != result.id
{
return Err(DiskError::DiskNotFound);
@@ -2571,7 +2573,9 @@ impl DiskAPI for LocalDiskWrapper {
mod tests {
use super::*;
use crate::disk::endpoint::Endpoint;
use crate::disk::format::FormatV3;
use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::{FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET};
use std::{
io,
panic::{AssertUnwindSafe, catch_unwind},
@@ -2926,6 +2930,56 @@ mod tests {
assert_eq!(snapshot.total_errors_availability, 1);
}
#[tokio::test]
async fn fresh_capacity_keeps_disk_identity_precheck_without_requiring_id_in_snapshot() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let meta_dir = dir.path().join(RUSTFS_META_BUCKET);
tokio::fs::create_dir_all(&meta_dir)
.await
.expect("metadata directory should be created");
let mut format = FormatV3::new(1, 1);
format.erasure.this = format.erasure.sets[0][0];
tokio::fs::write(meta_dir.join(FORMAT_CONFIG_FILE), format.to_json().expect("format should serialize"))
.await
.expect("format should be written");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("formatted disk should open"));
let disk_id = disk
.get_disk_id()
.await
.expect("disk ID should be readable")
.expect("formatted disk should have an ID");
let wrapper = LocalDiskWrapper::new(Arc::clone(&disk), false);
wrapper.set_disk_id_state(Some(disk_id)).await;
let snapshot = wrapper
.disk_info(&DiskInfoOptions {
fresh_capacity: true,
..Default::default()
})
.await
.expect("fresh capacity should pass when the disk identity matches");
assert!(snapshot.fresh_capacity);
assert!(snapshot.total > 0);
assert!(snapshot.id.is_none(), "capacity-only snapshots should not expose the disk ID");
let stale_wrapper = LocalDiskWrapper::new(disk, false);
stale_wrapper.set_disk_id_state(Some(Uuid::nil())).await;
let error = stale_wrapper
.disk_info(&DiskInfoOptions {
fresh_capacity: true,
..Default::default()
})
.await
.expect_err("a stale disk identity must still be rejected before the capacity probe");
assert_eq!(error, DiskError::DiskNotFound);
}
#[tokio::test]
async fn delete_versions_counts_returned_batch_error_classes() {
let dir = tempfile::tempdir().expect("temp dir should be created");
+34 -2
View File
@@ -428,6 +428,19 @@ impl DiskError {
}
}
/// Whether an internode RPC was cancelled, without classifying the peer as offline.
pub fn io_error_is_rpc_cancelled(error: &io::Error) -> bool {
error
.get_ref()
.and_then(|source| source.downcast_ref::<RpcStatusError>())
.is_some_and(|error| error.status().code() == tonic::Code::Cancelled)
}
pub(crate) fn clone_rpc_status_io_error(error: &io::Error) -> Option<io::Error> {
let status = error.get_ref()?.downcast_ref::<RpcStatusError>()?;
Some(io::Error::new(error.kind(), RpcStatusError(status.0.clone())))
}
pub fn internode_http_error_kind(&self) -> Option<InternodeHttpErrorKind> {
match self {
DiskError::Io(io_error) => io_error
@@ -701,8 +714,8 @@ impl Clone for DiskError {
DiskError::conditional_file_not_committed(io::Error::new(io_error.kind(), io_error.to_string())),
),
DiskError::Io(io_error) => {
if let Some(status) = io_error.get_ref().and_then(|source| source.downcast_ref::<RpcStatusError>()) {
return DiskError::Io(io::Error::new(io_error.kind(), RpcStatusError(status.0.clone())));
if let Some(error) = Self::clone_rpc_status_io_error(io_error) {
return DiskError::Io(error);
}
DiskError::Io(
Self::clone_dangling_delete_grace(io_error)
@@ -900,6 +913,25 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn rpc_status_survives_disk_and_storage_clones() {
for code in [tonic::Code::Cancelled, tonic::Code::PermissionDenied] {
let status = tonic::Status::new(code, "operation was canceled");
let original = DiskError::Io(io::Error::new(io::ErrorKind::Interrupted, RpcStatusError::from(status)));
let display = original.to_string();
let disk = original.clone();
let storage = crate::error::StorageError::from(disk);
let cloned = storage.clone();
for error in [io::Error::from(original), io::Error::from(storage), io::Error::from(cloned)] {
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
let status = error.get_ref().unwrap().downcast_ref::<RpcStatusError>().unwrap().status();
assert_eq!(status.code(), code);
assert_eq!(status.message(), "operation was canceled");
assert_eq!(DiskError::from(error).to_string(), display);
}
}
}
#[test]
fn retired_marker_deferral_survives_disk_and_storage_clones() {
let original = DiskError::retired_marker_deferred("missing retirement record");
File diff suppressed because it is too large Load Diff
@@ -73,7 +73,7 @@ impl ProcessResultBudget {
}
inner
.available
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |available| available.checked_sub(bytes))
.try_update(Ordering::AcqRel, Ordering::Acquire, |available| available.checked_sub(bytes))
.map(|_| {
Some(ResultBudgetReservation {
inner: Arc::clone(inner),
@@ -377,13 +377,21 @@ async fn blackbox_heal_requests_preserve_repair_scope() {
// Without a durable MRF consumer, partial PUTs fall back to the heal
// admission channel and wait for its receipt before acknowledging the write.
// Drive the test receiver alongside the PUT so neither waits on the other.
let (_put_dirs, put_set) = make_local_set_disks(4, 2).await;
let (_put_dirs, put_store) = crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks().await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&put_store), Vec::new()).await;
let put_set = Arc::clone(&put_store.pools[0].disk_set[0]);
assert_eq!(put_set.set_drive_count, 4);
assert_eq!(put_set.default_parity_count, 2);
let put_bucket = "bb-put-partial-convergence";
let put_object = "object.bin";
put_set
put_store
.make_bucket(put_bucket, &MakeBucketOptions::default())
.await
.expect("PUT bucket should be created");
let put_incarnation = put_store
.bucket_incarnation_id_from_disk(put_bucket)
.await
.expect("partial repair must bind the real persisted bucket generation");
let offline_disk = {
let mut disks = put_set.disks.write().await;
disks[0].take()
@@ -400,6 +408,7 @@ async fn blackbox_heal_requests_preserve_repair_scope() {
&ObjectOptions {
no_lock: true,
versioned: true,
expected_bucket_incarnation_id: Some(put_incarnation),
..Default::default()
},
)
@@ -419,6 +428,8 @@ async fn blackbox_heal_requests_preserve_repair_scope() {
assert_eq!(request.object_version_id.as_deref(), Some(committed_version.as_str()));
assert_eq!(request.pool_index, Some(0));
assert_eq!(request.set_index, Some(0));
assert_eq!(request.source, HealRequestSource::Mrf);
assert_eq!(request.expected_bucket_incarnation_id, Some(put_incarnation));
let duplicate_request = tokio::time::timeout(std::time::Duration::from_millis(100), async {
loop {
@@ -447,7 +458,7 @@ async fn blackbox_heal_requests_preserve_repair_scope() {
}
let healthy_bucket = "bb-put-healthy-convergence";
put_set
put_store
.make_bucket(healthy_bucket, &MakeBucketOptions::default())
.await
.expect("healthy PUT bucket should be created");
+3 -2
View File
@@ -623,8 +623,9 @@ impl Clone for StorageError {
fn clone(&self) -> Self {
match self {
StorageError::Io(e) => {
if let Some(error) =
DiskError::clone_dangling_delete_grace(e).or_else(|| DiskError::clone_retired_marker_deferred(e))
if let Some(error) = DiskError::clone_rpc_status_io_error(e)
.or_else(|| DiskError::clone_dangling_delete_grace(e))
.or_else(|| DiskError::clone_retired_marker_deferred(e))
{
return StorageError::Io(error);
}
+5 -9
View File
@@ -908,7 +908,7 @@ fn explicit_url_port(raw: &str) -> Result<Option<u16>> {
return Err(invalid());
}
let (scheme, remainder) = raw.split_once("://").ok_or_else(&invalid)?;
let (scheme, remainder) = raw.split_once("://").ok_or_else(invalid)?;
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return Err(invalid());
}
@@ -916,17 +916,17 @@ fn explicit_url_port(raw: &str) -> Result<Option<u16>> {
.split('/')
.next()
.filter(|authority| !authority.is_empty())
.ok_or_else(&invalid)?;
.ok_or_else(invalid)?;
if authority.contains('@') {
return Err(invalid());
}
let port = if let Some(bracketed) = authority.strip_prefix('[') {
let (_, suffix) = bracketed.split_once(']').ok_or_else(&invalid)?;
let (_, suffix) = bracketed.split_once(']').ok_or_else(invalid)?;
if suffix.is_empty() {
return Ok(None);
}
suffix.strip_prefix(':').ok_or_else(&invalid)?
suffix.strip_prefix(':').ok_or_else(invalid)?
} else if let Some((_, port)) = authority.rsplit_once(':') {
port
} else {
@@ -1001,11 +1001,7 @@ fn parse_explicit_local_endpoint_host(raw: &str) -> Result<Host<String>> {
let host = Host::parse(raw).map_err(|_| invalid())?;
let host = match host {
Host::Domain(domain) => Host::Domain(
domain_without_optional_trailing_dot(&domain)
.ok_or_else(&invalid)?
.to_string(),
),
Host::Domain(domain) => Host::Domain(domain_without_optional_trailing_dot(&domain).ok_or_else(invalid)?.to_string()),
host => host,
};
if matches!(&host, Host::Domain(domain) if domain.contains('*'))
+5
View File
@@ -1444,6 +1444,10 @@ pub struct ObjectInfo {
pub version_purge_status: VersionPurgeStatusType,
pub replication_decision: String,
pub checksum: Option<Bytes>,
/// True when this `CompleteMultipartUpload` result is the object already
/// published by the same upload. Callers must not repeat quota, replication,
/// lifecycle, or object-created side effects for that response.
pub multipart_completion_replayed: bool,
}
impl Clone for ObjectInfo {
@@ -1484,6 +1488,7 @@ impl Clone for ObjectInfo {
version_purge_status: self.version_purge_status.clone(),
replication_decision: self.replication_decision.clone(),
checksum: self.checksum.clone(),
multipart_completion_replayed: self.multipart_completion_replayed,
expires: self.expires,
}
}
+2 -2
View File
@@ -396,7 +396,7 @@ impl InstanceContext {
pub(crate) fn begin_namespace_commit(self: &Arc<Self>) -> Arc<NamespaceCommitGuard> {
let counted = self
.namespace_commits
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| count.checked_add(1))
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| count.checked_add(1))
.is_ok();
if counted {
self.advance_namespace_commit_generation();
@@ -412,7 +412,7 @@ impl InstanceContext {
fn advance_namespace_commit_generation(&self) {
let _ = self
.namespace_commit_generation
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| Some(generation.saturating_add(1)));
.try_update(Ordering::AcqRel, Ordering::Acquire, |generation| Some(generation.saturating_add(1)));
}
pub(crate) fn namespace_commit_generation(&self) -> u64 {
+12 -3
View File
@@ -602,9 +602,18 @@ pub(crate) async fn initialize_local_disk_maps(
}
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
let handle = get_global_tier_config_mgr();
TierConfigMgr::reload_handle(&handle, store.clone()).await?;
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
init_tier_config_mgr_handle(get_global_tier_config_mgr(), store).await
}
pub(crate) async fn init_tier_config_mgr_handle(handle: Arc<RwLock<TierConfigMgr>>, store: Arc<ECStore>) -> Result<()> {
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
// Startup may continue after a transient recovery failure. Its retained
// mutation fences still need a worker to consume later commit notifications.
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle.clone(), store));
if initial_reload.is_err() {
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
}
initial_reload?;
Ok(())
}
@@ -107,7 +107,9 @@ async fn handle_prepare(
}
let tier_config_mgr = api.tier_config_mgr();
for _ in 0..3 {
// Peers can race to create the same durable intent. Re-read after bounded
// lock contention so the winning publication follows the normal identity checks.
for attempt in 0..3 {
let (stored, applied) = match load_tier_mutation_intent_record(api.clone(), mutation_id).await {
Ok(existing) => {
if !existing.same_identity_as(&intent) {
@@ -123,9 +125,11 @@ async fn handle_prepare(
match save_tier_mutation_intent_record_if_absent(api.clone(), &intent).await {
Ok(()) => (intent.clone(), true),
Err(Error::PreconditionFailed) => continue,
Err(Error::Lock(rustfs_lock::LockError::Timeout { .. })) if attempt < 2 => continue,
Err(err) => return Err(err.into()),
}
}
Err(Error::Lock(rustfs_lock::LockError::Timeout { .. })) if attempt < 2 => continue,
Err(err) => return Err(err.into()),
};
@@ -2884,7 +2884,16 @@ impl SetDisks {
let result = if let Some(deadline) = single_pending_hedge_deadline.take() {
tokio::select! {
result = join_set.join_next() => result,
_ = tokio::time::sleep_until(deadline) => {
_ = async {
tokio::time::sleep_until(deadline).await;
#[cfg(test)]
rename_fanout_barrier::checkpoint(
object.as_ref(),
0,
rename_fanout_barrier::PHASE_NON_INLINE_HEDGE_TIMER,
)
.await;
} => {
if bounded_fanout
&& !force_full_wait
&& join_set.len() == 1
@@ -6516,7 +6525,15 @@ impl SetDisks {
let disks = self.get_disks_internal().await;
// Phase 1: classify every online disk. A directory that holds object
// An unresolved slot may hold the only copy of readable object
// metadata or uncommitted data under this prefix. Do not classify or
// remove orphan residue from the online subset while any drive is
// unavailable.
if disks.iter().any(Option::is_none) {
return Ok(false);
}
// Phase 1: classify every disk. A directory that holds object
// data or uncommitted residue on ANY disk blocks itself and its
// ancestors on every disk, so a degraded/healable object is never
// destroyed; purgeable subtrees beside it are still reclaimed.
@@ -6570,6 +6587,47 @@ impl SetDisks {
Ok(purged)
}
/// Reclaim metadata-less directory trees discovered by a recursive empty
/// bucket listing. The initial directory read fails closed if any slot is
/// unavailable; per-prefix purges then reuse the full cross-disk scan.
pub(crate) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
let disks = self.get_disks_internal().await;
if disks.is_empty() || disks.iter().any(Option::is_none) {
return false;
}
let mut prefixes = HashSet::new();
for disk in disks.iter().flatten() {
let entries = match disk.list_dir("", bucket, "", 0).await {
Ok(entries) => entries,
Err(_) => return false,
};
prefixes.extend(
entries
.into_iter()
.filter(|entry| entry.ends_with(SLASH_SEPARATOR) && is_safe_orphan_dir_entry(entry)),
);
}
let mut prefixes = prefixes.into_iter().collect::<Vec<_>>();
prefixes.sort_unstable();
let mut purged = false;
for prefix in prefixes {
match self.purge_orphan_dir_object(bucket, &prefix).await {
Ok(prefix_purged) => purged |= prefix_purged,
Err(_) => return false,
}
}
purged
}
/// Orphan cleanup must not infer a complete scan from only the online
/// subset of a set's disks.
pub(crate) async fn orphan_purge_has_complete_disk_set(&self) -> bool {
let disks = self.get_disks_internal().await;
!disks.is_empty() && disks.iter().all(Option::is_some)
}
fn orphan_purge_in_backoff(&self, key: &str) -> bool {
let backoff = self
.orphan_purge_backoff
@@ -7322,6 +7380,9 @@ pub(crate) mod rename_fanout_barrier {
CLEANUP as PHASE_CLEANUP, READ_VERSION as PHASE_READ_VERSION, RENAME as PHASE_RENAME, ROLLBACK as PHASE_ROLLBACK,
};
/// Object-scoped hedge timer checkpoint; slot zero identifies the timer, not a disk.
pub const PHASE_NON_INLINE_HEDGE_TIMER: &str = "non_inline_hedge_timer";
/// One armed barrier: the fan-out task matching `(disk_index, phase)` pauses.
struct Armed {
disk_index: usize,
+115 -9
View File
@@ -1350,6 +1350,8 @@ mod prepared_get_object_metadata_tests {
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
// Isolate late body recovery from the independent metadata slow-tail hedge.
let _hedge_timer = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_NON_INLINE_HEDGE_TIMER);
let calls = disk_call_counters::observe(&object);
let mut reader = set_disks
.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts)
@@ -1414,6 +1416,8 @@ mod prepared_get_object_metadata_tests {
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
// Isolate late body recovery from the independent metadata slow-tail hedge.
let _hedge_timer = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_NON_INLINE_HEDGE_TIMER);
let calls = disk_call_counters::observe(&object);
let mut reader = set_disks
.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts)
@@ -1427,6 +1431,11 @@ mod prepared_get_object_metadata_tests {
.expect("late parity should restore the exact GET body");
assert_eq!(restored, payload);
assert_eq!(calls.total(disk_call_counters::KIND_READ_VERSION), 7);
assert_eq!(
calls.for_disk(disk_call_counters::KIND_READ_VERSION, order[3]),
1,
"the omitted parity disk must only be read by the late metadata refresh"
);
},
)
.await;
@@ -1491,6 +1500,8 @@ mod prepared_get_object_metadata_tests {
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
// Isolate late body recovery from the independent metadata slow-tail hedge.
let _hedge_timer = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_NON_INLINE_HEDGE_TIMER);
let calls = disk_call_counters::observe(&object);
let mut reader = set_disks
.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts)
@@ -1593,6 +1604,8 @@ mod prepared_get_object_metadata_tests {
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", Some("true")),
],
async {
// Hold this object's hedge timer so real-disk latency cannot add speculative fanout.
let _hedge_timer = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_NON_INLINE_HEDGE_TIMER);
let calls = disk_call_counters::observe(&object);
let mut reader = set_disks
.get_object_reader(bucket, &object, None, HeaderMap::new(), &opts)
@@ -4337,9 +4350,15 @@ impl SetDisks {
}
}
pub(in crate::set_disk) async fn persist_partial_write(&self, bucket: &str, object: &str, version_id: Option<&str>) -> bool {
pub(in crate::set_disk) async fn persist_partial_write(
&self,
bucket: &str,
object: &str,
version_id: Option<&str>,
source_bucket_incarnation_id: Option<Uuid>,
) -> bool {
use rustfs_common::mrf_channel::{
MrfDurableAdmissionError, MrfScope, mrf_delivery_enabled, persist_partial_write_intent,
MrfDurableAdmissionError, MrfScope, mrf_delivery_enabled, persist_partial_write_intent_with_incarnation,
};
if !mrf_delivery_enabled() {
@@ -4358,7 +4377,9 @@ impl SetDisks {
Ok::<_, MrfDurableAdmissionError>((version, scope))
})();
let result = match identity {
Ok((version, scope)) => persist_partial_write_intent(bucket, object, version, scope).await,
Ok((version, scope)) => {
persist_partial_write_intent_with_incarnation(bucket, object, version, scope, source_bucket_incarnation_id).await
}
Err(err) => Err(err),
};
match result {
@@ -4394,15 +4415,24 @@ impl SetDisks {
pub(in crate::set_disk) async fn submit_rename_tail_heal(
&self,
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
mut request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
) {
if let Some(object) = request.object_prefix.as_deref()
&& self
.persist_partial_write(&request.bucket, object, request.object_version_id.as_deref())
.persist_partial_write(
&request.bucket,
object,
request.object_version_id.as_deref(),
request.expected_bucket_incarnation_id,
)
.await
{
return;
}
if request.expected_bucket_incarnation_id.is_none() {
return;
}
request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Mrf;
#[cfg(test)]
{
let capture = self
@@ -4790,6 +4820,10 @@ impl SetDisks {
/// Read the persisted bucket identity through this set's metadata owner.
/// Missing or non-authoritative legacy identities remain errors.
pub async fn bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result<Uuid> {
if crate::bucket::utils::is_meta_bucketname(bucket) {
// Metadata writes can already hold the pool metadata write lock.
return Err(Error::other("system metadata bucket has no bucket incarnation"));
}
metadata_sys::get_bucket_incarnation_id_in(&self.ctx, bucket).await
}
@@ -7148,10 +7182,10 @@ fn parts_after_marker(part_numbers: &[usize], part_number_marker: usize) -> Opti
return Some(part_numbers);
}
part_numbers
.iter()
.position(|&part_number| part_number != 0 && part_number == part_number_marker)
.map(|index| &part_numbers[index + 1..])
// reduce_quorum_part_numbers returns sorted numbers; the marker need not exist.
part_numbers.last().filter(|&&last| part_number_marker <= last)?;
let index = part_numbers.partition_point(|&part_number| part_number <= part_number_marker);
Some(&part_numbers[index..])
}
pub fn canonicalize_etag(etag: &str) -> String {
@@ -7415,6 +7449,35 @@ mod tests {
make_test_set_disks_with_ctx(lockers, bootstrap_ctx()).await
}
#[tokio::test]
#[serial_test::serial]
async fn system_metadata_incarnation_lookup_does_not_reenter_pool_metadata() {
let (_temp_dirs, store, _other_store) =
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
let _pool_meta_guard = store.pool_meta.write().await;
let set = &store.pools[0].disk_set[0];
for bucket in [RUSTFS_META_BUCKET, RUSTFS_META_TMP_BUCKET, crate::disk::MIGRATING_META_BUCKET] {
tokio::time::timeout(Duration::from_secs(30), set.bucket_incarnation_id_from_disk(bucket))
.await
.expect("system metadata identity lookup must not reacquire the held pool metadata lock")
.expect_err("system metadata buckets have no user bucket incarnation");
}
drop(_pool_meta_guard);
let bucket = "user-incarnation-boundary";
let incarnation = Uuid::new_v4();
crate::bucket::metadata::save_bucket_incarnation(Arc::clone(&store), bucket, incarnation)
.await
.expect("persist the user bucket identity through the metadata owner");
assert_eq!(
set.bucket_incarnation_id_from_disk(bucket)
.await
.expect("user bucket identities must still load from the metadata owner"),
incarnation,
);
}
async fn make_test_set_disks_with_ctx(
lockers: Vec<Arc<dyn LockClient>>,
instance_ctx: Arc<InstanceContext>,
@@ -9418,6 +9481,31 @@ mod tests {
assert!(!purged, "a missing prefix should report nothing to purge");
}
#[tokio::test]
async fn orphan_directory_purge_preserves_tree_when_a_disk_slot_is_offline() {
let (dir, disk) = make_single_local_disk().await;
let prefix_dir = dir.path().join("bucket").join("pfx");
fs::create_dir_all(prefix_dir.join("nested").join("leaf"))
.await
.expect("orphan directory tree should be created");
let set = make_set_disks_with(vec![Some(disk), None]).await;
let purged = set
.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("an unavailable slot should fail closed without a scan error");
assert!(!purged, "an incomplete disk scan must not claim the tree is an orphan");
assert!(prefix_dir.join("nested/leaf").exists(), "online disk contents must remain untouched");
let bucket_purged = set.purge_orphan_dir_objects_in_bucket("bucket").await;
assert!(!bucket_purged, "bucket-wide cleanup must fail closed with an unavailable disk slot");
assert!(
prefix_dir.join("nested/leaf").exists(),
"bucket-wide cleanup must preserve the online tree"
);
}
// Cross-disk safety: if any drive still holds object data under the prefix, refuse
// to purge on every drive so a degraded/healable object is never destroyed.
#[tokio::test]
@@ -13895,6 +13983,24 @@ mod tests {
assert!(parts_after_marker(&part_numbers, 4).is_none());
}
#[test]
fn parts_after_marker_uses_exclusive_numeric_boundary_for_sparse_parts() {
let part_numbers = [1, 3, 10];
for (marker, expected) in [
(0, Some(&part_numbers[..])),
(1, Some(&part_numbers[1..])),
(2, Some(&part_numbers[1..])),
(3, Some(&part_numbers[2..])),
(9, Some(&part_numbers[2..])),
(10, Some(&part_numbers[3..])),
(11, None),
(usize::MAX, None),
] {
assert_eq!(parts_after_marker(&part_numbers, marker), expected, "marker {marker}");
}
assert_eq!(parts_after_marker(&[], 1), None);
}
#[test]
fn delete_file_info_version_id_maps_explicit_null_version_to_stored_null() {
assert_eq!(delete_file_info_version_id(Some(Uuid::nil())), None);
+23 -9
View File
@@ -1166,7 +1166,7 @@ impl SetDisks {
if !latest_meta.deleted && !latest_meta.is_remote() && !protected {
result.detail =
"Legacy object uses standard repair; independent object identity remains unverified".to_owned();
rustfs_heal_contracts::heal_channel::LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL.to_owned();
}
if disks_to_heal_count == 0 {
@@ -4862,18 +4862,33 @@ mod heal_result_report_tests {
},
)
.await
.expect("commit all source shards");
.expect("commit source object");
let mut files = Vec::new();
// PUT can succeed at quorum while minority shards remain unavailable.
// Require every source disk before modifying any shard in this fixture.
let mut source_metadata = Vec::with_capacity(disks.len());
for disk in &disks {
source_metadata.push(disk.read_version("", bucket, object, "", &ReadOptions::default()).await);
}
assert!(
source_metadata.iter().all(|source| source.is_ok()),
"source fixture must populate all {} disks before truncation in {mode:?} mode; per-disk errors: {:?}",
disks.len(),
source_metadata
.iter()
.enumerate()
.map(|(slot, source)| (slot, source.as_ref().err()))
.collect::<Vec<_>>()
);
let files: Vec<_> = source_metadata
.into_iter()
.map(|source| source.expect("source metadata checked above"))
.collect();
let mut paths = Vec::new();
let mut original_shards = Vec::new();
let mut original_metadata = Vec::new();
let mut damaged = Vec::new();
for (slot, disk) in disks.iter().enumerate() {
let source = disk
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("read source metadata");
for (slot, source) in files.iter().enumerate() {
assert_eq!((source.erasure.data_blocks, source.erasure.parity_blocks), (12, 4));
assert_eq!(source.parts[0].integrity.is_some(), mode == ShardIntegrityWriteMode::Protected);
let directory = temp_dirs[slot].path().join(bucket).join(object);
@@ -4905,7 +4920,6 @@ mod heal_result_report_tests {
}
paths.push(path);
original_shards.push(original);
files.push(source);
}
assert_eq!(damaged.len(), coding_indexes.len());
if mode == ShardIntegrityWriteMode::Protected {
+459 -13
View File
@@ -1451,6 +1451,103 @@ impl SetDisks {
delimiter: delimiter.to_owned(),
})
}
/// Answer a CompleteMultipartUpload whose staging upload is already gone.
///
/// AWS keeps Complete idempotent for the same upload id and part list while
/// the completed object is still the result of that upload (the first 200
/// can be lost, and SDK retries depend on a second 200 with the same ETag).
/// Returns `Ok(None)` when this key has no such object, so the caller keeps
/// `InvalidUploadID`. A matching upload id with a different part list is
/// `InvalidPart` and must not fall through to `NoSuchUpload`.
async fn replay_completed_multipart_upload(
&self,
bucket: &str,
object: &str,
upload_id: &str,
uploaded_parts: &[CompletePart],
opts: &ObjectOptions,
) -> Result<Option<ObjectInfo>> {
if upload_id.is_empty() {
return Ok(None);
}
let read_opts = ObjectOptions {
no_lock: true,
metadata_cache_safe: false,
versioned: opts.versioned,
version_suspended: opts.version_suspended,
..Default::default()
};
match self.get_object_info(bucket, object, &read_opts).await {
Ok(info) => {
match completed_multipart_upload_matches(
&info.user_defined,
info.parts.as_ref(),
info.delete_marker,
upload_id,
uploaded_parts,
bucket,
object,
) {
Ok(true) => return Ok(Some(mark_multipart_completion_replayed(info))),
Ok(false) => {}
Err(err) => return Err(err),
}
}
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
Err(err) => return Err(err),
}
// Unversioned overwrite replaces the only slot. A versioned bucket can
// keep the completed version under a newer one; that version is still
// the result of this upload until it is deleted.
if !(opts.versioned || opts.version_suspended) {
return Ok(None);
}
let Some(versions) = self.load_file_info_versions_exact(bucket, object).await? else {
return Ok(None);
};
for fi in versions.versions {
if fi.deleted || fi.tier_free_version() || fi.is_canonical_delete_marker() {
continue;
}
match completed_multipart_upload_matches(&fi.metadata, &fi.parts, false, upload_id, uploaded_parts, bucket, object) {
Ok(true) => {
let info = ObjectInfo::from_file_info(&fi, bucket, object, true);
return Ok(Some(mark_multipart_completion_replayed(info)));
}
Ok(false) => {}
Err(err) => return Err(err),
}
}
Ok(None)
}
/// Best-effort removal of staging left behind when commit succeeded and the
/// process stopped before `delete_all`. A cleanup miss must not turn the
/// idempotent 200 into an error; the object is already durable.
async fn reclaim_replayed_multipart_staging(&self, bucket: &str, object: &str, upload_id: &str, upload_id_path: &str) {
if let Err(err) = self
.delete_all_with_quorum(RUSTFS_META_MULTIPART_BUCKET, upload_id_path, self.default_write_quorum())
.await
{
warn!(
target: "rustfs_ecstore::set_disk",
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
op = "complete_multipart_upload",
result = "cleanup_quorum_missed",
bucket = %bucket,
object = %object,
upload_id = %upload_id,
error = %err,
"replayed multipart completion left staging behind"
);
}
}
}
#[async_trait::async_trait]
@@ -2028,7 +2125,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
if object_parts.len() > ret.parts.len() {
ret.is_truncated = true;
ret.next_part_number_marker = ret.parts.last().map(|v| v.part_num).unwrap_or_default();
ret.next_part_number_marker = ret.parts.last().map(|v| v.part_num);
}
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
@@ -2337,6 +2434,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
result
}
// complete_multipart_upload finished
#[tracing::instrument(skip(self))]
async fn complete_multipart_upload(
@@ -2400,9 +2498,27 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
.await?;
let expected_restore_operation_id = restore_commit_operation_id_from_metadata(&opts.user_defined)?;
let (mut fi, files_metas) = self
let (mut fi, files_metas) = match self
.check_upload_id_exists_with_opts(bucket, object, upload_id, true, opts)
.await?;
.await
{
Ok(found) => found,
Err(err) if crate::error::is_err_invalid_upload_id(&err) => {
match self
.replay_completed_multipart_upload(bucket, object, upload_id, &uploaded_parts, opts)
.await
{
Ok(Some(existing)) => {
self.reclaim_replayed_multipart_staging(bucket, object, upload_id, &upload_id_path)
.await;
return Ok(existing);
}
Ok(None) => return Err(err),
Err(replay_err) => return Err(replay_err),
}
}
Err(err) => return Err(err),
};
ensure_data_movement_upload_access(&fi, bucket, object, upload_id, opts)?;
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
.await?;
@@ -2988,6 +3104,16 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
rustfs_utils::http::remove_str(&mut fi.metadata, rustfs_filemeta::shard_integrity::SUFFIX_UPLOAD_INTEGRITY);
fi.persist_shard_integrity()?;
// The staging directory is removed after commit. Recording the upload id
// on the object is what lets a retried CompleteMultipartUpload return this
// version instead of NoSuchUpload. insert_str writes both internal prefixes;
// a later conflicting pair fails closed in the replay matcher.
// Internal migration must preserve the source completion identity rather
// than replace it (or invent one) with the temporary transfer upload id.
if !opts.data_movement && !upload_id.is_empty() {
insert_str(&mut fi.metadata, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID, upload_id.to_owned());
}
for meta in parts_metadatas.iter_mut() {
if meta.has_valid_erasure_geometry() {
meta.size = fi.size;
@@ -3522,6 +3648,58 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
}
fn mark_multipart_completion_replayed(mut info: ObjectInfo) -> ObjectInfo {
info.multipart_completion_replayed = true;
info
}
/// `Ok(true)` when `upload_id` is the completion that published this version and
/// the requested parts are that version's part list. `Ok(false)` means this
/// version is not that completion. `Err` is a definite part-list or metadata
/// failure and must not be reported as `NoSuchUpload`.
fn completed_multipart_upload_matches(
metadata: &HashMap<String, String>,
parts: &[ObjectPartInfo],
delete_marker: bool,
upload_id: &str,
uploaded_parts: &[CompletePart],
bucket: &str,
object: &str,
) -> Result<bool> {
if delete_marker || upload_id.is_empty() {
return Ok(false);
}
let Some(stored_upload_id) = rustfs_utils::http::get_consistent_str(metadata, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID)
else {
if rustfs_utils::http::contains_key_str(metadata, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID) {
return Err(Error::FileCorrupt);
}
return Ok(false);
};
if stored_upload_id.is_empty() || stored_upload_id != upload_id {
return Ok(false);
}
if parts.len() != uploaded_parts.len() {
let part_num = uploaded_parts.first().map(|part| part.part_num).unwrap_or(0);
return Err(Error::InvalidPart(part_num, bucket.to_owned(), object.to_owned()));
}
for (stored, requested) in parts.iter().zip(uploaded_parts) {
if stored.number != requested.part_num {
return Err(Error::InvalidPart(requested.part_num, bucket.to_owned(), object.to_owned()));
}
let stored_etag = rustfs_utils::path::trim_etag(&stored.etag);
let client_etag = requested.etag.as_deref().map(rustfs_utils::path::trim_etag);
if client_etag.as_deref() != Some(stored_etag.as_str()) {
return Err(Error::InvalidPart(
requested.part_num,
stored.etag.clone(),
requested.etag.clone().unwrap_or_default(),
));
}
}
Ok(true)
}
/// Final ETag for a completed multipart object. An authorized replication
/// request preserves the source ETag so the replication HEAD comparison
/// converges even when the source ETag is not derivable from the uploaded
@@ -4327,6 +4505,54 @@ mod tests {
}
}
#[tokio::test]
async fn list_object_parts_pagination_preserves_sparse_markers_and_terminates() {
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-pagination";
let object = "object";
make_bucket_on_all(&disks, bucket).await;
let opts = ObjectOptions::default();
let upload = set_disks
.new_multipart_upload(bucket, object, &opts)
.await
.expect("multipart upload should be created");
let empty = set_disks
.list_object_parts(bucket, object, &upload.upload_id, None, 2, &opts)
.await
.expect("empty upload should be listable");
assert!(empty.parts.is_empty());
assert!(!empty.is_truncated);
assert_eq!(empty.next_part_number_marker, None);
for part_number in [10, 1, 3] {
put_test_part(&set_disks, bucket, object, &upload.upload_id, part_number, b"part", 4).await;
}
for (marker, max_parts, expected_parts, next_marker) in [
(None, 0, vec![], None),
(None, 1, vec![1], Some(1)),
(None, 2, vec![1, 3], Some(3)),
(None, 3, vec![1, 3, 10], None),
(None, MAX_PARTS_COUNT + 1, vec![1, 3, 10], None),
(Some(1), 2, vec![3, 10], None),
(Some(2), 1, vec![3], Some(3)),
(Some(3), 2, vec![10], None),
(Some(10), 2, vec![], None),
(Some(11), 2, vec![], None),
] {
let page = set_disks
.list_object_parts(bucket, object, &upload.upload_id, marker, max_parts, &opts)
.await
.expect("uploaded parts should be listable");
assert_eq!(page.part_number_marker, marker.unwrap_or_default());
assert_eq!(page.max_parts, max_parts.min(MAX_PARTS_COUNT));
assert_eq!(page.parts.iter().map(|part| part.part_num).collect::<Vec<_>>(), expected_parts);
assert_eq!(page.is_truncated, next_marker.is_some());
assert_eq!(page.next_part_number_marker, next_marker);
}
}
async fn object_transaction_epochs(disks: &[DiskStore], bucket: &str, object: &str) -> Vec<Option<Uuid>> {
let mut epochs = Vec::with_capacity(disks.len());
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
@@ -4939,6 +5165,10 @@ mod tests {
)
.await
.expect("completed data movement object should be readable");
assert!(
!rustfs_utils::http::contains_key_str(&completed.user_defined, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID),
"migration of a source without a completion identity must not invent one"
);
assert!(!rustfs_utils::http::contains_key_str(
&completed.user_defined,
rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD
@@ -8630,6 +8860,216 @@ mod tests {
assert_eq!(paged, vec!["u0", "u1", "u2", "u3"]);
}
#[test]
fn completed_multipart_upload_match_requires_same_upload_and_parts() {
let upload_id = "upload-1";
let mut metadata = HashMap::new();
insert_str(&mut metadata, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID, upload_id.to_string());
let parts = vec![ObjectPartInfo {
number: 1,
etag: "abc".to_string(),
..Default::default()
}];
let requested = vec![CompletePart {
part_num: 1,
etag: Some("\"abc\"".to_string()),
..Default::default()
}];
assert!(
completed_multipart_upload_matches(&metadata, &parts, false, upload_id, &requested, "bucket", "object")
.expect("quoted ETag must match the stored part")
);
let wrong_etag = vec![CompletePart {
part_num: 1,
etag: Some("def".to_string()),
..Default::default()
}];
assert!(matches!(
completed_multipart_upload_matches(&metadata, &parts, false, upload_id, &wrong_etag, "bucket", "object"),
Err(StorageError::InvalidPart(1, _, _))
));
assert!(
!completed_multipart_upload_matches(&metadata, &parts, false, "other-upload", &requested, "bucket", "object")
.expect("a different upload id is not this completion")
);
assert!(
!completed_multipart_upload_matches(&metadata, &parts, true, upload_id, &requested, "bucket", "object")
.expect("a delete marker is not the completed object")
);
metadata.insert(
format!(
"{}{}",
rustfs_utils::http::MINIO_INTERNAL_PREFIX,
rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID
),
"disagrees".to_string(),
);
assert!(
matches!(
completed_multipart_upload_matches(&metadata, &parts, false, upload_id, &requested, "bucket", "object"),
Err(StorageError::FileCorrupt)
),
"conflicting internal upload ids must fail closed"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retried_complete_multipart_upload_returns_the_committed_object() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-complete-retry";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let payload = vec![0x11; 4096];
let (upload_id, parts) =
stage_upload_with_create_opts(&set_disks, bucket, object, &payload, &ObjectOptions::default()).await;
let first = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts.clone(), &ObjectOptions::default())
.await
.expect("the first completion should publish the object");
assert!(!first.multipart_completion_replayed, "the first completion publishes a new object");
assert_eq!(
rustfs_utils::http::get_consistent_str(&first.user_defined, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID),
Some(upload_id.as_str()),
"the completed object must record the upload id"
);
let mut quoted_parts = parts.clone();
quoted_parts[0].etag = quoted_parts[0].etag.as_ref().map(|etag| format!("\"{etag}\""));
let retried = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, quoted_parts, &ObjectOptions::default())
.await
.expect("a retry with the same upload id and parts must succeed");
assert!(retried.multipart_completion_replayed);
assert_eq!(retried.etag, first.etag);
assert_eq!(retried.size, first.size);
assert_eq!(retried.version_id, first.version_id);
let mismatched = parts.clone();
let mut mismatched = mismatched;
mismatched[0].etag = Some("not-the-part".to_string());
let mismatch_err = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, mismatched, &ObjectOptions::default())
.await
.expect_err("a retry with a different part ETag must not pretend the upload is missing");
assert!(matches!(mismatch_err, StorageError::InvalidPart(..)));
let unknown = set_disks
.clone()
.complete_multipart_upload(bucket, object, "not-a-real-upload", parts.clone(), &ObjectOptions::default())
.await
.expect_err("an unknown upload id must stay InvalidUploadID");
assert!(matches!(unknown, StorageError::InvalidUploadID(..)));
let mut reader = PutObjReader::from_vec(b"replaced".to_vec());
set_disks
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("an overwrite should replace the completed object");
let overwritten = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &ObjectOptions::default())
.await
.expect_err("a retry after the object is no longer that upload must stay InvalidUploadID");
assert!(matches!(overwritten, StorageError::InvalidUploadID(..)));
let current = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the overwrite must remain readable");
assert_eq!(current.size, b"replaced".len() as i64);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_complete_multipart_upload_returns_one_object() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-complete-concurrent";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let (upload_id, parts) =
stage_upload_with_create_opts(&set_disks, bucket, object, &[0x22; 4096], &ObjectOptions::default()).await;
let first_set = set_disks.clone();
let second_set = set_disks.clone();
let first_parts = parts.clone();
let second_parts = parts.clone();
let first_upload = upload_id.clone();
let second_upload = upload_id.clone();
let (first, second) = tokio::join!(
async move {
first_set
.complete_multipart_upload(bucket, object, &first_upload, first_parts, &ObjectOptions::default())
.await
},
async move {
second_set
.complete_multipart_upload(bucket, object, &second_upload, second_parts, &ObjectOptions::default())
.await
},
);
let first = first.expect("one completion must succeed");
let second = second.expect("the duplicate completion must succeed");
assert_eq!(first.etag, second.etag, "both completions must report the same ETag");
assert_eq!(first.version_id, second.version_id);
assert_eq!(
usize::from(first.multipart_completion_replayed) + usize::from(second.multipart_completion_replayed),
1
);
let info = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the object must be readable after duplicate completion");
assert_eq!(info.etag, first.etag);
assert_eq!(info.size, 4096);
}
#[tokio::test]
async fn versioned_complete_retry_returns_the_completed_version() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-complete-versioned";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let versioned = ObjectOptions {
versioned: true,
..Default::default()
};
let (upload_id, parts) = stage_upload_with_create_opts(&set_disks, bucket, object, &[0x33; 4096], &versioned).await;
let completed = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts.clone(), &versioned)
.await
.expect("versioned completion should publish a version");
let completed_version = completed.version_id.expect("versioned completion assigns a version id");
let mut reader = PutObjReader::from_vec(b"newer-version".to_vec());
let successor = set_disks
.put_object(bucket, object, &mut reader, &versioned)
.await
.expect("a later versioned put should add a version");
assert_ne!(successor.version_id, Some(completed_version));
let retried = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &versioned)
.await
.expect("the retry must still find the version this upload published");
assert!(retried.multipart_completion_replayed);
assert_eq!(retried.etag, completed.etag);
assert_eq!(retried.version_id, Some(completed_version));
let latest = set_disks
.get_object_info(bucket, object, &versioned)
.await
.expect("the latest version must stay the successor put");
assert_eq!(latest.version_id, successor.version_id);
assert_ne!(latest.etag, completed.etag);
}
/// Crash-consistency for the two `complete_multipart_upload` commit windows.
///
/// rustfs/backlog#864: a fault that interrupts a completion must never mutate
@@ -9299,18 +9739,24 @@ mod tests {
"the post-commit crash must leave the upload listable for reclamation"
);
// A retried CompleteMultipartUpload is answered deterministically: the
// commit rename already consumed the upload's metadata, so the retry
// resolves to InvalidUploadID (NoSuchUpload to the S3 client, the
// standard answer for a completed-then-retried upload) — never a torn
// state, and the committed object is untouched by the retry.
let retried = complete(&set_disks, bucket, object, &u_new, parts_retry).await;
// The commit rename already consumed the upload's xl.meta, so the
// retry cannot read staging. It must still return the committed
// object: AWS CompleteMultipartUpload is idempotent for the same
// upload id and part list, and the retry must not tear that object.
let (body_before_retry, etag_before_retry) = read_object(&set_disks, bucket, object).await;
assert_eq!(body_before_retry, new, "a post-commit crash must leave the whole new version readable");
let retried = complete(&set_disks, bucket, object, &u_new, parts_retry)
.await
.expect("a retried complete after the commit landed must return the committed object");
assert!(retried.multipart_completion_replayed, "the retry must not publish a second object");
assert_eq!(retried.etag, etag_before_retry, "the retry must return the committed ETag");
let (body_after_retry, etag_after_retry) = read_object(&set_disks, bucket, object).await;
assert_eq!(body_after_retry, new, "the retry must not disturb the committed object");
assert_eq!(etag_after_retry, etag_before_retry, "the retry must not rewrite the committed ETag");
assert!(
matches!(retried, Err(StorageError::InvalidUploadID(..))),
"a retried complete after the commit landed must resolve to InvalidUploadID, got {retried:?}"
!upload_is_listed(&set_disks, bucket, object, &u_new).await,
"the idempotent retry must reclaim staging left behind by the crash"
);
let (body_after_retry, _) = read_object(&set_disks, bucket, object).await;
assert_eq!(body_after_retry, new, "the failed retry must not disturb the committed object");
// Reclaim the leftover exactly as the production tail does: delete_all
// on the upload path (abort_multipart_upload cannot — the upload's
+190 -52
View File
@@ -3500,6 +3500,13 @@ impl SetDisks {
mut publication_fence: Option<RemoteTuplePublicationFence>,
) -> Result<(ObjectInfo, Option<OldCurrentSize>)> {
let protect_write = opts.shard_integrity_write_enabled();
let source_bucket_incarnation_id = match opts.expected_bucket_incarnation_id {
Some(incarnation_id) => Some(incarnation_id),
// Internal metadata writes may already hold the pool metadata
// lock; resolving a user bucket identity would re-enter it.
None if !is_meta_bucketname(bucket) => self.bucket_incarnation_id_from_disk(bucket).await.ok(),
None => None,
};
if publication_fence.is_none()
&& opts.data_movement
&& rustfs_utils::http::metadata_compat::contains_key_str(
@@ -4031,7 +4038,7 @@ impl SetDisks {
{
#[cfg(any(test, feature = "test-util"))]
pause_put_object_commit(bucket, object, PutObjectCommitPause::BeforeNamespace).await;
if let Some(expected_incarnation_id) = opts.expected_bucket_incarnation_id
if let Some(expected_incarnation_id) = source_bucket_incarnation_id
&& opts.bucket_lifecycle_lock_fence.is_none()
{
bucket_lifecycle_guard = Some(
@@ -4642,6 +4649,7 @@ impl SetDisks {
request.object_version_id = committed_version_id
.or_else(|| commit_version_suspended.then(Uuid::nil))
.map(|version_id| version_id.to_string());
request.expected_bucket_incarnation_id = source_bucket_incarnation_id;
let object_lock_guard = _object_lock_guard.take();
let publication_guard = _publication_guard.take();
let bucket_lifecycle_guard = _bucket_lifecycle_guard.take();
@@ -4790,6 +4798,7 @@ impl SetDisks {
request.object_version_id = committed_version_id
.or_else(|| commit_version_suspended.then(Uuid::nil))
.map(|version_id| version_id.to_string());
request.expected_bucket_incarnation_id = source_bucket_incarnation_id;
commit_set.submit_rename_tail_heal(request).await;
}
@@ -7492,8 +7501,15 @@ impl SetDisks {
ensure_delete_commit_locks_held(None, bucket, &encoded_object, opts)?;
authorization.authorized_journal_name(&candidate)?;
begin_scanner_publication_delete_mutation(opts.scanner_publication_commit_scope.as_ref())?;
self.delete_object_version(bucket, &encoded_object, &delete_request, false)
.await?;
self.delete_object_version_with_purge(
bucket,
&encoded_object,
&delete_request,
false,
None,
opts.expected_bucket_incarnation_id,
)
.await?;
if let Some((_, deleted_object)) = replication_delete {
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_object).await;
}
@@ -7573,6 +7589,7 @@ impl SetDisks {
fi: &FileInfo,
force_del_marker: bool,
delete_marker_purge: Option<MrfDeleteMarkerPurge>,
source_bucket_incarnation_id: Option<Uuid>,
) -> Result<()> {
let transported = delete_file_info_with_replication_transport_metadata(fi);
let fi = &transported;
@@ -7727,13 +7744,55 @@ impl SetDisks {
{
let version_id = fi.version_id.map(|version| version.to_string());
let _ = self
.add_partial(bucket, object, version_id.as_deref().unwrap_or_default())
.add_partial_with_source_incarnation(
bucket,
object,
version_id.as_deref().unwrap_or_default(),
source_bucket_incarnation_id,
)
.await;
}
quorum_result
}
}
impl SetDisks {
async fn add_partial_with_source_incarnation(
&self,
bucket: &str,
object: &str,
version_id: &str,
source_bucket_incarnation_id: Option<Uuid>,
) -> Result<()> {
if self
.persist_partial_write(bucket, object, Some(version_id), source_bucket_incarnation_id)
.await
{
return Ok(());
}
let Some(source_bucket_incarnation_id) = source_bucket_incarnation_id else {
// The fallback is best-effort. Without the source generation it
// cannot safely target the object name after bucket recreation.
return Ok(());
};
let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
Some(HealChannelPriority::Normal),
Some(self.pool_index),
Some(self.set_index),
);
request.object_version_id = (!version_id.is_empty()).then(|| version_id.to_string());
request.source = rustfs_heal_contracts::heal_channel::HealRequestSource::Mrf;
request.expected_bucket_incarnation_id = Some(source_bucket_incarnation_id);
if let Err(error) = rustfs_heal_contracts::heal_channel::send_heal_request(request).await {
warn!(bucket, object, version_id, error = %error, "Failed to enqueue heal request for partial object");
}
Ok(())
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
type Error = Error;
@@ -8021,7 +8080,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
#[tracing::instrument(skip(self))]
async fn delete_object_version(&self, bucket: &str, object: &str, fi: &FileInfo, force_del_marker: bool) -> Result<()> {
self.delete_object_version_with_purge(bucket, object, fi, force_del_marker, None)
let source_bucket_incarnation_id = if is_meta_bucketname(bucket) {
None
} else {
self.bucket_incarnation_id_from_disk(bucket).await.ok()
};
self.delete_object_version_with_purge(bucket, object, fi, force_del_marker, None, source_bucket_incarnation_id)
.await
}
@@ -8843,7 +8907,15 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
delete_request.set_skip_tier_free_version();
}
begin_scanner_publication_delete_mutation(scanner_publication_commit_scope.as_ref())?;
self.delete_object_version(bucket, object, &delete_request, false).await?;
self.delete_object_version_with_purge(
bucket,
object,
&delete_request,
false,
None,
opts.expected_bucket_incarnation_id,
)
.await?;
if let Some((_, deleted_object)) = replication_delete {
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_object).await;
}
@@ -8858,7 +8930,15 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
};
delete_request.set_tier_free_version_id(&Uuid::new_v4().to_string());
begin_scanner_publication_delete_mutation(scanner_publication_commit_scope.as_ref())?;
self.delete_object_version(bucket, object, &delete_request, false).await?;
self.delete_object_version_with_purge(
bucket,
object,
&delete_request,
false,
None,
opts.expected_bucket_incarnation_id,
)
.await?;
}
for version in &versions.free_versions {
ensure_delete_commit_locks_held(_lock_guard.as_ref(), bucket, object, &opts)?;
@@ -8870,7 +8950,15 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
};
delete_request.set_tier_free_version();
begin_scanner_publication_delete_mutation(scanner_publication_commit_scope.as_ref())?;
self.delete_object_version(bucket, object, &delete_request, false).await?;
self.delete_object_version_with_purge(
bucket,
object,
&delete_request,
false,
None,
opts.expected_bucket_incarnation_id,
)
.await?;
}
}
}
@@ -8978,7 +9066,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
};
ensure_delete_commit_locks_held(_lock_guard.as_ref(), bucket, object, &opts)?;
begin_scanner_publication_delete_mutation(scanner_publication_commit_scope.as_ref())?;
self.delete_object_version(bucket, object, &dfi, false)
self.delete_object_version_with_purge(bucket, object, &dfi, false, None, opts.expected_bucket_incarnation_id)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
self.invalidate_get_object_metadata_cache(bucket, object).await;
@@ -9072,6 +9160,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&fi,
should_force_delete_marker_for_missing_version(&opts),
delete_marker_purge.clone(),
opts.expected_bucket_incarnation_id,
)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
@@ -9124,9 +9213,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
if opts.skip_free_version {
dfi.set_skip_tier_free_version();
}
self.delete_object_version_with_purge(bucket, object, &dfi, opts.delete_marker, delete_marker_purge)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
self.delete_object_version_with_purge(
bucket,
object,
&dfi,
opts.delete_marker,
delete_marker_purge,
opts.expected_bucket_incarnation_id,
)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
#[cfg(test)]
pause_delete_object_commit_after_publish(bucket, object).await;
@@ -9188,28 +9284,13 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[tracing::instrument(skip(self))]
async fn add_partial(&self, bucket: &str, object: &str, version_id: &str) -> Result<()> {
if self.persist_partial_write(bucket, object, Some(version_id)).await {
return Ok(());
}
let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
Some(HealChannelPriority::Normal),
Some(self.pool_index),
Some(self.set_index),
);
request.object_version_id = (!version_id.is_empty()).then(|| version_id.to_string());
if let Err(e) = rustfs_heal_contracts::heal_channel::send_heal_request(request).await {
warn!(
bucket,
object,
version_id,
error = %e,
"Failed to enqueue heal request for partial object"
);
}
Ok(())
let source_bucket_incarnation_id = if is_meta_bucketname(bucket) {
None
} else {
self.bucket_incarnation_id_from_disk(bucket).await.ok()
};
self.add_partial_with_source_incarnation(bucket, object, version_id, source_bucket_incarnation_id)
.await
}
#[tracing::instrument(skip(self))]
@@ -9768,7 +9849,10 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[cfg(all(test, feature = "test-util"))]
pause_transition_transaction_at(bucket, object, TransitionTransactionKillPoint::CommitFenceBeforeLocalCommit).await;
upload_cleanup.disarm();
if let Err(err) = self.delete_object_version(bucket, object, &fi, false).await {
if let Err(err) = self
.delete_object_version_with_purge(bucket, object, &fi, false, None, opts.expected_bucket_incarnation_id)
.await
{
warn!(
bucket = bucket,
object = object,
@@ -9839,7 +9923,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
continue;
}
let _ = self
.add_partial(bucket, object, opts.version_id.as_deref().unwrap_or_default())
.add_partial_with_source_incarnation(
bucket,
object,
opts.version_id.as_deref().unwrap_or_default(),
opts.expected_bucket_incarnation_id,
)
.await;
break;
}
@@ -19558,15 +19647,11 @@ mod put_object_tmp_cleanup_tests {
expected.is_subset(&after_tail),
"the detached tail must re-mark capacity after the first scope was drained"
);
let request = tokio::time::timeout(Duration::from_secs(30), heal_requests.recv())
.await
.expect("a failed tail should submit heal")
.expect("the per-set heal capture should stay connected");
assert_eq!(request.bucket, bucket);
assert_eq!(request.object_prefix.as_deref(), Some(object));
assert_eq!(request.object_version_id.as_deref(), Some(expected_version_id.as_str()));
assert_eq!(request.pool_index, Some(set_disks.pool_index));
assert_eq!(request.set_index, Some(set_disks.set_index));
assert!(
heal_requests.try_recv().is_err(),
"a legacy fixture without a bucket-generation record must not enqueue an unfenced fallback heal"
);
assert_eq!(expected_version_id, Uuid::nil().to_string());
})
.await;
}
@@ -19687,7 +19772,7 @@ mod put_object_tmp_cleanup_tests {
#[tokio::test]
#[serial_test::serial(capacity_dirty_scope)]
async fn tail_drained_put_preserves_quorum_success_and_heals_failed_tail() {
async fn tail_drained_put_preserves_quorum_success_without_unfenced_fallback_heal() {
let (_dirs, disks, set) = hermetic_set_disks(4).await;
let bucket = "put-full-tail-heal";
let object = "full-tail-heal-object";
@@ -19723,12 +19808,10 @@ mod put_object_tmp_cleanup_tests {
.expect("PUT task should join")
.expect("a minority tail error must not negate committed quorum");
assert_eq!(tasks.running(), 0);
let heal = tokio::time::timeout(Duration::from_secs(30), heals.recv())
.await
.expect("failed tail must schedule heal")
.expect("heal capture must remain connected");
assert_eq!(heal.bucket, bucket);
assert_eq!(heal.object_prefix.as_deref(), Some(object));
assert!(
heals.try_recv().is_err(),
"a legacy fixture without a bucket-generation record must not enqueue an unfenced fallback heal"
);
let info = set
.get_object_info(bucket, object, &ObjectOptions::default())
.await
@@ -22371,6 +22454,61 @@ mod single_delete_namespace_owner_tests {
.expect("seed a complete real object version");
}
#[tokio::test]
#[serial_test::serial]
async fn internal_metadata_mutations_do_not_reenter_pool_metadata() {
let (_dirs, store, _other_store) =
crate::services::rebalance::test_three_pool_stores_with_isolated_node_contexts(None).await;
let set = &store.pools[0].disk_set[0];
let disks = set.disk_inventory().await.into_iter().flatten().collect::<Vec<_>>();
for bucket in [RUSTFS_META_BUCKET, crate::disk::MIGRATING_META_BUCKET] {
for disk in &disks {
if let Err(err) = disk.make_volume(bucket).await {
assert_eq!(err, DiskError::VolumeExists, "prepare the internal metadata volume");
}
}
let version = Uuid::new_v4();
seed_version(set, bucket, "delete-under-pool-lock", version, b"metadata version").await;
let request = FileInfo {
name: "delete-under-pool-lock".to_string(),
version_id: Some(version),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
let mut reader = PutObjReader::from_vec(b"metadata under lock".to_vec());
let opts = ObjectOptions {
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
// Pool metadata transactions retain this write guard while saving
// internal objects. None of these paths may look up a user bucket.
let _pool_meta_guard = store.pool_meta.write().await;
let limit = Duration::from_secs(30);
let (put, delete, partial) = tokio::join!(
tokio::time::timeout(limit, set.put_object(bucket, "put-under-pool-lock", &mut reader, &opts)),
tokio::time::timeout(limit, set.delete_object_version(bucket, &request.name, &request, false)),
tokio::time::timeout(limit, set.add_partial(bucket, "partial-under-pool-lock", "")),
);
assert!(
matches!(&put, Ok(Ok(_))) && matches!(&delete, Ok(Ok(()))) && matches!(&partial, Ok(Ok(()))),
"internal operations must finish while pool metadata is locked: bucket={bucket}, put={put:?}, delete={delete:?}, partial={partial:?}"
);
for disk in &disks {
let written = disk
.read_version("", bucket, "put-under-pool-lock", "", &ReadOptions::default())
.await
.expect("the internal PUT must reach every disk");
assert_eq!(written.size, b"metadata under lock".len() as i64);
let deleted = disk
.read_version("", bucket, &request.name, &version.to_string(), &ReadOptions::default())
.await;
assert!(matches!(deleted, Err(DiskError::FileNotFound | DiskError::FileVersionNotFound)));
}
}
}
#[tokio::test]
#[serial_test::serial(capacity_dirty_scope)]
async fn single_delete_advances_namespace_generation_through_cleanup() {
+54 -10
View File
@@ -1569,12 +1569,27 @@ mod tests {
disk_indexes: &[usize],
) -> Vec<(usize, crate::disk::DiskStore)> {
let offline = {
let mut disks = set.disks.write().await;
let disks = set.disks.read().await;
disk_indexes
.iter()
.map(|index| (*index, disks[*index].take().expect("fault-injection disk should start online")))
.map(|index| (*index, disks[*index].clone().expect("fault-injection disk should start online")))
.collect::<Vec<_>>()
};
for (_, disk) in &offline {
disk.close().await.expect("fault injection should stop per-disk monitoring");
disk.force_offline_for_test();
}
// Empty slots can be renewed from the still-present disk paths by the endpoint monitor.
// Keep the original handles and prove reconnect cannot clear their injected IO failure.
set.connect_disks().await;
{
let disks = set.disks.read().await;
for (index, disk) in &offline {
let current = disks[*index].as_ref().expect("fault-injection slot should remain populated");
assert!(Arc::ptr_eq(current, disk), "reconnect must preserve the fault-injection handle");
assert_eq!(disk.runtime_state(), crate::disk::health_state::RuntimeDriveHealthState::Offline);
}
}
let local_disk_map = ecstore.ctx.local_disk_map();
let mut local_disks = local_disk_map.write().await;
for (_, disk) in &offline {
@@ -1595,9 +1610,12 @@ mod tests {
local_disks.insert(disk.endpoint().to_string(), Some(Arc::clone(disk)));
}
}
let mut disks = set.disks.write().await;
let disks = set.disks.read().await;
for (index, disk) in offline {
assert!(disks[index].replace(disk).is_none(), "fault-injection disk slot should remain empty");
let current = disks[index].as_ref().expect("fault-injection slot should remain populated");
assert!(Arc::ptr_eq(current, &disk), "fault-injection disk handle should remain unchanged");
assert_eq!(disk.runtime_state(), crate::disk::health_state::RuntimeDriveHealthState::Offline);
disk.reset_health_for_store_init_retry();
}
}
@@ -1854,8 +1872,12 @@ mod tests {
assert_eq!(residue.diagnostic_bytes_read, 0);
}
#[tokio::test]
#[tokio::test(start_paused = true)]
async fn bucket_residue_scan_distinguishes_visible_and_tier_free_xlmeta() {
// Classification keeps the production budget, but real filesystem
// scheduling must not advance the clock before classification finishes.
let (clock_guard_tx, clock_guard_rx) = std::sync::mpsc::channel::<()>();
let clock_guard = tokio::task::spawn_blocking(move || clock_guard_rx.recv());
let root = tempfile::tempdir().expect("temporary bucket root should be created");
let bucket_path = root.path().join("bucket");
let visible_path = bucket_path.join("visible").join(STORAGE_FORMAT_FILE);
@@ -1877,6 +1899,7 @@ mod tests {
let visible_scan = scan_metadata_less_residue(&bucket_path)
.await
.expect("visible xl.meta scan should succeed");
assert!(!visible_scan.diagnostic_truncated, "{visible_scan:?}");
assert_eq!(visible_scan.xlmeta_blocker, Some(BucketDeleteBlockerKind::VisibleVersion));
tokio::fs::remove_dir_all(bucket_path.join("visible"))
@@ -1913,6 +1936,7 @@ mod tests {
let free_scan = scan_metadata_less_residue(&bucket_path)
.await
.expect("free-version xl.meta scan should succeed");
assert!(!free_scan.diagnostic_truncated, "{free_scan:?}");
assert_eq!(free_scan.xlmeta_blocker, Some(BucketDeleteBlockerKind::TierFreeVersion));
tokio::fs::remove_dir_all(bucket_path.join("free"))
@@ -1933,6 +1957,7 @@ mod tests {
let exact_limit_scan = scan_metadata_less_residue(&bucket_path)
.await
.expect("exact-limit xl.meta scan should remain fail closed");
assert!(!exact_limit_scan.diagnostic_truncated, "{exact_limit_scan:?}");
assert_eq!(exact_limit_scan.xlmeta_blocker, Some(BucketDeleteBlockerKind::UnknownXlMeta));
assert_eq!(exact_limit_scan.diagnostic_bytes_read, BUCKET_DELETE_XLMETA_DIAGNOSTIC_MAX_BYTES);
tokio::fs::remove_dir_all(bucket_path.join("exact-limit"))
@@ -1953,9 +1978,31 @@ mod tests {
let oversized_scan = scan_metadata_less_residue(&bucket_path)
.await
.expect("oversized xl.meta scan should remain fail closed");
assert!(!oversized_scan.diagnostic_truncated, "{oversized_scan:?}");
assert_eq!(oversized_scan.xlmeta_blocker, Some(BucketDeleteBlockerKind::UnknownXlMeta));
assert_eq!(oversized_scan.diagnostic_bytes_read, 0);
assert!(oversized_scan.diagnostic_bytes_read <= BUCKET_DELETE_XLMETA_DIAGNOSTIC_MAX_BYTES);
drop(clock_guard_tx);
let _ = clock_guard
.await
.expect("classification clock guard should exit after its sender is dropped");
// An exhausted diagnostic budget may return before observing xl.meta.
// It must report truncation rather than inventing a classification.
let first_io_started = Arc::new(AtomicBool::new(false));
let mut budget = BucketDeleteDiagnosticBudget::new().with_first_io_delay(
BUCKET_DELETE_DIAGNOSTIC_MAX_ELAPSED + Duration::from_millis(100),
first_io_started.clone(),
);
let truncated = scan_metadata_less_residue_with_budget(&bucket_path, &mut budget)
.await
.expect("a diagnostic timeout should return a partial scan");
assert!(first_io_started.load(Ordering::SeqCst));
assert!(truncated.diagnostic_truncated, "{truncated:?}");
assert_eq!(truncated.xlmeta_blocker, None);
assert_eq!(truncated.entries_scanned, 0);
assert_eq!(truncated.diagnostic_bytes_read, 0);
}
#[tokio::test]
@@ -3376,7 +3423,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn make_bucket_seeds_new_bucket_durability_override() {
async fn make_bucket_inherits_default_durability() {
temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, None::<&str>)], async {
let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await;
let bucket = format!("bucket-default-durability-{}", Uuid::new_v4().simple());
@@ -3389,10 +3436,7 @@ mod tests {
let metadata = metadata_sys::get_in(&ecstore.ctx, &bucket)
.await
.expect("metadata should load for the new bucket");
assert_eq!(
metadata.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
);
assert!(metadata.durability_config().is_none());
})
.await;
}
+276 -6
View File
@@ -4362,7 +4362,7 @@ mod tests {
// successful attempts. Only injected faults spend this global budget.
// A real failure may consume an attempt, so preserve the final chance.
faults
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
.try_update(Ordering::SeqCst, Ordering::SeqCst, |faults| {
(faults < crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS.saturating_sub(1))
.then_some(faults.saturating_add(1))
})
@@ -11176,9 +11176,17 @@ mod tests {
const ADMITTED_READS: usize = 32;
let temp_dir = tempfile::tempdir().expect("create validation-read shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-validation-read-shutdown", &[4]))
.await;
// The manual worker must own the seeded records and shutdown progress.
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"dispatch-validation-read-shutdown",
&[(1, 4)],
CancellationToken::new(),
Some(Arc::new(instance_ctx)),
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-validation-read-shutdown-bucket";
store
@@ -11710,8 +11718,17 @@ mod tests {
const ADMITTED_BATCH: usize = 32;
let temp_dir = tempfile::tempdir().expect("create delete-batch shutdown store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "dispatch-delete-batch-shutdown", &[4])).await;
// The manual worker must own the seeded records and shutdown progress.
let mut instance_ctx = crate::runtime::instance::InstanceContext::new();
instance_ctx.suppress_tier_delete_journal_recovery_for_test();
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store_with_layout(
temp_dir.path(),
"dispatch-delete-batch-shutdown",
&[(1, 4)],
CancellationToken::new(),
Some(Arc::new(instance_ctx)),
))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "dispatch-delete-batch-shutdown-bucket";
store
@@ -20362,6 +20379,259 @@ mod tests {
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_config_initial_reload_lock_failure_keeps_recovery_worker() {
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
let temp_dir = tempfile::tempdir().expect("create contended tier startup store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-startup-contention", &[4])).await;
let manager = ctx.tier_config_mgr();
register_mock_tier(&manager, "COLD-A").await;
let candidate = TierConfigMgr::new();
let candidate = candidate.read().await;
let candidate_digest = tier_config_candidate_digest(&candidate).expect("empty candidate digest should resolve");
candidate
.save_tiering_config(store.clone())
.await
.expect("committed removal config should persist");
let config_info = store
.get_object_info(
RUSTFS_META_BUCKET,
&format!("{}/{TIER_CONFIG_FILE}", com::CONFIG_PREFIX),
&ObjectOptions::default(),
)
.await
.expect("committed removal config metadata should load");
let mutation_id = uuid::Uuid::new_v4();
let mut intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", candidate_digest);
intent.kind = TierMutationIntentKind::Remove;
intent.affected_targets[0].new_backend_identity = None;
intent
.advance(
TierMutationIntentState::Committed,
Some(config_info.etag.expect("committed config should have an ETag")),
)
.expect("removal intent should commit");
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("committed removal intent should persist");
let config_lock = format!("{}/{TIER_CONFIG_FILE}.lock", com::CONFIG_PREFIX);
let namespace = store
.new_ns_lock(RUSTFS_META_BUCKET, &config_lock)
.await
.expect("tier config namespace should resolve");
let owner = namespace
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("competing recovery should hold the config lock");
let initial_error = runtime_sources::init_tier_config_mgr_handle(manager.clone(), store.clone())
.await
.expect_err("startup must still report the initial recovery lock failure");
assert!(initial_error.to_string().contains(&config_lock));
match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
Err(err) => assert_eq!(err.message, "Remote tier configuration is being replaced"),
Ok(_) => panic!("failed initial recovery must retain its mutation fence"),
}
drop(owner);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
if manager.read().await.tiers.is_empty()
&& matches!(
TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await,
Err(err) if err.code == crate::services::tier::tier_handlers::ERR_TIER_NOT_FOUND.code
)
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("recovery worker must publish and clear its fence without another admin mutation");
assert_eq!(
load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("unexpired committed tombstone must remain durable"),
intent
);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_peer_prepare_reloads_after_save_and_load_lock_timeouts() {
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use std::sync::atomic::Ordering;
let temp_dir = tempfile::tempdir().expect("create contended tier prepare store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-prepare-contention", &[4])).await;
let mutation_id = uuid::Uuid::new_v4();
let intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [9; 32]);
let object = crate::services::tier::tier_mutation_intent::tier_mutation_intent_record_object_name(mutation_id)
.expect("intent record object should resolve");
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
RUSTFS_META_BUCKET,
&object,
crate::set_disk::PutObjectCommitPause::BeforeNamespace,
);
let prepare_store = store.clone();
let payload = intent.encode().expect("prepare intent should encode");
let mut prepare = tokio::spawn(async move {
handle_tier_mutation_peer_request(
prepare_store,
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&payload,
)
.await
});
barrier.wait_until_paused().await;
let namespace = store.pools[0].disk_set[0]
.new_ns_lock(RUSTFS_META_BUCKET, &object)
.await
.expect("intent record namespace should resolve");
let owner = namespace
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("competing publisher should hold the intent namespace");
// The competing publisher already owns the namespace. Bypass only its
// recursive acquisition and drain the durable tail before releasing it.
com::save_config_with_opts(
store.clone(),
&object,
intent.encode().expect("winning intent should encode"),
&ObjectOptions {
no_lock: true,
max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("competing publisher should persist the matching intent under its lock");
let manager = ctx
.lock_manager()
.as_fast_lock_manager()
.expect("fixture should use real locks");
let key = rustfs_lock::fast_lock::types::ObjectKey {
bucket: Arc::from(RUSTFS_META_BUCKET),
object: Arc::from(object.as_str()),
version: None,
};
let timeouts = &manager.get_shard(&key).metrics().timeouts;
let before = timeouts.load(Ordering::Relaxed);
barrier.release_and_wait_until_namespace_pending().await;
drop(barrier);
tokio::select! {
result = &mut prepare => panic!("Prepare must retry the contended save and load before completing: {result:?}"),
result = tokio::time::timeout(Duration::from_secs(30), async {
while timeouts.load(Ordering::Relaxed) - before < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}) => result.expect("the save and subsequent load should each exhaust their lock wait"),
}
assert_eq!(timeouts.load(Ordering::Relaxed) - before, 2);
drop(owner);
let outcome = tokio::time::timeout(Duration::from_secs(30), prepare)
.await
.expect("third prepare round should complete after the competing publisher releases its lock")
.expect("prepare task should join")
.expect("third prepare round should reload the matching durable intent");
assert_eq!(outcome.state, TierMutationPeerState::Prepared);
assert!(!outcome.applied, "the retry must reuse the winning record rather than recreate it");
assert_eq!(
load_tier_mutation_intent_record(store.clone(), mutation_id)
.await
.expect("winning record should remain readable"),
intent
);
assert_eq!(timeouts.load(Ordering::Relaxed) - before, 2);
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn tier_mutation_peer_prepare_preserves_lock_timeout_after_three_rounds() {
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use std::sync::atomic::Ordering;
let temp_dir = tempfile::tempdir().expect("create exhausted tier prepare store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "tier-prepare-exhaustion", &[4])).await;
let mutation_id = uuid::Uuid::new_v4();
let intent = tier_mutation_peer_test_intent(mutation_id, "COLD-A", [9; 32]);
let object = crate::services::tier::tier_mutation_intent::tier_mutation_intent_record_object_name(mutation_id)
.expect("intent record object should resolve");
let barrier = crate::set_disk::PutObjectCommitBarrier::install(
RUSTFS_META_BUCKET,
&object,
crate::set_disk::PutObjectCommitPause::BeforeNamespace,
);
let prepare_store = store.clone();
let payload = intent.encode().expect("prepare intent should encode");
let prepare = tokio::spawn(async move {
handle_tier_mutation_peer_request(
prepare_store,
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
&payload,
)
.await
});
barrier.wait_until_paused().await;
let namespace = store.pools[0].disk_set[0]
.new_ns_lock(RUSTFS_META_BUCKET, &object)
.await
.expect("intent record namespace should resolve");
let lock_timeout = crate::set_disk::get_lock_acquire_timeout();
let owner = namespace
.get_write_lock(lock_timeout)
.await
.expect("competing publisher should hold the intent namespace");
let manager = ctx
.lock_manager()
.as_fast_lock_manager()
.expect("fixture should use real locks");
let key = rustfs_lock::fast_lock::types::ObjectKey {
bucket: Arc::from(RUSTFS_META_BUCKET),
object: Arc::from(object.as_str()),
version: None,
};
let timeouts = &manager.get_shard(&key).metrics().timeouts;
let before = timeouts.load(Ordering::Relaxed);
barrier.release_and_wait_until_namespace_pending().await;
drop(barrier);
let error = tokio::time::timeout(Duration::from_secs(30), prepare)
.await
.expect("three contended prepare rounds should exhaust the bounded budget")
.expect("prepare task should join")
.expect_err("Prepare must fail while the competing publisher keeps the namespace");
match error {
TierMutationPeerError::Store(Error::Lock(rustfs_lock::LockError::Timeout { resource, timeout })) => {
assert_eq!(resource, key.to_string());
assert_eq!(timeout, lock_timeout);
}
other => panic!("Prepare must preserve the final typed lock timeout: {other:?}"),
}
assert_eq!(timeouts.load(Ordering::Relaxed) - before, 3, "save and load must share three rounds");
drop(owner);
assert!(matches!(
load_tier_mutation_intent_record(store, mutation_id).await,
Err(Error::ConfigNotFound)
));
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
+194 -69
View File
@@ -387,6 +387,24 @@ fn should_purge_empty_directory_listing(
&& result.prefixes.is_empty()
}
fn should_purge_empty_recursive_bucket_listing(
prefix: &str,
delimiter: Option<&str>,
marker: Option<&str>,
max_keys: i32,
incl_deleted: bool,
result: &ListObjectsInfo,
) -> bool {
prefix.is_empty()
&& delimiter.is_none()
&& marker.is_none()
&& max_keys > 0
&& !incl_deleted
&& !result.is_truncated
&& result.objects.is_empty()
&& result.prefixes.is_empty()
}
const MARKER_TAG_VERSION: &str = "v2";
const LEGACY_MARKER_TAG_VERSIONS: &[&str] = &["v1", MARKER_TAG_VERSION];
const LIST_CACHE_MARKER_PREFIX: &str = "[rustfs_cache:";
@@ -4006,82 +4024,91 @@ impl ECStore {
// key sorting between `<marker>` and `<marker>[` is silently skipped on
// the continuation page (backlog#1047).
opts.parse_marker();
if let Some(mode) = list_objects_index_mode_from_env()
&& let Some(result) = self
.clone()
let key_only_result = if let Some(mode) = list_objects_index_mode_from_env() {
self.clone()
.list_objects_from_opt_in_key_only_provider(&opts, mode, max_keys, incl_deleted)
.await?
{
if should_purge_empty_directory_listing(prefix, opts.marker.as_deref(), max_keys, incl_deleted, &result)
&& has_authoritative_never_versioned_state_in(&self.ctx, bucket)
.await
.unwrap_or(false)
{
self.purge_orphan_dir_object(bucket, prefix).await;
}
return Ok(result);
}
let mut list_result = self
.clone()
.list_path(&opts)
.await
.unwrap_or_else(|err| MetaCacheEntriesSortedResult {
err: Some(to_filemeta_err(err)),
..Default::default()
});
let next_cache_id = list_result.entries.as_ref().and_then(|entries| entries.list_id.clone());
// err=None means gather_results filled its limit → disk has more data
let disk_has_more = list_result.err.is_none();
if let Some(err) = list_result.err.take()
&& err != rustfs_filemeta::Error::Unexpected
{
return Err(to_object_err(err.into(), vec![bucket, prefix]));
}
if let Some(result) = list_result.entries.as_mut() {
result.forward_past(opts.marker.clone());
}
// contextCanceled
// Last RAW scanned key, captured before folding, so `list_objects_paginate`
// can advance past a fully-collapsed common-prefix page (ECA-03 / #944).
let last_scanned_key = last_scanned_entry_name(list_result.entries.as_ref());
let get_objects = ObjectInfo::from_meta_cache_entries_sorted_infos(
&list_result.entries.unwrap_or_default(),
bucket,
prefix,
delimiter.clone(),
)
.await;
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
get_objects,
&delimiter,
max_keys,
disk_has_more,
next_cache_id.as_deref(),
false,
last_scanned_key.as_deref(),
);
let _ = next_version_idmarker;
let result = ListObjectsInfo {
is_truncated,
next_marker,
objects,
prefixes,
} else {
None
};
if should_purge_empty_directory_listing(prefix, opts.marker.as_deref(), max_keys, incl_deleted, &result)
let result = if let Some(result) = key_only_result {
result
} else {
let mut list_result = self
.clone()
.list_path(&opts)
.await
.unwrap_or_else(|err| MetaCacheEntriesSortedResult {
err: Some(to_filemeta_err(err)),
..Default::default()
});
let next_cache_id = list_result.entries.as_ref().and_then(|entries| entries.list_id.clone());
// err=None means gather_results filled its limit → disk has more data
let disk_has_more = list_result.err.is_none();
if let Some(err) = list_result.err.take()
&& err != rustfs_filemeta::Error::Unexpected
{
return Err(to_object_err(err.into(), vec![bucket, prefix]));
}
if let Some(result) = list_result.entries.as_mut() {
result.forward_past(opts.marker.clone());
}
// Last RAW scanned key, captured before folding, so `list_objects_paginate`
// can advance past a fully-collapsed common-prefix page (ECA-03 / #944).
let last_scanned_key = last_scanned_entry_name(list_result.entries.as_ref());
let get_objects = ObjectInfo::from_meta_cache_entries_sorted_infos(
&list_result.entries.unwrap_or_default(),
bucket,
prefix,
delimiter.clone(),
)
.await;
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate(
get_objects,
&delimiter,
max_keys,
disk_has_more,
next_cache_id.as_deref(),
false,
last_scanned_key.as_deref(),
);
let _ = next_version_idmarker;
ListObjectsInfo {
is_truncated,
next_marker,
objects,
prefixes,
}
};
let purge_exact_prefix =
should_purge_empty_directory_listing(prefix, opts.marker.as_deref(), max_keys, incl_deleted, &result);
let purge_empty_bucket = should_purge_empty_recursive_bucket_listing(
prefix,
delimiter.as_deref(),
opts.marker.as_deref(),
max_keys,
incl_deleted,
&result,
);
if (purge_exact_prefix || purge_empty_bucket)
&& has_authoritative_never_versioned_state_in(&self.ctx, bucket)
.await
.unwrap_or(false)
{
self.purge_orphan_dir_object(bucket, prefix).await;
if purge_exact_prefix {
self.purge_orphan_dir_object(bucket, prefix).await;
} else {
self.purge_orphan_dir_objects_in_bucket(bucket).await;
}
}
Ok(result)
}
@@ -9507,6 +9534,38 @@ mod test {
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &truncated));
}
#[test]
fn recursive_bucket_orphan_purge_requires_an_empty_complete_root_scan() {
let empty = ListObjectsInfo::default();
assert!(super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing(
"ghost/", None, None, 1, false, &empty
));
assert!(!super::should_purge_empty_recursive_bucket_listing("", Some("/"), None, 1, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing(
"",
None,
Some("marker"),
1,
false,
&empty
));
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 0, false, &empty));
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, true, &empty));
let live = ListObjectsInfo {
objects: vec![ObjectInfo::default()],
..Default::default()
};
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &live));
let incomplete = ListObjectsInfo {
is_truncated: true,
..Default::default()
};
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &incomplete));
}
#[tokio::test]
async fn empty_recursive_listing_purges_committed_delete_residue() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
@@ -9558,6 +9617,72 @@ mod test {
}
}
#[tokio::test]
async fn empty_recursive_bucket_listing_purges_orphan_directory_prefixes() {
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 = "recursive-bucket-orphan-purge";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created with authoritative metadata");
for dir in &dirs {
tokio::fs::create_dir_all(dir.path().join(bucket).join("ghost").join("nested").join("leaf"))
.await
.expect("metadata-less orphan directory tree should be created");
}
let result = store
.clone()
.list_objects_generic(bucket, "", None, None, 1000, false)
.await
.expect("recursive bucket listing should succeed");
assert!(result.objects.is_empty(), "orphan directories are not S3 objects");
assert!(result.prefixes.is_empty(), "a delimiter-less listing has no CommonPrefixes");
for dir in &dirs {
assert!(
!dir.path().join(bucket).join("ghost").exists(),
"an empty recursive bucket scan should reclaim its metadata-less orphan prefix"
);
}
let versioned_bucket = "recursive-bucket-versioned-orphan";
store
.make_bucket(versioned_bucket, &MakeBucketOptions::default())
.await
.expect("versioned test bucket should be created");
store
.update_bucket_metadata_config(
versioned_bucket,
crate::bucket::metadata::BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("bucket versioning should be enabled");
for dir in &dirs {
tokio::fs::create_dir_all(dir.path().join(versioned_bucket).join("ghost").join("nested").join("leaf"))
.await
.expect("versioned bucket orphan tree should be created");
}
store
.clone()
.list_objects_generic(versioned_bucket, "", None, None, 1000, false)
.await
.expect("recursive versioned bucket listing should succeed");
for dir in &dirs {
assert!(
dir.path().join(versioned_bucket).join("ghost").exists(),
"root recursive LIST must not purge orphan residue in a versioned bucket"
);
}
}
#[tokio::test]
async fn list_objects_exact_prefix_paginates_across_storage_layers() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
+4 -1
View File
@@ -403,7 +403,10 @@ async fn enqueue_transition_after_write(
) -> Result<ObjectInfo> {
match result {
Ok(oi) => {
if should_enqueue_transition_immediately(&oi) {
// A retried completion did not publish a new object. Immediate
// transition/expiry already ran for the original commit; the scanner
// still sees the object if that commit never reached this hook.
if !oi.multipart_completion_replayed && should_enqueue_transition_immediately(&oi) {
enqueue_transition_immediate(&oi, src.clone()).await;
if let Ok(api) = metadata_sys::object_store_in(&store.ctx).await {
enqueue_immediate_expiry(api, &oi, src, opts).await;
+26 -4
View File
@@ -993,10 +993,32 @@ impl ECStore {
.await;
}
let pool_idx = self.multipart_upload_pool_idx(bucket, object, upload_id, opts).await?;
let pool = self.pools[pool_idx].clone();
pool.complete_multipart_upload(bucket, object, upload_id, uploaded_parts, opts)
.await
match self.multipart_upload_pool_idx(bucket, object, upload_id, opts).await {
Ok(pool_idx) => {
self.pools[pool_idx]
.clone()
.complete_multipart_upload(bucket, object, upload_id, uploaded_parts, opts)
.await
}
// The staging upload is already gone. The completed object lives on
// the pool that published it; other pools answer InvalidUploadID.
Err(err) if is_err_invalid_upload_id(&err) => {
let mut missing = err;
for pool_idx in self.existing_multipart_pool_order().await {
match self.pools[pool_idx]
.clone()
.complete_multipart_upload(bucket, object, upload_id, uploaded_parts.clone(), opts)
.await
{
Ok(info) => return Ok(info),
Err(err) if is_err_invalid_upload_id(&err) => missing = err,
Err(err) => return Err(err),
}
}
Err(missing)
}
Err(err) => Err(err),
}
}
#[cfg(all(test, feature = "test-util"))]
+29
View File
@@ -4693,6 +4693,14 @@ impl ECStore {
/// the caller falls back to surfacing the original NotFound.
pub(super) async fn purge_orphan_dir_object(&self, bucket: &str, object: &str) -> bool {
let prefix = decode_dir_object(object);
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
if !set.orphan_purge_has_complete_disk_set().await {
return false;
}
}
}
let mut purged = false;
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
@@ -4713,6 +4721,27 @@ impl ECStore {
purged
}
pub(super) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
if is_meta_bucketname(bucket) {
return false;
}
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
if !set.orphan_purge_has_complete_disk_set().await {
return false;
}
}
}
let mut purged = false;
for pool in self.pools.iter() {
for set in pool.disk_set.iter() {
purged |= set.purge_orphan_dir_objects_in_bucket(bucket).await;
}
}
purged
}
pub async fn delete_object_with_tier_delete_journal(
self: &Arc<Self>,
bucket: &str,
+12
View File
@@ -24,6 +24,12 @@ pub const HEAL_DELETE_DANGLING: bool = true;
pub const RUSTFS_RESERVED_BUCKET: &str = "rustfs";
pub const RUSTFS_RESERVED_BUCKET_PATH: &str = "/rustfs";
/// Detail attached to a completed deep heal when a healthy legacy object has
/// no independent identity commitment. Durable MRF handling uses this exact
/// reason to pause proofless retries without treating the object as repaired.
pub const LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL: &str =
"Legacy object uses standard repair; independent object identity remains unverified";
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum HealItemType {
Metadata,
@@ -355,6 +361,9 @@ pub struct HealChannelRequest {
pub object_prefix: Option<String>,
/// Object version ID (optional)
pub object_version_id: Option<String>,
/// Bucket incarnation observed by the object write that produced a local
/// durable partial-write responsibility.
pub expected_bucket_incarnation_id: Option<Uuid>,
/// Force start heal
pub force_start: bool,
/// Priority
@@ -592,6 +601,7 @@ pub fn create_heal_request(
bucket,
object_prefix,
object_version_id: None,
expected_bucket_incarnation_id: None,
force_start,
priority: priority.unwrap_or_default(),
pool_index: None,
@@ -624,6 +634,7 @@ pub fn create_heal_request_with_options(
bucket,
object_prefix,
object_version_id: None,
expected_bucket_incarnation_id: None,
force_start,
priority: priority.unwrap_or_default(),
pool_index,
@@ -655,6 +666,7 @@ fn create_auto_heal_disk_request(set_disk_id: String, priority: Option<HealChann
disk: Some(set_disk_id),
heal_endpoints: Vec::new(),
object_version_id: None,
expected_bucket_incarnation_id: None,
force_start: false,
priority: priority.unwrap_or(HealChannelPriority::Low),
pool_index: None,
+1
View File
@@ -99,6 +99,7 @@ crc-fast = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
tonic = { workspace = true }
rustfs-filemeta = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
rustfs-test-utils = { workspace = true }

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