Compare commits

..

12 Commits

Author SHA1 Message Date
Chris An 46337942d5 fix(test): backport scanner wait correction for 1.0.4 (#8930)
* fix(test): follow scanner schedules during multi-pool recovery

* fix(test): retain active scanner completion budget

* test: preserve E2E membership for scanner wait regression
2026-10-25 18:21:25 +08:00
Hauser 46e56019eb chore(deps): refresh dependencies and pin Pulsar 6.9.0 (#8923) 2026-10-25 14:41:41 +08:00
Chris An e02c27feaf chore(release): prepare 1.0.4 2026-10-25 13:28:03 +08:00
Chris An 440f831d12 ci(release): pin the 1.0.3 console for the security patch
Retain Console v0.1.37 and its original verified archive digest.
Reject mismatched release metadata rather than selecting newer UI changes.
2026-10-25 13:28:00 +08:00
Chris An 0ad3afcf0a fix(security): backport emSign distrust to 1.0.3
Update the patched verifier and bundled roots without unrelated dependency changes.
Disable the distrusted G3 native root and validate both Docker trust stores.

Refs #8819
2026-10-25 13:27:56 +08:00
overtrue 331fb89628 chore(release): prepare 1.0.3 2026-10-17 15:57:35 +08:00
Chris 2b243d8659 fix(scanner): clear transient cycle recovery on follower lock contention (1.0.2 backport) (#8596)
fix(scanner): clear transient cycle recovery on follower lock contention (#8566)

(cherry picked from commit 606d8a3a8a)

Co-authored-by: Ramakrishna Chilaka <49393831+RamakrishnaChilaka@users.noreply.github.com>
2026-10-16 04:05:23 +08:00
Chris c52570d385 fix(rebalance): wait for readiness and admit peer workers (1.0.2 backport) (#8593)
fix(rebalance): wait for readiness and admit peer workers (#8570)

* fix(rebalance): avoid contention when peers join the same run

* fix(rebalance): wait for fleet proof during initialization

* test(rebalance): check contextualized competing run errors

* fix(rebalance): retain typed worker admission lock errors

* test(rebalance): retry the init readiness 503 and pin the retry error shape (#8586)

The e2e harness only retried the pre-fix 500 carrying the fleet-proof
marker; the init stage now answers 503 + Retry-After after its own
readiness budget, so start_rebalance treats that as the same wait. The
missing-proof regression also asserts the typed error shape that the admin
handler's retry predicate depends on, not only the rendered message.

---------


(cherry picked from commit 7682a12b58)

Co-authored-by: cxymds <cxymds@gmail.com>
2026-10-15 18:11:29 +00:00
Chris e0b1635abf fix(multipart): retain commit guards and reclaim orphan staging (1.0.2 backport) (#8592)
fix(multipart): retain commit guards across request cancellation (#8571)

* fix(multipart): retain commit guards across request cancellation

* fix(multipart): preserve commit task error context

* fix(multipart): reclaim verified orphan upload staging (#8572)

* fix(multipart): reclaim verified orphan upload staging

* fix(multipart): share orphan guards across matching lock domains

* fix(multipart): preserve orphan cleanup task error context

* fix(ecstore): box guarded delete transition identity

* fix(multipart): keep NoSuchUpload when orphan recovery cannot prove absence (#8587)

Recovery is a side path of an idempotent Abort. An offline disk, lock
contention, partially overlapping lock domains or unreadable bucket
metadata all stop the staging-absence proof before any deletion; those
used to propagate as 503/500, where the base answered NoSuchUpload. They
now keep that answer and delete nothing; only failures inside the
deletion task remain request failures, because a retry can finish them.

---------


(cherry picked from commit 65f30bd5c1)

Co-authored-by: cxymds <cxymds@gmail.com>
2026-10-15 18:00:45 +00:00
Chris f0f2b0cf48 fix(replication): skip the replica ETag audit for encrypted sources (1.0.2 backport) (#8580)
Backport of #8573 (main commit 7a32ca2adb) to release/1.0.2. The five touched files were identical between release/1.0.2 and the main base the fix was written against, so this applied without conflicts.

New encrypted writes store a keyed HMAC fingerprint as the object ETag
(docs/operations/encrypted-etags.md). Replication forwards that ETag on
x-rustfs-source-etag, but a target that computes its own ETag (AWS S3 and
most compatible stores) answers the plaintext PUT with the MD5 of what it
received. Both values are 32 hex characters, so verify_single_part_replica
treated the fingerprint as a comparable MD5 and failed every single-part
SSE-S3 / SSE-KMS replica with "replica etag mismatch"; MRF and resync then
retried the object forever. RustFS and MinIO targets adopt the source ETag
and were unaffected.

Exempt sources whose stored ETag cannot describe the wire bytes: any
managed-SSE or SSE-C source, decided by the existing replication
source-encryption classifier. The checksum header on the replication PUT
remains the integrity check for those objects; plaintext sources keep the
audit unchanged.

Claude-Session: https://claude.ai/code/session_01CezCdjE9AdShFUi9tNk797


(cherry picked from commit 7a32ca2adb)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 22:47:37 +08:00
Chris 0882e99c32 fix(s3): make an unsupported delete participant retryable and explain it (1.0.2 backport) (#8577)
Backport of #8574 (main commit d7156290d5) to release/1.0.2.

A user-bucket delete fans out through the delete-transaction RPC. When a
participant answers Unimplemented (a peer still running a release without
that RPC, or a protocol-version mismatch) the coordinator reports
StorageError::UnsupportedDisk, which had no S3 mapping: clients received a
500 InternalError whose message was the variant's Display text,
"drive does not support O_DIRECT". That text describes the startup drive
probe and sends an operator mid-upgrade after the wrong cause.

Map UnsupportedDisk to 503 ServiceUnavailable with a constant message that
names the protocol-version cause and the remedy (finish upgrading every
node, then retry). The typed source is retained for diagnostics and no
Retry-After is added, since retrying the same node does not help until the
cluster upgrade completes.

Conflict resolution against release/1.0.2: the DiskFull arm and the
capacity-failure test from #8553 are not on this branch and were not
carried over; only the UnsupportedDisk mapping, its message, the mapping
table row and the new unit test are included.

(cherry picked from commit d7156290d5)


Claude-Session: https://claude.ai/code/session_01CezCdjE9AdShFUi9tNk797

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 22:01:36 +08:00
Chris 802ac8a1bd test(heal): avoid needless collect in legacy root recovery test (1.0.2 backport) (#8551)
test(heal): avoid needless collect in legacy root recovery test
2026-10-14 08:22:12 -07:00
710 changed files with 24905 additions and 179720 deletions
@@ -6,17 +6,7 @@
- Route every new sync/flush through the durability-mode and bucket override
gates; mode `none` must not pay the new fsync.
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
one small operation into many `spawn_blocking` round trips; coalesce `open`,
`read`/`write`, `fsync`, and `close` into one blocking closure per disk.
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
directory once (`openat2` on Linux) and reuse the handle across sibling reads
instead of re-walking ancestor components per file or re-opening identical
`source_parent == destination_parent` directories.
- After `fstat`/`file.metadata()`, require `Vec::with_capacity(len + 1)` before
`read_to_end` so small metadata reads do not trigger 6–7 exponential-growth
`read()` syscalls.
- Avoid redundant `store.load()` or `root_missing()` round-trips under the same
exclusive lock when the state or original bytes were just loaded or persisted.
one small operation into many `spawn_blocking` round trips.
- Measure lock hold time across I/O and compare acquisition order for ABBA.
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
section when they need not be there.
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
| 7 | [Maintain milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
## Human Confirmation
@@ -37,6 +37,6 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
@@ -1,17 +1,7 @@
# Post-release Security Advisory, Milestone, Installation, and Website Updates
# Post-release Milestone, Installation, and Website Updates
Read only after the final non-preview tag has passed Phase 6. Tag creation or a green binary build alone is insufficient: the release assets, source archive, container images, Helm package, and applicable latest channels must be available. Preview tags never enter this phase.
## Security Advisories
Reconcile advisories after every completed final release, including Console-only releases. Read-only preparation may start earlier; do not claim an unpublished artifact is an available fix.
1. Fetch every page of the repository's live Security Advisories, including triage, draft, published, and closed states. A permission error or partial response blocks reconciliation; it does not mean there are no advisories. Keep unpublished advisory details in private task artifacts, never in a public PR, release note, or issue.
2. For each potentially relevant advisory, map its fix to the exact published tag and verify the regression evidence. Main-branch reachability, a merged private fix, or a green build alone does not prove the shipped version is fixed. Check cherry-picked equivalents and reverted fixes explicitly. For embedded Console or dependency fixes, verify the shipped component version.
3. Determine the first available fixed release and affected version ranges from evidence. Preserve an earlier correct patched version; do not relabel all fixes as the current release. An unchanged server baseline such as a Console-only release requires checking the earlier server release as well. Leave unresolved reports and fixes absent from the tag pending; do not close them merely because a release shipped.
4. Prepare any necessary corrections to package identity, affected ranges, patched versions, fix/release links, and advisory text. Preserve unrelated fields, credit, CVE identifiers, and disclosure coordination. Apply updates only within existing user authorization; read back the result to verify it. Publishing a draft, requesting a CVE, or closing a report is a separate action: require explicit authorization for the concrete advisories and respect any embargo. Release-tag approval alone does not authorize those actions. Prepare the exact proposed changes before asking for missing authorization.
5. Record each relevant advisory's URL, current state, fix evidence, first fixed version, action taken or proposed, and remaining blocker in private task artifacts. Report advisory follow-up separately from artifact publication. A successful release with unresolved advisory work is not complete follow-up; retain that work for the release monitor or a concrete user decision.
## Version milestones
1. Query all pages of both open and closed milestones in the RustFS release repository, matching titles exactly to version strings without a `v` prefix. A failed or incomplete query is not evidence that a milestone is absent; resolve the lookup before making changes.
@@ -48,10 +38,3 @@ Use the existing announcement configuration from [website PR #112](https://githu
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
## Reconcile main and remove the completed release branch
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
@@ -0,0 +1,4 @@
interface:
display_name: "RustFS Release Bump"
short_description: "Prepare RustFS release branches like PR #2957."
default_prompt: "Use $rustfs-release-version-bump to prepare and verify an exact RustFS release-version bump."
@@ -36,21 +36,6 @@ changed lesson.
## Finding Standard
Every candidate names the lower-trust principal, the boundary it crosses, the
affected resource, and the observable result. A best-practice gap or an impact
confined to the caller's own principal is hardening, not a candidate.
Report each candidate under exactly one verdict:
- `confirmed`: a complete `file:line` source trace and a bounded observed
result. Only confirmed findings carry severity; each also states attacker
prerequisites, concrete input/path, impact, smallest safe fix, and a
regression check.
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
as deployment or infrastructure behavior. State that fact; assign no severity
and do not substitute an assumption.
- `rejected`: a disproved candidate, with the caller, invariant, or test that
disproves it.
Do not exaggerate unauthenticated impact when the actual issue requires
authenticated low privilege.
Each finding includes severity, `file:line`, attacker prerequisites, concrete
input/path, impact, smallest safe fix, and a regression check. Do not exaggerate
unauthenticated impact when the actual issue requires authenticated low privilege.
@@ -0,0 +1,4 @@
interface:
display_name: "Security Advisory Lessons"
short_description: "Apply advisory lessons in reviews."
default_prompt: "Use $security-advisory-lessons for a dedicated RustFS security review grounded in past advisories."
+5
View File
@@ -0,0 +1,5 @@
# Bound individual tool outputs retained in context; retrieve relevant ranges
# from task-owned log files when more evidence is needed.
# https://learn.chatgpt.com/docs/config-file/config-reference
tool_output_token_limit = 4000
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
sha256-darwin=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
sha256-darwin=1520cd3027c3a6924afc6a204eddd5475bc30f0109d4c2847456a66177263244
sha256-linux=26155b7f1c5bf6bf596ff24a4136e6767ea4ee6461c32db7fbe1400791da2c28
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
+1 -1
View File
@@ -1 +1 @@
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
sha256=ba8f392b617cf9f2338142233a417e43927b28096635e628233ce8a60cf49d10
+6 -1
View File
@@ -29,7 +29,7 @@
{
"invariant": "list-uncommitted-version",
"suite": "rustfs-filemeta",
"name": "metacache::tests::resolve_merge_keeps_the_newest_version_at_listing_quorum"
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
},
{
"invariant": "minio-object-fixture",
@@ -241,6 +241,11 @@
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
},
{
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
"suite": "rustfs-ecstore",
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
},
{
"invariant": "borrowed-context-boundary",
"suite": "rustfs-ecstore",
+1 -1
View File
@@ -27,6 +27,7 @@ script-tests: ## Run shell script tests
trap 'status=$$?; printf "ERROR: script-tests failed (exit %s): %s\n" "$$status" "$$BASH_COMMAND" >&2; exit "$$status"' ERR
./scripts/test_build_rustfs_options.sh
./scripts/test_docker_runtime_timezone.sh
sh ./scripts/test_ca_bundle.sh
./scripts/test_entrypoint_credentials.sh
./scripts/test_internode_grpc_ab_bench.sh
./scripts/test_object_batch_bench_enhanced.sh
@@ -52,7 +53,6 @@ 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_inspect_btf_prototypes.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
+4 -78
View File
@@ -68,17 +68,6 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
# Keep each test's internal concurrency and zero-retry policy unchanged.
[[profile.default.overrides]]
filter = """
package(rustfs-ecstore) & (
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
)
"""
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
threads-required = "num-test-threads"
@@ -87,36 +76,12 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
threads-required = "num-test-threads"
# These real-disk lock probes must finish durable IO within production's 5s
# acquisition budget. Reserve capacity from sibling test processes while
# preserving the probes' internal concurrency, deadlines, and assertions.
[[profile.default.overrides]]
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
# These eight-disk decommission fixtures exercise concurrent retries,
# free-version cleanup, and real capacity probes under production lock deadlines.
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
threads-required = "num-test-threads"
# This registration/backup lock probe uses an isolated eight-disk store.
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
# registration budget; retain the queued backup and deadlock assertions.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
# Reserve capacity across test processes while preserving each scenario's fixed
# budget, assertions, and internal concurrency.
[[profile.default.overrides]]
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
threads-required = "num-test-threads"
[[profile.default.scripts]]
@@ -124,11 +89,8 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
# The admin target-repair fixtures compose the full bucket delete and recreate
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
[[profile.default.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
filter = '(package(rustfs) & test(/^(admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
@@ -362,17 +324,6 @@ path = "junit.xml"
# Match the local durable-progress reservations. All members, deadlines,
# assertions, and internal concurrency remain enabled without retries.
[[profile.ci.overrides]]
filter = """
package(rustfs-ecstore) & (
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
)
"""
threads-required = "num-test-threads"
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
threads-required = "num-test-threads"
@@ -381,36 +332,12 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
threads-required = "num-test-threads"
# These real-disk lock probes must finish durable IO within production's 5s
# acquisition budget. Reserve capacity from sibling test processes while
# preserving the probes' internal concurrency, deadlines, and assertions.
[[profile.ci.overrides]]
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
# These eight-disk decommission fixtures exercise concurrent retries,
# free-version cleanup, and real capacity probes under production lock deadlines.
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
threads-required = "num-test-threads"
# This registration/backup lock probe uses an isolated eight-disk store.
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
# registration budget; retain the queued backup and deadlock assertions.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
threads-required = "num-test-threads"
[[profile.ci.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
# Reserve capacity across test processes while preserving each scenario's fixed
# budget, assertions, and internal concurrency.
[[profile.ci.overrides]]
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
threads-required = "num-test-threads"
[[profile.ci.scripts]]
@@ -419,7 +346,7 @@ setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
[[profile.ci.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
filter = '(package(rustfs) & test(/^(admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
@@ -678,7 +605,6 @@ default-filter = """
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
| 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(/^conditional_delete_objects_test::/)
| 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)$/)
-5
View File
@@ -23,11 +23,6 @@ duplicate its rules here.
pass by fixing the cause, not by weakening the gate).
- If `shellcheck` is installed, actionlint also lints `run:` scripts; treat
those findings as part of the gate.
- Runner placement and billing rules live in `.github/workflows/README.md`.
A new job defaults to `ubuntu-latest` (free for this public repository);
a self-hosted `runs-on` needs one of the reasons listed there, stated in a
comment next to it, and a `rust-cache` key is never shared between the
hosted and self-hosted images.
## CI Alignment
@@ -74,6 +74,7 @@ Notes:
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
+2 -1
View File
@@ -1,6 +1,6 @@
[
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
@@ -18,6 +18,7 @@
"never_ran_grace_until": "2026-09-08T00:00:00Z"
},
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
{
"workflow": ".github/workflows/runner-hygiene.yml",
"max_age_hours": 792,
-74
View File
@@ -1,74 +0,0 @@
# Workflow runners: what is billed and where a job belongs
**Use this when:** adding a job, changing a `runs-on`, moving a lane between runner pools, or deciding whether a new check is worth its runner cost.
**Source of truth:** `runs-on` in each workflow file for current placement; `.github/actionlint.yaml` for the declared self-hosted labels; the organisation's Settings → Billing → Usage page for what is actually charged; `docs/testing/ci-gates.md` for which checks block a merge (that does not change when a runner changes).
## Billing facts
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
## Placement rule
A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason goes in a comment next to its `runs-on`:
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
## Cache rule
`Swatinem/rust-cache` builds its key from the OS, architecture, toolchain and lockfile. It does not include the runner label or image, so a cache written on an `sm-standard-4` pod and one written on `ubuntu-latest` would overwrite each other under the same `shared-key`.
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
These are the patterns that have cost real money here without validating anything more:
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
## Measuring a run
To see which pool each job of a run used and for how long:
```bash
gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
- `runs-on` is a literal label. A new self-hosted label is declared in `.github/actionlint.yaml` in the same change (`.github/AGENTS.md`).
- The job declares `timeout-minutes` (`scripts/security/check_job_timeouts.sh`).
- `actionlint` reports nothing.
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
+9 -4
View File
@@ -29,6 +29,7 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -47,6 +48,7 @@ on:
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
@@ -75,7 +77,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -87,7 +89,7 @@ jobs:
cargo-deny:
name: Cargo Deny
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -130,7 +132,7 @@ jobs:
workflow-pin-report:
name: Workflow Pin Report
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 5
steps:
- name: Checkout repository
@@ -153,6 +155,9 @@ jobs:
- name: Check preview release workflow policy
run: ./scripts/security/check_preview_release_workflow.sh
- name: Check performance A/B workflow trust boundary
run: ./scripts/security/check_performance_ab_workflow.sh
- name: Check tier evidence workflow isolation
run: ./scripts/security/check_tier_artifact_workflow.sh
@@ -164,7 +169,7 @@ jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
if: github.event_name == 'pull_request' && github.event.action != 'closed'
permissions:
@@ -1,70 +0,0 @@
name: BTF prototype reference
on:
pull_request:
paths:
- "scripts/inspect_btf_prototypes.py"
- "scripts/test_inspect_btf_prototypes.py"
- "scripts/fixtures/btf_prototypes.c"
- ".github/workflows/btf-prototype-reference.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: btf-prototype-reference-${{ github.ref }}
cancel-in-progress: true
jobs:
reference:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Check prototype and CLI contracts
run: python3 -B scripts/test_inspect_btf_prototypes.py
- name: Install only missing native reference tools
run: |
select_bpftool() {
local candidate
local candidates=()
if command -v bpftool >/dev/null; then
candidates+=("$(command -v bpftool)")
fi
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
if [[ -x "$candidate" ]]; then
candidates+=("$candidate")
fi
done
while IFS= read -r candidate; do
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
&& "$candidate" version >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
return 1
}
packages=()
command -v clang >/dev/null || packages+=(clang)
command -v llvm-objcopy >/dev/null || packages+=(llvm)
select_bpftool >/dev/null || packages+=(linux-tools-generic)
if (( ${#packages[@]} )); then
sudo apt-get update
sudo apt-get install --no-install-recommends -y "${packages[@]}"
fi
# File parsing needs a working binary, not a host-kernel wrapper.
selected_bpftool="$(select_bpftool)"
"$selected_bpftool" version
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
- name: Compare compiled declarations with bpftool
run: |
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf"
+55 -31
View File
@@ -22,12 +22,9 @@
# 3. Trigger docker.yml to build and push images using the uploaded binaries
#
# Platform scope:
# - Development builds come from the nightly schedule and manual dispatch and
# cover the full platform matrix; they feed the dev download channel
# (artifacts/rustfs/dev on R2, the *-main-latest packages). Tags build the
# release matrix. Main pushes do not build: at the usual merge rate most
# per-merge builds were cancelled by the next merge before publishing
# anything, and the dev channel does not need per-merge granularity.
# - Pushes to main (development builds) build the Linux targets only — they
# feed the dev download channel and Docker dev images. Tags, the weekly
# schedule and manual dispatch build the full platform matrix.
#
# Manual Parameters:
# - publish: Publish binaries/releases for manual runs (default: false)
@@ -39,8 +36,24 @@ name: Build and Release
on:
push:
tags: [ "*.*.*" ]
branches: [ main ]
paths-ignore:
- "**.md"
- "**.txt"
- ".github/**"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "LICENSE*"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".gitignore"
- ".dockerignore"
- "flake.lock"
schedule:
- cron: "13 1 * * *" # Nightly 01:13 UTC
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
workflow_dispatch:
inputs:
publish:
@@ -80,7 +93,7 @@ jobs:
# Build strategy check - determine build type based on trigger
build-check:
name: Build Strategy Check
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -131,6 +144,12 @@ jobs:
build_type="release"
echo "📦 Release build detected: $tag_name"
fi
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
# Main branch push - development build
should_build=true
build_type="development"
version="dev-${short_sha}"
echo "🛠️ Development build detected"
elif [[ "${{ github.event_name }}" == "schedule" ]] || \
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Scheduled or manual build
@@ -159,7 +178,7 @@ jobs:
prepare-platform-matrix:
name: Prepare Platform Matrix
needs: build-check
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
outputs:
matrix: ${{ steps.select.outputs.matrix }}
@@ -181,18 +200,23 @@ jobs:
selected="all"
fi
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
# so the packaged binary and console smoke execute the artifact.
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
# pins the glibc 2.31 floor on the same architecture; musl stays a
# native cargo build.
# Per-merge development builds only feed the dev download channel
# and the Docker dev images, which consume the Linux binaries; at
# the usual merge cadence most per-merge builds are cancelled by the
# next merge anyway. macOS and Windows stay covered by tag builds,
# the weekly scheduled build and manual dispatch.
if [[ "${selected}" == "all" \
&& "${{ github.event_name }}" == "push" \
&& "${{ needs.build-check.outputs.build_type }}" == "development" ]]; then
selected="linux-x86_64-musl,linux-aarch64-musl,linux-x86_64-gnu,linux-aarch64-gnu"
echo "Development (main push) build: restricting to Linux targets"
fi
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
]}'
@@ -250,11 +274,7 @@ jobs:
with:
rust-version: stable
target: ${{ matrix.target }}
# rust-cache keys include OS and architecture but not the runner image.
# The Ubicloud aarch64 legs therefore use their own suffix so they
# cannot restore a cache written on a different arm64 image.
# x86_64, macOS, and Windows keys stay unsuffixed.
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
# main only. A cache saved on refs/tags/X is scoped to that tag: no
# other tag, no main run and no PR can restore it, so every release
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
@@ -302,7 +322,8 @@ jobs:
download_console_assets() {
local curl_bin="curl"
local python_bin="python3"
local console_api="https://api.github.com/repos/rustfs/console/releases/latest"
# Preserve the reviewed 1.0.3 Console in this security-only release branch.
local console_api="https://api.github.com/repos/rustfs/console/releases/tags/v0.1.37"
local console_json="console-release.json"
local console_url
local console_sha256
@@ -339,12 +360,15 @@ jobs:
release = json.load(handle)
tag = release.get("tag_name", "") or "unknown"
if tag != "v0.1.37":
raise SystemExit("Console release does not match the security-release pin")
expected_digest = "sha256:e6ff2c7e7695de824ec4cda91d8611d1aba5d8462e6809592c92a50b23f5b9ab"
for asset in release.get("assets", []):
name = asset.get("name", "")
digest = asset.get("digest", "")
url = asset.get("browser_download_url", "")
if name.startswith("rustfs-console-") and name.endswith(".zip") and digest.startswith("sha256:") and url:
if name == "rustfs-console-v0.1.37.zip" and digest == expected_digest and url:
sha256 = digest.split(":", 1)[1]
if not re.fullmatch(r"[0-9a-fA-F]{64}", sha256):
raise SystemExit(f"console zip asset has invalid sha256 digest: {sha256}")
@@ -625,7 +649,7 @@ jobs:
LATEST_FILES="$LATEST_FILE"
fi
elif [[ "$BUILD_TYPE" == "development" ]]; then
# Development builds (nightly schedule and manual dispatch)
# Development builds (only main branch triggers development builds)
# Create main-latest version filename
# Convert from rustfs-linux-x86_64-dev-abc123 to rustfs-linux-x86_64-main-latest
MAIN_LATEST_FILE="${PACKAGE_BASENAME}-main-latest.zip"
@@ -774,9 +798,9 @@ jobs:
exit 0
fi
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
# images follow the GitHub runner image and often do. Install the
# official CLI bundle when it is missing so R2 uploads work on either.
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
# images did). Install the official CLI bundle on demand so R2 uploads
# survive a fresh runner without relying on a distro package.
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI not found on runner; installing..."
if command -v apt-get >/dev/null 2>&1; then
@@ -840,7 +864,7 @@ jobs:
name: Build Summary
needs: [ build-check, build-rustfs ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Build completion summary
@@ -1029,7 +1053,7 @@ jobs:
name: Update Latest Version
needs: [ build-check, publish-release ]
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- name: Update latest.json
+22 -31
View File
@@ -42,23 +42,26 @@
name: Cache Warm
on:
schedule:
# Every six hours; the 22:41 UTC run lands before ci.yml's nightly 00:11
# UTC matrix. A reader restores the newest entry under its key and
# rebuilds whatever its own diff changed, so a cache up to six hours old
# costs a PR the handful of crates merged since. Warming after every merge
# (fourteen completed runs a day, about two sm-standard-4 hours each)
# spent far more than that to avoid it.
- cron: "41 4,10,16,22 * * *"
push:
branches: [ main ]
# A toolchain or lockfile change invalidates most of every key; those
# re-warm at once instead of waiting for the next slot.
paths:
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/actions/setup/**"
- ".github/workflows/cache-warm.yml"
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
# compiles, it cannot change what ci.yml needs restored either.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
workflow_dispatch:
inputs:
emit_timings:
@@ -72,7 +75,7 @@ on:
permissions:
contents: read
# Scoped by event. Scheduled, push and dispatch runs do not compete: GitHub keeps
# Scoped by event. A push run and a dispatch run do not compete: GitHub keeps
# one running plus one pending per group, so with a single shared group a
# manually dispatched run was displaced as pending by the next merge and
# cancelled — observed three times in a row, which made the --timings gate in
@@ -159,17 +162,6 @@ jobs:
cargo build --workspace --all-targets
cargo build -p rustfs --bins --features e2e-test-hooks
# ci.yml runs Clippy on every pull request. Clippy's check-mode artifacts
# are separate from the build above, so warm them with the exact
# invocation ci.yml uses; a pull request then re-checks only what its diff
# touches. A lint failure on main must not stop the warm-up: the cache is
# still saved (cache-on-failure), and the failure belongs to ci.yml.
- name: Warm Clippy check artifacts
run: |
if ! cargo clippy --all-targets -- -D warnings; then
echo "::warning::cargo clippy reports findings on this commit; check artifacts are still cached"
fi
# Runs before rust-cache's post step, so these are the sizes it is about
# to archive. Reported so the cache-all-crates decision stays evidence-led:
# registry/src is what that flag prunes, registry/cache is what the pruned
@@ -193,10 +185,9 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Readers: test-and-lint-rio-v2 (nightly schedule, merge group, dispatch),
# build-rustfs-debug-binary-rio-v2 (nightly schedule / manual dispatch only —
# dormant rio-v2 variant, see rustfs/backlog#1835 and
# docs/architecture/minio-file-format-compat.md).
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
# The second build below stays despite the reduced cadence: it warms the
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
# which keeps that lane inside its 30-minute timeout.
+107 -94
View File
@@ -42,7 +42,7 @@ on:
merge_group:
types: [ checks_requested ]
schedule:
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
workflow_dispatch:
permissions:
@@ -62,15 +62,10 @@ env:
jobs:
# Compile-free lanes, and lanes that only run a prebuilt binary, use
# GitHub-hosted runners: they are free for this public repository, and the
# self-hosted pool is reserved for the lanes that need its CPU count and
# disk (the workspace test build leaves a ~64GB target/, hosted runners
# have 14GB). Placement and billing rules: README.md in this directory.
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -79,7 +74,7 @@ jobs:
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
@@ -105,12 +100,16 @@ jobs:
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install typos download dependency
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends wget
- name: Typos check with custom config file
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
@@ -118,7 +117,7 @@ jobs:
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -169,13 +168,10 @@ jobs:
echo "started_at=$(date --utc --iso-8601=seconds)"
} > artifacts/test-and-lint/run-metadata.txt
# Clippy gates every event, pull requests included. When it ran only after
# merge, a lint regression merged freely and then failed every main run
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
# writes the same invocation's check artifacts into ci-dev, so a pull
# request re-checks only the crates its diff touches. Main keeps the step
# because pull requests can merge without being up to date with main.
# Keep full Clippy validation on main, merge groups, and scheduled/manual
# runs. PRs retain the complete default test suite and compile-free checks.
- name: Run clippy lints
if: github.event_name != 'pull_request'
run: |
./scripts/ci/resource_sampler.sh start clippy
trap './scripts/ci/resource_sampler.sh stop' EXIT
@@ -183,27 +179,31 @@ jobs:
- name: Run nextest tests
env:
# #5394 mitigation, settled by the backlog#1601 experiment.
# #5394 mitigation, now under a measured experiment (backlog#1601).
#
# 2 was chosen when three concurrent workspace test links were believed
# to saturate the runner's overlay I/O and wedge Cargo until the 75m
# timeout. cgroup v2 readings from the sampler show the pod actually
# has 14 CPUs and 28GB (peak use 2.1GB): the label name "sm-standard-4"
# had led everyone, including the original mitigation, to assume 4
# cores, and memory was never the constraint.
# has 14 CPUs and 28GB (peak use 2.1GB), so 2 throttles compilation to
# a seventh of what is available and memory was never the constraint —
# the label name "sm-standard-4" had led everyone, including the
# original mitigation, to assume 4 cores.
#
# Measured on the same ci-dev cache, the test-profile compile took
# 28m21s at 2 on a pull request and 16-23 min at 3 across three
# successful main runs. The first pull-request run at 3 that met a
# busy node (run 39193827281) then hit the 75m timeout before any
# test started: the sampler shows cargo spinning for 70 minutes with
# the pod's io.pressure "full" at 86-88% and CPU pressure at zero,
# while node-level IO pressure was already 36% from co-tenants when
# the step began. The constraint is the pod's shared storage, not
# CPU or memory, and the limit that storage sustains on a contended
# node is 2. Main pushes and dispatches keep 3 (rarer, and three
# successes); pull requests stay at 2 until the pods get local disk
# for target/, which is the lever that would unlock the 14 cores.
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
# merge path is untouched while the experiment runs.
#
# 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
# criterion is that ratio dropping at least 10% (below ~1.76) with no
# 75m timeout and no run showing three consecutive samples of
# rustc/collect2/rust-lld in D state. If it does not, the conclusion is
# "this limit is not the bottleneck" — fix it back at 2 and record the
# experiment, which is a result, not a failure.
#
# Must stay step-level: rust-cache hashes CARGO/CC/CFLAGS/CXX/CMAKE/RUST
# prefixed variables from process.env into the cache key, so promoting
@@ -323,12 +323,10 @@ jobs:
path: artifacts/ilm-integration
retention-days: 3
# Both Connect boundaries rebuild only the rustfs crate on top of the restored
# ci-dev cache: the two e2e features are empty, so every dependency keeps its
# cached dev-profile artifact and each feature set recompiles rustfs once.
# The budget covers a ci-dev miss, where the first step builds the whole graph.
connect-feature-boundaries:
name: Connect Feature Boundaries
# 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.
offline-enrollment-e2e:
name: Offline Enrollment E2E Root Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
@@ -353,58 +351,14 @@ jobs:
- name: Protect Connect test home
run: chmod go-w "$(realpath "$HOME")"
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
# The guard in rustfs/src/connect/registration.rs is cfg(not(debug_assertions)),
# and it must be tripped by a profile that turns debug assertions off for
# the whole graph. Handing `-C debug-assertions=off` to the rustfs lib alone
# through `cargo rustc` leaves its dependencies on the dev-profile cfg, so
# the lib also fails on items gated on one side only (E0599 on
# Filenames::Embedded) and "fails solely at the compile_error" can never
# hold. `cargo check --release` is metadata-only and needs no
# link-concurrency limit; it is cold because ci-dev holds no release
# artifacts.
- name: Reject short credentials in release builds
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
- name: Check offline enrollment E2E root boundary
run: ./scripts/check_offline_enrollment_e2e.sh
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
# Alternate feature configurations run on the nightly schedule, merge
# groups and manual dispatch. The default workspace remains the PR gate and
# the per-push main baseline: repeating four feature builds on every merge
# bought nothing the nightly run does not report within a day.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
# Alternate feature configurations run on main, merge groups, and scheduled
# or manual validation; the default workspace remains the PR gate.
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -460,10 +414,67 @@ jobs:
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')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
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:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
- name: Reject short credentials in release builds
env:
CARGO_BUILD_JOBS: "2"
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "release feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
# Same cadence as test-and-lint-rio-v2 above.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -619,7 +630,7 @@ jobs:
build-rustfs-debug-binary-rio-v2:
name: Build RustFS Debug Binary (rio-v2)
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
# default build, so this full-suite lane runs only on the nightly schedule
# default build, so this full-suite lane runs only on the weekly schedule
# and manual dispatch. Per-PR cfg-seam coverage stays with
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
@@ -1089,7 +1100,7 @@ jobs:
s3-implemented-tests:
name: S3 Implemented Tests
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: ubuntu-latest
runs-on: sm-standard-4
timeout-minutes: 60
steps:
- name: Checkout repository
@@ -1158,12 +1169,12 @@ jobs:
name: S3 Lifecycle Behavior Tests
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
# suite is already red this lane cannot tell us anything new, and it holds a
# runner for up to 30 minutes doing so. Both lanes only download the
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
# already waits on e2e-tests — finishes later anyway, so a green PR's total
# wall clock is unchanged.
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: ubuntu-latest
runs-on: sm-standard-4
timeout-minutes: 30
steps:
- name: Checkout repository
@@ -1220,8 +1231,9 @@ jobs:
- typos
- quick-checks
- test-and-lint
- connect-feature-boundaries
- offline-enrollment-e2e
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
@@ -1231,7 +1243,7 @@ jobs:
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -1247,11 +1259,12 @@ jobs:
name: Alert on scheduled failure
needs:
- classify-changes
- connect-feature-boundaries
- connect-short-credential-boundary
- required-checks
- typos
- quick-checks
- test-and-lint
- offline-enrollment-e2e
- test-and-lint-rio-v2
- test-and-lint-protocols
- build-rustfs-debug-binary
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
# Echoes one line; the run exists only so the concurrency group cancels the
# in-flight run of a closed PR.
permissions: {}
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -61,7 +61,7 @@ jobs:
checks: write
issues: write
pull-requests: write
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- name: Report CLA result for merge queue
+1 -1
View File
@@ -45,7 +45,7 @@ on:
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
# and e2e-replication-nightly (04:00) lanes.
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
- cron: "43 7 * * 0"
concurrency:
+3 -3
View File
@@ -83,7 +83,7 @@ jobs:
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main')
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -501,7 +501,7 @@ jobs:
name: Scan Docker Images
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
permissions:
contents: read
@@ -556,7 +556,7 @@ jobs:
name: Docker Build Summary
needs: [ build-check, build-docker ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Docker build completion summary
+3 -3
View File
@@ -153,7 +153,7 @@ jobs:
- name: Build rustfs binary
run: |
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py build --bins
- name: Verify distributed e2e membership
env:
@@ -170,9 +170,9 @@ jobs:
set -euo pipefail
rm -f target/nextest/e2e-distributed/junit.xml
if [ -n "${FILTER}" ]; then
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
else
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
test -s target/nextest/e2e-distributed/junit.xml
@@ -38,8 +38,8 @@ name: e2e-nightly
on:
workflow_dispatch:
schedule:
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
# and stale (01:30).
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
# stale (01:30) and performance-ab (06:00).
- cron: "29 4 * * *"
# Only alert-on-failure needs more than read access; it declares its own
+1 -1
View File
@@ -371,7 +371,7 @@ jobs:
name: Upstream HEAD classification canary
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
continue-on-error: true
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 20
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
+24 -58
View File
@@ -19,15 +19,12 @@ on:
paths:
- ".github/workflows/e2e-upgrade.yml"
- "crates/e2e_test/src/common.rs"
- "crates/e2e_test/src/distributed/**"
- "crates/e2e_test/src/fake_s3_target/**"
- "crates/e2e_test/src/lib.rs"
- "crates/e2e_test/src/replication_extension_test.rs"
- "crates/e2e_test/src/rpc_fault_proxy.rs"
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/heal/**"
- "crates/kms/**"
- "crates/storage-api/**"
- "rustfs/**"
@@ -49,11 +46,14 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
build:
name: Build upgrade candidate
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -66,11 +66,19 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-server-hosted
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
@@ -81,31 +89,19 @@ jobs:
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: Upgrade compatibility (${{ matrix.source.version }})
name: Upgrade compatibility
needs: build
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source:
- version: 1.0.0-rc.5
asset: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
sha256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
- version: 1.0.1
asset: rustfs-linux-x86_64-gnu-v1.0.1.zip
sha256: 9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
UPGRADE_SOURCE_VERSION: ${{ matrix.source.version }}
UPGRADE_SOURCE_ASSET: ${{ matrix.source.asset }}
UPGRADE_SOURCE_SHA256: ${{ matrix.source.sha256 }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -115,7 +111,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-tests-hosted
cache-shared-key: e2e-upgrade-tests
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
@@ -130,15 +126,12 @@ jobs:
run: chmod +x target/debug/rustfs
- name: Verify and unpack pinned previous release
id: verify_source
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/rustfs/rustfs/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
@@ -155,19 +148,14 @@ jobs:
run: |
export no_proxy="$NO_PROXY"
cases=(
direct-upgrade:direct_upgrade_from_previous_release_preserves_object_contracts
mixed-version-upgrade:rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
direct-upgrade:direct_upgrade_from_rc2_preserves_object_contracts
mixed-version-upgrade:rolling_upgrade_from_rc2_preserves_mixed_version_contracts
bucket-config-upgrade:direct_upgrade_from_previous_release_preserves_bucket_configuration
bucket-config-rollback:rollback_to_previous_release_reads_current_bucket_metadata
multipart-layout-upgrade:direct_upgrade_from_previous_release_preserves_multipart_layouts
multipart-layout-baseline:previous_release_baseline_replicates_multipart_layouts
odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration
multipart-layout-upgrade:direct_upgrade_from_rc5_preserves_multipart_layouts
multipart-layout-baseline:rc5_baseline_replicates_multipart_layouts
)
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
cases+=(checkpoint-upgrade:rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects)
else
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
fi
status=0
for entry in "${cases[@]}"; do
artifact="${entry%%:*}"
@@ -188,33 +176,11 @@ jobs:
done
exit "$status"
- name: Run mixed-version MPU recovery
if: ${{ !cancelled() && steps.verify_source.outcome == 'success' }}
timeout-minutes: 15
env:
NO_PROXY: 127.0.0.1,localhost,::1
HTTP_PROXY: ""
HTTPS_PROXY: ""
ALL_PROXY: ""
RUSTFS_E2E_LOG_FILTER: "warn,rustfs_ecstore::cluster::rpc::client=debug"
run: |
set -euo pipefail
export no_proxy="$NO_PROXY"
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/mpu-mixed-recovery"
mkdir -p "$RUSTFS_E2E_LOG_DIR"
mpu_test='distributed::multipart_fence_acceptance_test::mixed_version_failed_complete_requires_current_coordinator_for_abort'
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
"$mpu_test" -- --list --exact | tee "$RUSTFS_E2E_LOG_DIR/list.log"
test "$(grep -Fxc "${mpu_test}: test" "$RUSTFS_E2E_LOG_DIR/list.log")" -eq 1
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
"$mpu_test" -- --exact --nocapture --test-threads=1 \
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-g09-evidence-${{ matrix.source.version }}-${{ github.run_number }}
name: upgrade-g09-evidence-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence
if-no-files-found: ignore
retention-days: 14
@@ -223,7 +189,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-server-logs-${{ matrix.source.version }}-${{ github.run_number }}
name: upgrade-server-logs-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-logs
if-no-files-found: warn
retention-days: 14
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
+65 -31
View File
@@ -6,20 +6,29 @@ name: Gateway Integration Sync
# Merges `main` into the long-lived `gateway/integration` branch every weekday
# so the s3s -> gateway migration (rustfs/backlog#2734, task T0.1 in #2736)
# never accumulates merge debt. A clean merge lands as a `--no-ff` merge commit
# pushed straight to the branch. A conflicting merge is aborted and fails the
# run after its summary records the source and target SHAs, run URL, and exact
# conflicting paths. It creates no sync branch or pull request. Resolve the
# merge in an isolated worktree based on the latest target, preserve the
# two-parent merge commit, and push only after the required checks pass and the
# target still matches the merge's first parent. If the target moves, restart
# from its new head.
# pushed straight to the branch. A conflicting merge is aborted and surfaced as
# a pull request from `sync/<source>-<date>`, whose head is the un-merged
# source commit, so the conflict is resolved the same day, in review, with no
# other change riding along. The run itself stays green in that case: the pull
# request is the signal. Any other failure (push rejected, pull request not
# created) fails the run and, on the schedule, files the shared failure issue.
#
# Write scope: only `gateway/integration` is ever pushed. The push uses a
# one-hour installation token of the organisation's GitHub App (contents:
# write) because that app is the only non-admin bypass actor of the
# Resolving a conflict pull request: merge `origin/gateway/integration` into
# the sync branch, resolve, push, get the review. Land it with a fast-forward
# push of the resolved merge commit to `gateway/integration`, not with the
# squash button: a squash drops the merge ancestry and the same hunks conflict
# again on the next run.
#
# Write scope: only `gateway/integration` and `sync/*` are ever pushed, and
# both push targets below are literal refs. The pushes use a one-hour
# installation token of the organisation's own GitHub App (contents: write)
# because that app is the only non-admin bypass actor of the
# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor,
# so it cannot push through the required reviews and checks. GITHUB_TOKEN is
# read-only here.
# so it cannot push through the required reviews and checks. The conflict pull
# request is opened with FLAKE_UPDATE_TOKEN, the PAT that already opens the
# weekly flake.lock pull requests, because this repository keeps "Allow GitHub
# Actions to create and approve pull requests" switched off. GITHUB_TOKEN is
# therefore read-only here.
#
# `schedule` fires only from the default branch, so the cron is live once this
# file exists on `main`. `workflow_dispatch` runs from any ref that carries the
@@ -85,7 +94,6 @@ jobs:
id: merge
env:
SOURCE_REF: ${{ inputs.source_ref || 'main' }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
@@ -111,25 +119,13 @@ jobs:
fi
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
if [[ -z "$conflicts" ]]; then
git merge --abort || true
git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt"
git merge --abort
if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then
echo "::error::git merge failed without reporting conflicting paths; this is not a conflict"
exit 1
fi
{
echo "## Gateway integration sync stopped on conflicts"
echo
echo "- Run: ${RUN_URL}"
echo "- Source: \`${SOURCE_REF}\` at ${source_sha}"
echo "- Target: \`${TARGET_BRANCH}\` at ${target_sha}"
echo
echo "Conflicting files:"
printf '%s\n' "$conflicts" | sed 's/^/- /'
} >> "$GITHUB_STEP_SUMMARY"
git merge --abort
echo "::error::${SOURCE_REF} cannot be merged cleanly into ${TARGET_BRANCH}; no branch or pull request was created"
exit 1
echo "result=conflict" >> "$GITHUB_OUTPUT"
- name: Push the merge commit
if: steps.merge.outputs.result == 'merged'
@@ -141,12 +137,50 @@ jobs:
git push origin HEAD:refs/heads/gateway/integration
echo "Merged \`${SOURCE_REF}\` (${SOURCE_SHA}) into \`${TARGET_BRANCH}\` as $(git rev-parse HEAD)." >> "$GITHUB_STEP_SUMMARY"
- name: Open the conflict pull request
if: steps.merge.outputs.result == 'conflict'
env:
GH_TOKEN: ${{ secrets.FLAKE_UPDATE_TOKEN }}
SOURCE_REF: ${{ steps.merge.outputs.source_ref }}
SOURCE_SHA: ${{ steps.merge.outputs.source_sha }}
TARGET_SHA: ${{ steps.merge.outputs.target_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
date_utc=$(date -u +%Y-%m-%d)
sync_branch="sync/${SOURCE_REF//\//-}-${date_utc}"
git push origin "refs/remotes/origin/${SOURCE_REF}:refs/heads/${sync_branch}"
{
echo "The scheduled merge of \`${SOURCE_REF}\` into \`${TARGET_BRANCH}\` stopped on conflicts and was aborted; \`${TARGET_BRANCH}\` is unchanged."
echo
echo "This branch is the un-merged \`${SOURCE_REF}\` head. Merge \`origin/${TARGET_BRANCH}\` into it, resolve the files below, push, and get the review. Land the resolved merge commit with a fast-forward push to \`${TARGET_BRANCH}\`; a squash merge drops the merge ancestry and the same hunks conflict again on the next run. Do not include any change that is not conflict resolution."
echo
echo "- Run: ${RUN_URL}"
echo "- Source: \`${SOURCE_REF}\` at ${SOURCE_SHA}"
echo "- Target: \`${TARGET_BRANCH}\` at ${TARGET_SHA}"
echo
echo "## Conflicting files"
echo
echo "Output of \`git diff --name-only --diff-filter=U\` captured before \`git merge --abort\`:"
echo
sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt"
} > "$RUNNER_TEMP/pr-body.md"
title="sync: merge ${SOURCE_REF} ${date_utc} (conflicts)"
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --state open --json number --jq '.[0].number // empty')
if [[ -n "$existing" ]]; then
gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
pr_url=$(gh pr view "$existing" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
else
pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md")
fi
echo "Conflicts in \`${SOURCE_REF}\` -> \`${TARGET_BRANCH}\`; pull request: ${pr_url}" >> "$GITHUB_STEP_SUMMARY"
alert-on-failure:
name: Alert on scheduled failure
needs: [sync]
# Same ci-8 mechanism audit.yml and coverage.yml use: a scheduled run that
# could not push or encountered merge conflicts must not stay visible only
# in the Actions tab.
# could not push or could not open the conflict pull request must not stay
# visible only in the Actions tab.
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
runs-on: ubuntu-latest
timeout-minutes: 10
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
build-helm-package:
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
if: |
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
@@ -166,7 +166,7 @@ jobs:
retention-days: 1
publish-helm-package:
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
needs: [ build-helm-package ]
if: needs.build-helm-package.result == 'success'
+1 -1
View File
@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve nightly source
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
update-flake:
if: github.repository == 'rustfs/rustfs'
name: Update flake.lock
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -186,7 +186,7 @@ jobs:
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 45
strategy:
fail-fast: false
+2 -4
View File
@@ -618,10 +618,8 @@ jobs:
summary:
name: Summary
needs: [ resolve, package ]
# Every completed Build and Release run triggers this workflow; when
# resolve declined it (not a release tag), there is nothing to summarise.
if: always() && needs.resolve.result != 'skipped'
runs-on: ubuntu-latest
if: always()
runs-on: sm-standard-2
timeout-minutes: 5
steps:
- name: Print summary
+384
View File
@@ -0,0 +1,384 @@
# 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.
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
#
# Two entry points, honestly scoped:
# * schedule (nightly, on main): post-merge detection — catches a regression
# within 24h of landing, not before merge.
# * workflow_dispatch: an explicitly selected trusted ref.
# The dispatch input can run the gate with --allow-regression so a deliberate
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
# blocked (rustfs/backlog#935 correction 1).
name: Performance A/B
on:
schedule:
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
workflow_dispatch:
inputs:
duration:
description: "warp duration per round"
required: false
default: "12s"
type: string
allow_regression:
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
required: false
default: false
type: boolean
permissions:
actions: read
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
warp-ab:
if: github.repository == 'rustfs/rustfs'
name: Warp A/B budget gate
runs-on: sm-standard-2
# A normal nightly restores the last successful binary and builds only the
# candidate; daily access keeps that cache warm. A cache miss may build both
# and needs room for the A/B run plus artifact and cache publication.
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0 # baseline may be an earlier successful scheduled head
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install warp
run: |
set -euo pipefail
WARP_VERSION="1.0.0"
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
| sudo tar -xz -C /usr/local/bin warp
warp --version
- name: Decide exemption
id: exempt
env:
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
run: |
allow="false"
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
allow="true"
fi
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
# A failed regression run must keep comparing against the last known-good
# scheduled head. Otherwise the next nightly would absorb the regression
# into its baseline and turn green without a fix.
- name: Find last successful scheduled baseline
id: scheduled_baseline
if: github.event_name == 'schedule'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
result-encoding: string
script: |
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "performance-ab.yml",
event: "schedule",
status: "success",
per_page: 1,
});
return data.workflow_runs[0]?.head_sha ?? "";
# Manual runs compare a selected ref with current main. Scheduled runs
# compare current main with the last successful scheduled head. With no
# history, the first run measures the candidate against itself and seeds
# that head only if the complete rig succeeds.
- name: Resolve baseline / candidate commits
id: commits
env:
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
run: |
set -euo pipefail
candidate_sha="$(git rev-parse HEAD)"
if [[ "${{ github.event_name }}" == "schedule" ]]; then
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
else
baseline_sha="$(git rev-parse origin/main)"
fi
git cat-file -e "${baseline_sha}^{commit}"
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
exit 1
fi
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "baseline commit: $baseline_sha"
echo "candidate commit: $candidate_sha"
# Exact-key restore of the candidate binary saved by its successful
# scheduled run. A miss leaves cache-hit unset and falls back to a source
# build of that known-good head.
- name: Restore cached baseline binary
id: baseline_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
# baseline commit, a cache miss would make the rig build the same commit
# twice (~65min per side with the post-#4806 LTO profile — no job budget
# fits that). Build it once here, reuse it for both phases, and save it
# back to the cache so the next run hits.
- name: Build baseline on cache miss (same-commit self-heal)
id: selfheal
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p baseline-bin
cp target/release/rustfs baseline-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build baseline on cache miss (different candidate)
id: baseline_build
if: >-
steps.baseline_cache.outputs.cache-hit != 'true' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
mkdir -p baseline-bin
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
git worktree remove --force "$baseline_root"
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Build candidate binary
id: candidate_build
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cargo build --release --bin rustfs
mkdir -p candidate-bin
cp target/release/rustfs candidate-bin/rustfs
echo "built=true" >> "$GITHUB_OUTPUT"
- name: Save self-healed baseline to cache
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
- name: Run warp A/B and gate
id: ab
env:
INPUT_DURATION: ${{ github.event.inputs.duration }}
run: |
set -euo pipefail
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
# -> A2 baseline for each workload and drive-sync cell. It requires three
# rounds per leg to emit tail latency and error-rate evidence.
# --health-timeout 180 outlasts the server's own 120s startup-readiness
# budget, which the rig's previous 60s health poll undershot (the first
# two nightly failures). perf-6 recalibrates these once the noise study
# lands.
duration="${INPUT_DURATION:-12s}"
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
selfheal_built="${{ steps.selfheal.outputs.built }}"
baseline_built="${{ steps.baseline_build.outputs.built }}"
candidate_built="${{ steps.candidate_build.outputs.built }}"
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
chmod +x baseline-bin/rustfs
base_bin="$PWD/baseline-bin/rustfs"
args+=(--baseline-bin "$base_bin")
if [[ "$baseline_hit" == "true" ]]; then
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
elif [[ "$selfheal_built" == "true" ]]; then
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
else
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
fi
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
# No commits landed since the last successful baseline, so reuse
# the one binary for both phases and measure only rig drift.
args+=(--candidate-bin "$base_bin")
cand_src="same binary as baseline (same commit)"
elif [[ "$candidate_built" == "true" ]]; then
chmod +x candidate-bin/rustfs
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
cand_src="source build of the checked-out ref"
else
echo "::error::candidate binary was not built" >&2
exit 2
fi
else
echo "::error::baseline binary was not restored or built" >&2
exit 2
fi
echo "baseline binary: $base_src"
echo "candidate binary: $cand_src"
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
args+=(--allow-regression --exemption-reason "workflow dispatch override")
fi
# Do not let a gate FAIL abort the job here; capture status and surface
# it after the step summary is written.
set +e
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
echo "status=$?" >> "$GITHUB_OUTPUT"
set -e
# Locate the newest run dir + gate.md for the summary/comment/artifact
# steps. On a startup failure there is no gate.md, but the run dir still
# holds server-logs/ for diagnosis.
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
# shellcheck disable=SC2012
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
# shellcheck disable=SC2012
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
- name: Upload A/B results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: hotpath-warp-ab-${{ github.run_number }}
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
# is diagnosable. Short retention: this is churny nightly debug data.
path: target/hotpath-abba/
if-no-files-found: warn
retention-days: 14
- name: Write gate summary
if: always()
run: |
set -euo pipefail
status="${{ steps.ab.outputs.status }}"
gate_md="${{ steps.ab.outputs.gate_md }}"
run_dir="${{ steps.ab.outputs.run_dir }}"
{
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
echo
if [[ "$status" == "0" ]]; then
echo "Rig/gate exit: \`0\` (pass or warn)."
else
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
fi
echo
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
cat "$gate_md"
else
echo "No \`gate.md\` produced — the rig failed **before** the gate"
echo "(most likely server startup / health). Failing phase(s) below;"
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
for f in "$run_dir"/server-logs/*.log; do
[[ -f "$f" ]] || continue
echo
echo "<details><summary>$(basename "$f")</summary>"
echo
echo '```'
tail -n 30 "$f"
echo '```'
echo "</details>"
done
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Stage successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
run: |
set -euo pipefail
cp candidate-bin/rustfs baseline-bin/rustfs
- name: Cache successful candidate baseline
if: >-
steps.ab.outputs.status == '0' &&
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: baseline-bin/rustfs
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
# Scheduled failure alerting is handled by the alert-on-failure job below
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
- name: Enforce gate
if: always()
run: |
status="${{ steps.ab.outputs.status }}"
if [[ -z "$status" ]]; then
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
exit 1
fi
if [[ "$status" != "0" ]]; then
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
exit "$status"
fi
echo "warp A/B budget gate passed."
alert-on-failure:
name: Alert on scheduled failure
needs: [warp-ab]
# `always()` is required: without it this job is skipped when a needed
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
# ci-8); manual dispatch failures are already watched by a human.
# `cancelled` is included alongside `failure` on purpose: a job that hits
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
# timeouts went silent precisely because the guard was failure-only. The
# composite action already reports cancelled/timed-out jobs in the issue
# body.
if: >-
github.repository == 'rustfs/rustfs' && (
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
@@ -279,9 +279,7 @@ jobs:
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
STEPS="$STEPS,7,8"
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
# Step 10 asserts the surviving pools after the removal
# (rustfs#8726), so it only runs together with step 9.
STEPS="$STEPS,9,10"
STEPS="$STEPS,9"
fi
fi
ARGS=(--steps "$STEPS" --with-warp -y \
@@ -295,10 +293,6 @@ jobs:
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
fi
# Finalize the removal so step 10 can exercise the surviving
# pools; passing it without step 9 selected would run step 10
# against a topology where nothing was removed.
case ",${STEPS}," in *,9,*) ARGS+=(--finalize-decommission) ;; esac
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
@@ -16,8 +16,8 @@
#
# Forces a job failure and then runs .github/actions/schedule-failure-issue
# through the exact `needs` + `always() && contains(needs.*.result,
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
# Dispatch it twice to verify both the issue-creation and
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
# performance-ab). Dispatch it twice to verify both the issue-creation and
# the dedupe-comment paths, then close the resulting
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
@@ -44,7 +44,7 @@ permissions:
jobs:
forced-failure:
name: Forced failure
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 5
steps:
- name: Fail on purpose
@@ -28,6 +28,7 @@ on:
- "mint"
- "minio-interop"
- "Nightly GNU Build"
- "Performance A/B"
- "Runner Hygiene"
types: [completed]
+1 -1
View File
@@ -27,7 +27,7 @@ on:
jobs:
stale:
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 30
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+1 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
update:
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 10
steps:
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
targets-live:
if: github.repository == 'rustfs/rustfs'
name: PostgreSQL, MySQL, AMQP, and NATS
runs-on: ubuntu-latest
runs-on: sm-standard-2
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -60,7 +60,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: targets-live-lane-hosted
cache-shared-key: targets-live-lane
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
install-build-packaging-tools: 'false'
install-test-tools: 'false'
-134
View File
@@ -19,21 +19,8 @@ on:
branches: [ main ]
paths:
- "crates/ecstore/src/disk/**"
- "crates/ecstore/src/layout/**"
- "crates/ecstore/src/store/init_format.rs"
- "crates/ecstore/src/set_disk/ops/locking.rs"
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
- "crates/utils/src/path.rs"
- "rustfs/build.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/main.rs"
- "rustfs/src/server/runtime.rs"
- "rustfs/src/startup_*.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/actions/setup/**"
@@ -42,21 +29,8 @@ on:
branches: [ main ]
paths:
- "crates/ecstore/src/disk/**"
- "crates/ecstore/src/layout/**"
- "crates/ecstore/src/store/init_format.rs"
- "crates/ecstore/src/set_disk/ops/locking.rs"
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
- "crates/utils/src/path.rs"
- "rustfs/build.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/main.rs"
- "rustfs/src/server/runtime.rs"
- "rustfs/src/startup_*.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/actions/setup/**"
@@ -103,122 +77,14 @@ jobs:
shell: pwsh
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
- name: Test multipart parent durability barriers
shell: pwsh
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
- name: Test Windows path conversion
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
- name: Test Windows volume and disk-space probes
shell: pwsh
run: |
# Map a drive letter to the temp drive's administrative share so the
# remote-volume probe also covers mapped network drives.
$drive = (Split-Path -Qualifier ([System.IO.Path]::GetTempPath())).TrimEnd(':')
net use R: "\\localhost\$drive`$" /persistent:no
if ($LASTEXITCODE -eq 0) {
$env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE = 'R:'
} else {
Write-Output "::warning::could not map R: to \\localhost\$drive`$; mapped-drive test will skip"
}
cargo test -p rustfs-utils --features os --lib os:: -- --nocapture
$status = $LASTEXITCODE
if ($env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE) { net use R: /delete /y | Out-Null }
exit $status
- name: Test Windows handle guards
shell: pwsh
run: cargo test -p rustfs-ecstore --lib windows_ -- --nocapture
- name: Test endpoint path parsing
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib layout::endpoint:: -- --nocapture"
- name: Test startup temporary-directory cleanup
shell: pwsh
run: cargo test -p rustfs-ecstore --lib cleanup_tmp_on_startup_ -- --nocapture
- name: Test object key Windows admission
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
- name: Test Windows file-name rules
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
- name: Test fresh format publication
shell: pwsh
run: cargo test -p rustfs-ecstore --lib fresh_format_load_initializes_all_disks -- --nocapture
# The native Windows main thread only gets the 1 MiB PE stack reserve, while
# Linux and macOS hand out 8 MiB. Startup runs the whole root future on the
# thread that calls `block_on`, so a debug server once overflowed before it
# listened on Windows only. Start a real server to keep that covered.
startup-smoke:
name: Startup Smoke
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: build-x86_64-pc-windows-msvc
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Build debug server binary
shell: pwsh
run: cargo build --locked -p rustfs --bin rustfs
- name: Print version
shell: pwsh
run: |
& .\target\debug\rustfs.exe --version
if ($LASTEXITCODE -ne 0) { throw "rustfs --version exited with $LASTEXITCODE" }
- name: Start server and wait for readiness
shell: pwsh
env:
RUSTFS_ACCESS_KEY: rustfsadmin
RUSTFS_SECRET_KEY: rustfsadmin
run: |
$volume = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke'
$stdout = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.out.log'
$stderr = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.err.log'
New-Item -ItemType Directory -Force -Path $volume | Out-Null
$server = Start-Process -FilePath .\target\debug\rustfs.exe `
-ArgumentList @('--address', '127.0.0.1:9000', "`"$volume`"") `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr `
-NoNewWindow -PassThru
# Cache the process handle now; otherwise ExitCode reads back empty.
$null = $server.Handle
$ready = $false
try {
for ($i = 0; $i -lt 180; $i++) {
if ($server.HasExited) { break }
try {
$response = Invoke-WebRequest -Uri 'http://127.0.0.1:9000/health/ready' -UseBasicParsing -NoProxy -TimeoutSec 2
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch {}
Start-Sleep -Seconds 1
}
} finally {
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force }
$server.WaitForExit()
Write-Host '--- server stdout (tail) ---'
Get-Content $stdout -Tail 100 -ErrorAction SilentlyContinue
Write-Host '--- server stderr (tail) ---'
Get-Content $stderr -Tail 100 -ErrorAction SilentlyContinue
}
if (-not $ready) {
throw ("rustfs did not report ready on /health/ready (exit code: {0})" -f $server.ExitCode)
}
-37
View File
@@ -3,9 +3,6 @@
This file contains repository-wide rules. Use the nearest subdirectory
`AGENTS.md` for path-specific invariants.
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
## Precedence
1. System/developer instructions.
@@ -41,27 +38,6 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
pending. Before requesting missing approval, prepare the concrete result that
is already authorized; retain explicit merge and release gates.
- Keep individual tool outputs concise. Save large outputs to task-owned logs
and retrieve only the relevant ranges.
## Common Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Task-Specific Guidance
Read only the reference needed for the current task, once per unchanged context:
@@ -89,19 +65,6 @@ or agent scratch notes. Durable architecture belongs under `docs/architecture/`,
operations under `docs/operations/`, and testing references under
`docs/testing/`. `scripts/check_no_planning_docs.sh` enforces this boundary.
## Further References
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Verification
Select checks from the final task-owned diff. Scoped `AGENTS.md` files may add a
-6
View File
@@ -19,17 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
- A `set-remote-target?update=true&creds=true` request on a site-replication peer's bucket target is now rejected with `400 InvalidRequest`, naming the target and why it counts as peer-owned. It used to return success while keeping the stored endpoint, credentials, and TLS settings, so an endpoint edit from the Console's bucket replication form appeared to apply but did not. Change a peer's address with a site-replication edit instead; see "Changing a site's address" in `docs/operations/site-replication-operations.md`. The `sync`, `bandwidth`, `proxy`, and `path` update groups are unchanged.
- Site-replication edits that omit the site name, such as `mc admin replicate update --deployment-id <ID> --endpoint <URL>`, now keep the stored name on the editing site and on every receiver instead of renaming the site after the new endpoint host. No persisted format changes.
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed
- **`mc admin info` and `mc admin replicate resync` work again**: since the cluster snapshot view (#3803), `GET /v3/info` and `GET /v3/storageinfo` returned the madmin `InfoMessage` / `StorageInfo` nested under an `info` key next to `admin_discovery` (and, for `/info`, `bitrotSelftest`). madmin decodes those bodies directly, so `mc admin info` printed empty values and `mc admin replicate resync start/status` rejected every alias with "alias provided is not part of cluster replication" because the local `deploymentID` read as empty. Requests on the MinIO-compatible `/minio/admin/v3` prefix now receive the bare madmin body again; `/rustfs/admin/v3`, which rc and the console use, keeps the envelope unchanged.
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
- **SSE-KMS encryption context could replace the location entry**: data keys are wrapped under an encryption-context entry, keyed by the bucket name, that ties them to the object's location. A client `x-amz-server-side-encryption-context` entry under the same key replaced it, and because the client context is stored with the object, the replacement also applied on every read. SSE-KMS writes now refuse such a context with `400 InvalidArgument`; other keys, including the names of other buckets, are unaffected. Objects stored with such an entry by an earlier release remain readable. See `docs/operations/kms-backend-security.md`.
- **Two admin handlers reported a missing policy or user as `500 InternalError`**: `GET /rustfs/admin/v3/info-canned-policy` and `DELETE /rustfs/admin/v3/remove-user` wrapped every IAM error as an internal fault instead of classifying it, so a lookup for something that does not exist answered `500 InternalError` where the rest of the admin API answers `404 NoSuchResource` — `GET /rustfs/admin/v3/user-info` already did, and `iam_error_to_s3_error` already mapped `NoSuchPolicy`, `NoSuchUser`, `NoSuchServiceAccount` and `NoSuchTempAccount` to `NoSuchResource`. A client could not tell "does not exist" from a real server failure, and a controller that checks existence before creating (a Kubernetes operator reconciling policies or users) could never create the object: the missing-policy lookup failed with a `500` and it retried forever. Both handlers now map their IAM errors through the shared `iam_error_to_s3_error` helper, so a missing policy or user is `404 NoSuchResource` and the `error` is preserved as the S3 error's source; the three IAM calls in the removal path (temporary-user lookup, service-account lookup, delete) are all reclassified, and every other IAM error stays `500 InternalError`. Fixes #8117.
@@ -90,7 +85,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
### Changed
- **Scanner bucket walkers are no longer tied to the disk count**: a new scanner key `walkers_per_disk` (`RUSTFS_SCANNER_WALKERS_PER_DISK`, `1` through `16`, default `1`) spawns that many bucket walkers per local disk of an erasure set, so a single-disk deployment can walk several buckets at once instead of one bucket, one object at a time. The walkers of one disk share its scanner admission, so a peer still sees one scan session per physical disk, and remote disks keep one walker. The walker count is what `max_concurrent_disk_scans` caps and the disk count stays the floor; the `disk_concurrency_budget` log line now reports `walkers_per_disk`, `walkers`, and the `reason` for the effective limit. The default keeps today's I/O profile because a local disk cannot yet report whether it is rotational; the design intends a higher default on flash once it can (`docs/architecture/scanner-per-bucket-convergence.md`). As groundwork for pacing, the raw-enumeration page writer inserts into its building page in place and commits pages without copying them, and a regression test pins the cost of recording 20,000 entries in one prefix so the quadratic bookkeeping removed by [#8114](https://github.com/rustfs/rustfs/pull/8114) (rustfs#8113) cannot return. No persisted format, protocol or admin API changes beyond the additional config key, which older releases preserve and ignore.
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
+46
View File
@@ -0,0 +1,46 @@
# RustFS — CLAUDE.md
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
Agent rules (change style, verification, PR conventions, scoped per-crate
guidance) live in [AGENTS.md](AGENTS.md) — follow it. This file only adds
what Claude Code needs on top: commands and pointers.
## Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Where to look (do not duplicate here)
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Domain conventions worth knowing up front
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Storage Invariants" — read them before touching metadata or
tiering code.
Generated
+485 -386
View File
File diff suppressed because it is too large Load Diff
+59 -78
View File
@@ -66,7 +66,6 @@ members = [
"crates/io-metrics", # I/O metrics collection for performance analysis
"crates/io-core", # Buffered Bytes, mmap-then-copy, and aligned pread I/O helpers
"crates/zip", # ZIP file handling and compression
"crates/zstd-compat", # Fixed codec for dependencies requiring zstd 0.13
]
resolver = "3"
@@ -75,7 +74,7 @@ edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.99.0"
version = "1.0.2"
version = "1.0.4"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -92,56 +91,56 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.2" }
rustfs-heal = { path = "crates/heal", version = "1.0.2" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.2" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.2" }
rustfs-audit = { path = "crates/audit", version = "1.0.2" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.2" }
rustfs-common = { path = "crates/common", version = "1.0.2" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.2" }
rustfs-config = { path = "./crates/config", version = "1.0.2" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.2" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.2" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.2" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.2" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.2" }
rustfs-iam = { path = "crates/iam", version = "1.0.2" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.2" }
rustfs-license = { path = "crates/license", version = "1.0.2" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.2" }
rustfs-kms = { path = "crates/kms", version = "1.0.2" }
rustfs-lock = { path = "crates/lock", version = "1.0.2" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.2" }
rustfs-notify = { path = "crates/notify", version = "1.0.2" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.2" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.2" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.2" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.2", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.2" }
rustfs-obs = { path = "crates/obs", version = "1.0.2" }
rustfs-policy = { path = "crates/policy", version = "1.0.2" }
rustfs-protos = { path = "crates/protos", version = "1.0.2" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.2" }
rustfs-replication = { path = "crates/replication", version = "1.0.2" }
rustfs-rio = { path = "crates/rio", version = "1.0.2" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.2" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.2" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.2" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.2" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.2" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.2" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.2" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.2" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.2" }
rustfs-signer = { path = "crates/signer", version = "1.0.2" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.2" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.2" }
rustfs-targets = { path = "crates/targets", version = "1.0.2" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.2" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.2" }
rustfs-utils = { path = "crates/utils", version = "1.0.2" }
rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
rustfs = { path = "./rustfs", version = "1.0.4" }
rustfs-heal = { path = "crates/heal", version = "1.0.4" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.4" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.4" }
rustfs-audit = { path = "crates/audit", version = "1.0.4" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.4" }
rustfs-common = { path = "crates/common", version = "1.0.4" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.4" }
rustfs-config = { path = "./crates/config", version = "1.0.4" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.4" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.4" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.4" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.4" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.4" }
rustfs-iam = { path = "crates/iam", version = "1.0.4" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.4" }
rustfs-license = { path = "crates/license", version = "1.0.4" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.4" }
rustfs-kms = { path = "crates/kms", version = "1.0.4" }
rustfs-lock = { path = "crates/lock", version = "1.0.4" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.4" }
rustfs-notify = { path = "crates/notify", version = "1.0.4" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.4" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.4" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.4" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.4", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.4" }
rustfs-obs = { path = "crates/obs", version = "1.0.4" }
rustfs-policy = { path = "crates/policy", version = "1.0.4" }
rustfs-protos = { path = "crates/protos", version = "1.0.4" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.4" }
rustfs-replication = { path = "crates/replication", version = "1.0.4" }
rustfs-rio = { path = "crates/rio", version = "1.0.4" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.4" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.4" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.4" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.4" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.4" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.4" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.4" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.4" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.4" }
rustfs-signer = { path = "crates/signer", version = "1.0.4" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.4" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.4" }
rustfs-targets = { path = "crates/targets", version = "1.0.4" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.4" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.4" }
rustfs-utils = { path = "crates/utils", version = "1.0.4" }
rustfs-zip = { path = "./crates/zip", version = "1.0.4" }
# Async Runtime and Networking
async-channel = "2.5.0"
@@ -247,8 +246,8 @@ anyhow = "1.0.104"
arc-swap = "1.9.3"
rustfs-tokio-tar = { version = "0.7.1" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.15"
tar-framing = "0.0.15"
tar-codec = "0.0.14"
tar-framing = "0.0.14"
atoi = "3.1.1"
atomic_enum = "0.3.0"
aws-config = { version = "1.13.0" }
@@ -261,7 +260,7 @@ aws-smithy-http-client = { default-features = false, version = "1.5.1" }
aws-smithy-runtime-api = { version = "1.19.1" }
aws-smithy-types = { version = "1.9.0" }
base64-simd = "0.8.0"
brotli = "9.1.0"
brotli = "9.0.0"
clap = { version = "4.6.7" }
const-str = { version = "1.1.0" }
convert_case = "0.12.0"
@@ -302,8 +301,7 @@ 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.13.0"
same-file = "1.0.6"
parking_lot = "0.12.5"
path-absolutize = "4.0.1"
percent-encoding = "2.3.2"
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
@@ -356,7 +354,7 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.1.2"
zip = "9.1.0"
zstd = "0.14.3"
# Observability and Metrics
@@ -377,16 +375,16 @@ libunftp = { version = "0.23.1" }
unftp-core = "0.1.0"
suppaftp = { version = "12.2.1" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.65.0" }
russh = { version = "0.64.1" }
russh-sftp = "3.0.1"
# WebDAV
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.7.2" }
rustfs-mimalloc = { version = "0.5.6" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.29.2", default-features = false }
hotpath = { version = "0.28.7", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
@@ -423,20 +421,3 @@ inherits = "release"
inherits = "release"
debug = true
strip = "none"
# Pin upstream fixes until compatible crates.io releases are available.
[patch.crates-io]
zstd = { path = "crates/zstd-compat" }
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
oauth2 = { git = "https://github.com/ramosbugs/oauth2-rs", rev = "89585f4a280afd7b285eaf1c9be0068874e03d22" }
aws-sigv4 = { git = "https://github.com/smithy-lang/smithy-rs", rev = "5c1c4bca2ea7af97496e077c9f1a718eaac64bf8" }
# Keep the signer's path dependencies on the SDK's existing registry types.
[patch."https://github.com/smithy-lang/smithy-rs"]
aws-credential-types = { version = "=1.3.1" }
aws-smithy-eventstream = { version = "=0.61.6" }
aws-smithy-http = { version = "=0.64.2" }
aws-smithy-runtime-api = { version = "=1.19.1" }
aws-smithy-types = { version = "=1.9.0" }
+16 -3
View File
@@ -12,12 +12,19 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM alpine:3.24.2 AS build
FROM alpine:3.24.1 AS build
ARG TARGETARCH
ARG RELEASE=latest
RUN apk add --no-cache ca-certificates curl jq unzip
# Keep the native store safe even if the distribution CA package lags distrust.
COPY scripts/check_ca_bundle.sh /tmp/check_ca_bundle.sh
RUN sed -i '/^mozilla\/emSign_ECC_Root_CA_-_G3.crt$/s/^/!/' /etc/ca-certificates.conf && \
update-ca-certificates --fresh && \
sh /tmp/check_ca_bundle.sh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs && \
rm /tmp/check_ca_bundle.sh
WORKDIR /build
RUN set -eux; \
@@ -70,7 +77,7 @@ RUN set -eux; \
rm -rf rustfs.zip /build/.tmp || true
FROM alpine:3.24.2
FROM alpine:3.24.1
ARG RELEASE=latest
ARG BUILD_DATE
@@ -98,7 +105,13 @@ RUN apk upgrade --no-cache && \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Keep the native store safe even if the distribution CA package lags distrust.
COPY scripts/check_ca_bundle.sh /tmp/check_ca_bundle.sh
RUN sed -i '/^mozilla\/emSign_ECC_Root_CA_-_G3.crt$/s/^/!/' /etc/ca-certificates.conf && \
update-ca-certificates --fresh && \
sh /tmp/check_ca_bundle.sh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs && \
rm /tmp/check_ca_bundle.sh
COPY --from=build /build/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
+14
View File
@@ -24,6 +24,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Keep the native store safe even if the distribution CA package lags distrust.
COPY scripts/check_ca_bundle.sh /tmp/check_ca_bundle.sh
RUN sed -i '/^mozilla\/emSign_ECC_Root_CA_-_G3.crt$/s/^/!/' /etc/ca-certificates.conf && \
update-ca-certificates --fresh && \
sh /tmp/check_ca_bundle.sh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs && \
rm /tmp/check_ca_bundle.sh
WORKDIR /build
RUN set -eux; \
@@ -103,6 +110,13 @@ RUN apt-get update && apt-get upgrade -y \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530" \
&& rm -rf /var/lib/apt/lists/*
# Keep the native store safe even if the distribution CA package lags distrust.
COPY scripts/check_ca_bundle.sh /tmp/check_ca_bundle.sh
RUN sed -i '/^mozilla\/emSign_ECC_Root_CA_-_G3.crt$/s/^/!/' /etc/ca-certificates.conf && \
update-ca-certificates --fresh && \
sh /tmp/check_ca_bundle.sh /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs && \
rm /tmp/check_ca_bundle.sh
COPY --from=build /build/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
+3 -3
View File
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
./docker-buildx.sh --push
# Build specific version
./docker-buildx.sh --release 1.0.4 --push
./docker-buildx.sh --release 1.0.1 --push
# Build for custom registry
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
```bash
make docker-buildx # Build locally
make docker-buildx-push # Build and push
make docker-buildx-version VERSION=1.0.4 # Build specific version
make docker-buildx-version VERSION=1.0.1 # Build specific version
make help-docker # Show all Docker-related commands
```
+3 -3
View File
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
./docker-buildx.sh --push
# 构建指定版本
./docker-buildx.sh --release 1.0.4 --push
./docker-buildx.sh --release 1.0.1 --push
# 构建并推送到自定义仓库
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
```bash
make docker-buildx # 本地构建
make docker-buildx-push # 构建并推送
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
make docker-buildx-version VERSION=1.0.1 # 构建指定版本
make help-docker # 显示所有 Docker 相关命令
```
-1
View File
@@ -29,7 +29,6 @@ workspace = true
[features]
default = []
test-util = []
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
-39
View File
@@ -97,45 +97,6 @@ pub fn try_get_global_local_node_name() -> Option<String> {
.filter(|name| !name.is_empty())
}
/// Run a synchronous test callback while publishing a name under its writer lock.
/// Call from a blocking worker; returning or unwinding releases the writer.
#[cfg(any(test, feature = "test-util"))]
#[doc(hidden)]
pub fn with_local_node_name_write_lock_for_test<T>(name: &str, work: impl FnOnce() -> T) -> T {
let mut writer = GLOBAL_LOCAL_NODE_NAME.blocking_write();
*writer = name.to_string();
let result = work();
drop(writer);
result
}
#[cfg(test)]
mod node_identity_tests {
use super::*;
#[tokio::test]
async fn local_node_name_nonblocking_read_recovers_after_writer_release() {
let previous = get_global_local_node_name().await;
let (unavailable, published, panicked) = tokio::task::spawn_blocking(|| {
let unavailable = with_local_node_name_write_lock_for_test("published-node", try_get_global_local_node_name);
let published = try_get_global_local_node_name();
let panicked = std::panic::catch_unwind(|| {
with_local_node_name_write_lock_for_test("unwound-node", || panic!("identity callback failed"));
});
(unavailable, published, panicked)
})
.await
.expect("identity writer worker must complete");
let after_unwind = try_get_global_local_node_name();
set_global_local_node_name(&previous).await;
assert!(unavailable.is_none(), "a held writer must make the nonblocking getter unavailable");
assert_eq!(published.as_deref(), Some("published-node"));
assert!(panicked.is_err(), "the callback panic must propagate after releasing the lock");
assert_eq!(after_unwind.as_deref(), Some("unwound-node"));
}
}
/// Set the global RustFS address used for gRPC connections.
///
/// # Arguments
+14 -73
View File
@@ -172,41 +172,11 @@ pub struct MrfUnverifiedLegacyEvent {
pub anchor: MrfDurableRepairAnchor,
}
#[derive(Clone, Copy)]
struct ResolvedMrfRepairIdentity {
version_id: Option<[u8; 16]>,
scope: Option<MrfScope>,
delete_marker_purge: Option<MrfDeleteMarkerPurgeIdentity>,
lease: MrfIngressLease,
bucket_incarnation_id: Uuid,
}
impl MrfDurableRepairAnchor {
/// Build a dischargeable anchor only when the caller supplies the storage
/// incarnation and the original ingress lease. Legacy replay records lack
/// both pieces and therefore remain fail-closed.
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
let identity = Self::resolve_identity(intent, bucket_incarnation_id)?;
Some(Self {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
version_id: identity.version_id,
scope: identity.scope,
delete_marker_purge: identity.delete_marker_purge,
lease: identity.lease,
bucket_incarnation_id: identity.bucket_incarnation_id,
})
}
/// Check whether the intent can form an anchor for this exact source,
/// without retaining its bucket/object handles. This checks identity shape,
/// not storage verification or retirement authority.
pub fn is_eligible_for_source(intent: &MrfIntent, source: Uuid) -> bool {
Self::resolve_identity(intent, source).is_some_and(|identity| identity.bucket_incarnation_id == source)
}
fn resolve_identity(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<ResolvedMrfRepairIdentity> {
let lease = intent.lease?;
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
let delete_marker_purge = canonical_delete_marker_purge_identity(intent)?;
@@ -216,7 +186,10 @@ impl MrfDurableRepairAnchor {
if bucket_incarnation_id.is_nil() {
return None;
}
Some(ResolvedMrfRepairIdentity {
Some(Self {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
version_id,
scope,
delete_marker_purge,
@@ -260,32 +233,24 @@ pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &m
let Ok(mut events) = registry.lock() else {
return 0;
};
consume_verified_mrf_repair_events_from_queue(bucket, anchors, &mut events)
}
fn consume_verified_mrf_repair_events_from_queue(
bucket: &str,
anchors: &mut Vec<MrfDurableRepairAnchor>,
events: &mut std::collections::VecDeque<MrfVerifiedRepairEvent>,
) -> usize {
let before = anchors.len();
let previous_event_count = events.len();
events.retain(|event| {
let mut retained = std::collections::VecDeque::with_capacity(events.len());
while let Some(event) = events.pop_front() {
if event.bucket.as_ref() != bucket {
return true;
retained.push_back(event);
continue;
}
let mut matched = false;
anchors.retain(|anchor| {
let proven = anchor.is_proven_by(event);
let proven = anchor.is_proven_by(&event);
matched |= proven;
!proven
});
!matched
});
// The old queue rebuild reserved the pre-consumption event count. Reuse
// that buffer when possible without extending its capacity lifetime:
// an emptied queue releases its backing storage on the next consumption.
events.shrink_to(previous_event_count);
if !matched {
retained.push_back(event);
}
}
*events = retained;
before.saturating_sub(anchors.len())
}
@@ -856,21 +821,6 @@ static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::V
static MRF_UNVERIFIED_LEGACY_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfUnverifiedLegacyEvent>>> =
OnceLock::new();
/// Whether durable repair events may need consumption. This is only a work
/// hint: either queue can receive an event after its empty observation, and a
/// bounded queue can evict that event before the next consumer invocation.
/// An unavailable lock is unknown, so callers must keep their normal path.
/// An allocated empty verified queue also needs the normal consumer pass to
/// release its buffer; this hint must not extend that buffer's lifetime.
pub fn mrf_durable_repair_events_may_be_pending() -> bool {
MRF_VERIFIED_REPAIR_EVENTS
.get()
.is_some_and(|registry| registry.try_lock().map_or(true, |events| events.capacity() != 0))
|| MRF_UNVERIFIED_LEGACY_EVENTS
.get()
.is_some_and(|registry| registry.try_lock().map_or(true, |events| !events.is_empty()))
}
/// Record a legacy notification for compatibility. This is not an
/// acknowledgement of storage verification or durable repair completion.
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
@@ -985,15 +935,6 @@ pub fn take_mrf_unverified_legacy_events_for(bucket: &str) -> Vec<MrfUnverifiedL
taken
}
#[cfg(test)]
mod event_presence_tests;
#[cfg(test)]
mod proof_queue_tests;
#[cfg(test)]
mod repair_identity_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1,140 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
fn isolated(name: &str) -> bool {
const CHILD: &str = "RUSTFS_MRF_EVENT_PRESENCE_TEST";
let test = format!("mrf_channel::event_presence_tests::{name}");
if let Ok(current) = std::env::var(CHILD) {
assert_eq!(current, test, "only the selected registry test may run in the child");
return false;
}
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([&test, "--exact", "--test-threads=1"])
.env(CHILD, &test)
.output()
.expect("run isolated global registry test");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("1 passed; 0 failed"),
"registry child must execute exactly once: {stdout}{}",
String::from_utf8_lossy(&output.stderr)
);
true
}
#[test]
fn event_presence_covers_both_queues_and_contention_without_consuming() {
if isolated("event_presence_covers_both_queues_and_contention_without_consuming") {
return;
}
assert!(!mrf_durable_repair_events_may_be_pending());
let anchor = MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: Arc::from("presence-test"),
object: Arc::from("object"),
version_id: Some([7; 16]),
scope: Some(MrfScope {
pool_index: 0,
set_index: 0,
}),
delete_marker_purge: None,
lease: MrfIngressLease::new(21),
bucket_incarnation_id: Uuid::new_v4(),
};
let event = MrfVerifiedRepairEvent {
kind: anchor.kind,
bucket: anchor.bucket.clone(),
object: anchor.object.clone(),
version_id: anchor.version_id,
scope: anchor.scope,
delete_marker_purge: None,
lease: Some(anchor.lease),
bucket_incarnation_id: anchor.bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::VerifiedHealthy,
};
note_mrf_verified_repair(event.clone());
assert!(mrf_durable_repair_events_may_be_pending());
assert!(mrf_durable_repair_events_may_be_pending(), "the hint must not consume a proof");
assert_eq!(take_mrf_verified_repair_events_for(&anchor.bucket), vec![event]);
assert!(
mrf_durable_repair_events_may_be_pending(),
"the empty allocated verified buffer still needs its original maintenance pass"
);
let mut retained = vec![anchor.clone()];
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&anchor.bucket, &mut retained), 0);
assert_eq!(retained, vec![anchor.clone()], "buffer maintenance cannot discharge an anchor");
assert_eq!(
MRF_VERIFIED_REPAIR_EVENTS
.get()
.expect("verified registry")
.lock()
.expect("verified storage")
.capacity(),
0,
"the actual consumer releases empty proof storage before subsequent idle passes can skip it"
);
assert!(!mrf_durable_repair_events_may_be_pending());
let legacy = MrfUnverifiedLegacyEvent { anchor };
note_mrf_unverified_legacy(legacy.clone());
assert!(mrf_durable_repair_events_may_be_pending(), "legacy-only work must not be skipped");
assert_eq!(take_mrf_unverified_legacy_events_for(&legacy.anchor.bucket), vec![legacy]);
assert!(!mrf_durable_repair_events_may_be_pending());
let verified = MRF_VERIFIED_REPAIR_EVENTS.get().expect("verified registry initialized");
let guard = verified.lock().expect("hold verified registry");
assert!(mrf_durable_repair_events_may_be_pending(), "contended verified is unknown");
drop(guard);
let legacy = MRF_UNVERIFIED_LEGACY_EVENTS.get().expect("legacy registry initialized");
let guard = legacy.lock().expect("hold legacy registry");
assert!(mrf_durable_repair_events_may_be_pending(), "contended legacy is unknown");
drop(guard);
assert!(!mrf_durable_repair_events_may_be_pending());
}
#[test]
fn event_presence_keeps_poisoned_verified_unknown() {
if isolated("event_presence_keeps_poisoned_verified_unknown") {
return;
}
assert!(!mrf_durable_repair_events_may_be_pending());
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| Mutex::new(Default::default()));
assert!(
std::panic::catch_unwind(|| {
let _guard = registry.lock().expect("unpoisoned registry");
panic!("poison the isolated verified registry");
})
.is_err()
);
assert!(mrf_durable_repair_events_may_be_pending());
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
}
#[test]
fn event_presence_keeps_poisoned_legacy_unknown() {
if isolated("event_presence_keeps_poisoned_legacy_unknown") {
return;
}
assert!(!mrf_durable_repair_events_may_be_pending());
let registry = MRF_UNVERIFIED_LEGACY_EVENTS.get_or_init(|| Mutex::new(Default::default()));
assert!(
std::panic::catch_unwind(|| {
let _guard = registry.lock().expect("unpoisoned registry");
panic!("poison the isolated legacy registry");
})
.is_err()
);
assert!(mrf_durable_repair_events_may_be_pending());
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
}
@@ -1,304 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use std::collections::VecDeque;
fn proof_fixture() -> (MrfDurableRepairAnchor, MrfVerifiedRepairEvent) {
let anchor = MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: Arc::from("proof-queue"),
object: Arc::from("matched"),
version_id: Some([7; 16]),
scope: Some(MrfScope {
pool_index: 1,
set_index: 2,
}),
delete_marker_purge: None,
lease: MrfIngressLease::new(21),
bucket_incarnation_id: Uuid::new_v4(),
};
let event = MrfVerifiedRepairEvent {
kind: anchor.kind,
bucket: anchor.bucket.clone(),
object: anchor.object.clone(),
version_id: anchor.version_id,
scope: anchor.scope,
delete_marker_purge: anchor.delete_marker_purge,
lease: Some(anchor.lease),
bucket_incarnation_id: anchor.bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::Repaired,
};
(anchor, event)
}
#[test]
fn proof_queue_reuses_unmatched_storage_in_contiguous_and_wrapped_layouts() {
for wrapped in [false, true] {
for foreign in [false, true] {
let (anchor, mut event) = proof_fixture();
if foreign {
event.bucket = Arc::from("another-bucket");
} else {
event.lease = None;
}
let mut events = VecDeque::with_capacity(8);
events.extend(std::iter::repeat_n(event, events.capacity()));
if wrapped {
let first = events.pop_front().expect("full local proof queue");
events.push_back(first);
assert!(!events.as_slices().1.is_empty(), "exercise a wrapped producer queue");
}
let capacity = events.capacity();
let storage = events
.iter()
.map(std::ptr::from_ref)
.min()
.expect("nonempty local proof storage");
let expected = events.clone();
let mut anchors = vec![anchor.clone()];
for _ in 0..4 {
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(events, expected);
assert_eq!(anchors, vec![anchor.clone()]);
assert_eq!(events.capacity(), capacity);
assert_eq!(
events.iter().map(std::ptr::from_ref).min(),
Some(storage),
"a full unmatched queue must reuse its buffer instead of rebuilding it for every bucket"
);
}
}
}
}
#[test]
fn proof_queue_consumes_all_exact_anchor_copies_and_preserves_duplicate_proof_order() {
let (anchor, event) = proof_fixture();
let waiting = MrfDurableRepairAnchor {
object: Arc::from("waiting"),
..anchor.clone()
};
let foreign = MrfVerifiedRepairEvent {
bucket: Arc::from("foreign"),
..event.clone()
};
let unleased = MrfVerifiedRepairEvent {
lease: None,
..event.clone()
};
let future = MrfVerifiedRepairEvent {
object: Arc::from("future"),
..event.clone()
};
let mut events = VecDeque::from(vec![
foreign.clone(),
event.clone(),
unleased.clone(),
event.clone(),
future.clone(),
]);
let first = events.pop_front().expect("foreign proof starts the queue");
events.push_back(first);
assert!(!events.as_slices().1.is_empty(), "match and retain proofs across the wrap boundary");
let mut anchors = vec![anchor.clone(), waiting.clone(), anchor.clone()];
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
2
);
assert_eq!(anchors, vec![waiting]);
assert_eq!(
events,
VecDeque::from(vec![unleased, event, future, foreign]),
"the later duplicate proof remains available after the first proof consumes every exact anchor"
);
let retained = events.clone();
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(events, retained, "a later consumer must still be able to match the retained proofs");
}
#[test]
fn proof_queue_keeps_every_incomplete_or_mismatched_identity() {
let (anchor, event) = proof_fixture();
let different_purge = MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: anchor.bucket_incarnation_id,
marker_incarnation_id: anchor.bucket_incarnation_id,
marker_identity: [3; 32],
};
for rejected in [
MrfVerifiedRepairEvent {
kind: MrfKind::DecodeFailure,
..event.clone()
},
MrfVerifiedRepairEvent {
bucket: Arc::from("foreign"),
..event.clone()
},
MrfVerifiedRepairEvent {
object: Arc::from("other"),
..event.clone()
},
MrfVerifiedRepairEvent {
version_id: None,
..event.clone()
},
MrfVerifiedRepairEvent {
version_id: Some([8; 16]),
..event.clone()
},
MrfVerifiedRepairEvent {
scope: None,
..event.clone()
},
MrfVerifiedRepairEvent {
scope: Some(MrfScope {
pool_index: 2,
set_index: 2,
}),
..event.clone()
},
MrfVerifiedRepairEvent {
scope: Some(MrfScope {
pool_index: 1,
set_index: 3,
}),
..event.clone()
},
MrfVerifiedRepairEvent {
lease: None,
..event.clone()
},
MrfVerifiedRepairEvent {
lease: Some(MrfIngressLease::new(22)),
..event.clone()
},
MrfVerifiedRepairEvent {
bucket_incarnation_id: Uuid::new_v4(),
..event.clone()
},
MrfVerifiedRepairEvent {
delete_marker_purge: Some(different_purge),
..event
},
] {
let mut anchors = vec![anchor.clone()];
let mut events = VecDeque::from([rejected.clone()]);
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(anchors, vec![anchor.clone()]);
assert_eq!(events, VecDeque::from([rejected]));
}
}
#[test]
fn proof_queue_requires_the_complete_delete_marker_purge_identity() {
let (mut anchor, mut event) = proof_fixture();
let purge = MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: anchor.bucket_incarnation_id,
marker_incarnation_id: anchor.bucket_incarnation_id,
marker_identity: [3; 32],
};
anchor.kind = MrfKind::DeleteMarkerPurge;
anchor.delete_marker_purge = Some(purge);
event.kind = anchor.kind;
event.delete_marker_purge = anchor.delete_marker_purge;
for rejected in [
None,
Some(MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: Uuid::new_v4(),
..purge
}),
Some(MrfDeleteMarkerPurgeIdentity {
marker_incarnation_id: Uuid::new_v4(),
..purge
}),
Some(MrfDeleteMarkerPurgeIdentity {
marker_identity: [4; 32],
..purge
}),
] {
let rejected = MrfVerifiedRepairEvent {
delete_marker_purge: rejected,
..event.clone()
};
let mut anchors = vec![anchor.clone()];
let mut events = VecDeque::from([rejected.clone(), event.clone()]);
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
1
);
assert!(anchors.is_empty());
assert_eq!(events, VecDeque::from([rejected]));
}
}
#[test]
fn proof_queue_capacity_follows_the_preconsumption_length_and_releases_on_the_next_empty_call() {
let (anchor, event) = proof_fixture();
let future_events = (0..3)
.map(|index| MrfVerifiedRepairEvent {
object: Arc::from(format!("future-{index}")),
..event.clone()
})
.collect::<Vec<_>>();
let mut events = VecDeque::with_capacity(64);
events.push_back(event);
events.extend(future_events.iter().cloned());
let previous_event_count = events.len();
let mut anchors = vec![anchor.clone()];
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
1
);
assert_eq!(events.iter().cloned().collect::<Vec<_>>(), future_events);
assert_eq!(
events.capacity(),
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity(),
"preserve the former queue rebuild's capacity lifetime instead of retaining the producer high water"
);
let previous_event_count = events.len();
let mut anchors = future_events
.iter()
.map(|event| MrfDurableRepairAnchor {
object: event.object.clone(),
..anchor.clone()
})
.collect();
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
3
);
assert!(events.is_empty());
assert_eq!(
events.capacity(),
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity()
);
assert_eq!(
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
0
);
assert_eq!(
events.capacity(),
0,
"the next empty consumption releases backing storage just as a rebuild did"
);
}
@@ -1,256 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
// Compatibility reference from a47649fb60c0492cc0fb02bff28439c5e357f8e4.
// Keep this independent of the shared resolver and its canonical helpers.
fn original_anchor(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<MrfDurableRepairAnchor> {
let lease = intent.lease?;
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
let (version_id, scope) = match intent.kind {
MrfKind::MetadataCorruption => (None, None),
MrfKind::DecodeFailure | MrfKind::PartialWrite | MrfKind::DeleteMarkerPurge => (version_id, intent.scope),
};
let delete_marker_purge = match intent.kind {
MrfKind::DeleteMarkerPurge => intent
.delete_marker_purge
.as_ref()
.filter(|payload| {
!payload.bucket_incarnation_id.is_nil()
&& payload.marker_incarnation_id == payload.bucket_incarnation_id
&& !payload.marker.is_empty()
&& payload.marker.len() <= MRF_MAX_DELETE_MARKER_BYTES
})
.map(|payload| {
Some(MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: payload.bucket_incarnation_id,
marker_incarnation_id: payload.marker_incarnation_id,
marker_identity: payload.marker_identity,
})
})?,
_ if intent.delete_marker_purge.is_none() => None,
_ => return None,
};
let bucket_incarnation_id = delete_marker_purge
.map(|identity| identity.bucket_incarnation_id)
.unwrap_or(bucket_incarnation_id);
if bucket_incarnation_id.is_nil() {
return None;
}
Some(MrfDurableRepairAnchor {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
version_id,
scope,
delete_marker_purge,
lease,
bucket_incarnation_id,
})
}
#[test]
fn shared_repair_identity_matches_the_original_constructor_and_source_filter() {
let source = Uuid::from_u128(1);
let foreign = Uuid::from_u128(2);
let purge = MrfDeleteMarkerPurge {
bucket_incarnation_id: source,
marker_incarnation_id: source,
marker_identity: [9; 32],
marker: Arc::from([7_u8]),
};
let payloads = [
None,
Some(purge.clone()),
Some(MrfDeleteMarkerPurge {
bucket_incarnation_id: foreign,
marker_incarnation_id: foreign,
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker: Arc::from([]),
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES]),
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES + 1]),
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
marker_incarnation_id: foreign,
..purge.clone()
}),
Some(MrfDeleteMarkerPurge {
bucket_incarnation_id: Uuid::nil(),
marker_incarnation_id: Uuid::nil(),
..purge
}),
];
let mut cases = 0;
for kind in [
MrfKind::DecodeFailure,
MrfKind::MetadataCorruption,
MrfKind::PartialWrite,
MrfKind::DeleteMarkerPurge,
] {
for lease in [None, Some(MrfIngressLease::new(0)), Some(MrfIngressLease::new(7))] {
for version_id in [None, Some([0; 16]), Some([7; 16])] {
for scope in [
None,
Some(MrfScope {
pool_index: u32::MAX,
set_index: 3,
}),
] {
for input_source in [Uuid::nil(), source, foreign] {
for (payload_index, payload) in payloads.iter().enumerate() {
let intent = MrfIntent {
bucket: Arc::from("source-shape"),
object: Arc::from("object"),
version_id,
kind,
delete_marker_purge: payload.clone(),
scope,
lease,
enqueued_at_ms: 0,
attempts: 0,
};
let expected = original_anchor(&intent, input_source);
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, input_source),
expected,
"kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
);
assert_eq!(
MrfDurableRepairAnchor::is_eligible_for_source(&intent, input_source),
expected
.as_ref()
.is_some_and(|anchor| anchor.bucket_incarnation_id == input_source),
"source eligibility must preserve the exact source: kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
);
cases += 1;
}
}
}
}
}
}
assert_eq!(cases, 1728, "exercise the complete explicit identity-shape matrix");
}
#[test]
fn repair_identity_preserves_literal_purge_source_lease_and_metadata_boundaries() {
let source = Uuid::from_u128(1);
let purge_source = Uuid::from_u128(2);
let mut intent = MrfIntent {
bucket: Arc::from("literal-bucket"),
object: Arc::from("marker"),
version_id: None,
kind: MrfKind::DeleteMarkerPurge,
delete_marker_purge: Some(MrfDeleteMarkerPurge {
bucket_incarnation_id: purge_source,
marker_incarnation_id: purge_source,
marker_identity: [9; 32],
marker: Arc::from([7_u8]),
}),
scope: None,
lease: Some(MrfIngressLease::new(17)),
enqueued_at_ms: 0,
attempts: 0,
};
assert!(
!valid_intent_payload(&intent),
"constructor compatibility is broader than durable admission"
);
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()),
Some(MrfDurableRepairAnchor {
kind: MrfKind::DeleteMarkerPurge,
bucket: Arc::from("literal-bucket"),
object: Arc::from("marker"),
version_id: None,
scope: None,
delete_marker_purge: Some(MrfDeleteMarkerPurgeIdentity {
bucket_incarnation_id: purge_source,
marker_incarnation_id: purge_source,
marker_identity: [9; 32],
}),
lease: MrfIngressLease::new(17),
bucket_incarnation_id: purge_source,
})
);
assert!(
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
"a purge cannot borrow a conflicting entry source"
);
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, Uuid::nil()));
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
intent.lease = None;
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
intent.lease = Some(MrfIngressLease::new(17));
intent
.delete_marker_purge
.as_mut()
.expect("purge fixture")
.marker_incarnation_id = Uuid::nil();
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
intent
.delete_marker_purge
.as_mut()
.expect("purge fixture")
.bucket_incarnation_id = Uuid::nil();
assert!(
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
"nil marker identity cannot borrow a non-nil source"
);
intent.kind = MrfKind::PartialWrite;
assert!(
MrfDurableRepairAnchor::from_intent(&intent, source).is_none(),
"ordinary intents cannot carry a purge payload"
);
intent.delete_marker_purge = None;
intent.kind = MrfKind::MetadataCorruption;
intent.version_id = Some([7; 16]);
intent.scope = Some(MrfScope {
pool_index: 8,
set_index: 9,
});
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, source),
Some(MrfDurableRepairAnchor {
kind: MrfKind::MetadataCorruption,
bucket: Arc::from("literal-bucket"),
object: Arc::from("marker"),
version_id: None,
scope: None,
delete_marker_purge: None,
lease: MrfIngressLease::new(17),
bucket_incarnation_id: source,
})
);
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, source));
assert!(MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none());
intent.lease = Some(MrfIngressLease::new(0));
assert_eq!(
MrfDurableRepairAnchor::from_intent(&intent, source)
.expect("preserve the existing opaque lease shape")
.lease,
MrfIngressLease::new(0)
);
}
-957
View File
@@ -1,957 +0,0 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
//! Mutable admission capacity without replacing or revoking held permits.
use std::num::NonZeroU16;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
use tokio::time::Instant;
/// A resource owner publishes a capacity; running units retain their permits
/// after a reduction. Lease slots are reserved once, with no per-unit Arc
/// allocation. Tokio wait queues may lazily initialize platform mutex storage
/// on first contention. It does not discover devices, start
/// workers, or decide resource policy. Cancellation drops the acquire future.
///
/// Uncontended admission is atomic. Parked work takes a FIFO admission turn,
/// so repeated fast acquisitions cannot starve an older waiter. Source/age
/// priority still belongs to the caller's scheduler, before joining this queue.
pub struct AdaptiveAdmission {
// One CAS observes both capacity and active count, so a concurrent shrink
// cannot admit a new unit using an already superseded capacity.
state: AtomicU64,
origin: Instant,
valid_until_millis: AtomicU64,
// Only the FIFO turn holder subscribes. A stored notification is harmless:
// capacity and freshness are always checked again after waking.
changed: Notify,
queued: AtomicUsize,
// FIFO admission turn, not a mutable-state lock. No storage lock or
// admitted permit may be held while joining. This private semaphore is
// never closed; release and resize do not acquire it.
turn: Semaphore,
maximum: u32,
slots: Box<[PermitSlots]>,
next_chunk: AtomicUsize,
}
struct PermitSlots {
free: AtomicU64,
references: [AtomicUsize; 64],
}
struct QueuedAdmission<'a>(&'a AdaptiveAdmission);
impl Drop for QueuedAdmission<'_> {
fn drop(&mut self) {
self.0.queued.fetch_sub(1, Ordering::AcqRel);
}
}
/// One active unit. Clones share that unit without allocating; the final drop
/// returns it to the same owner, including after resize or waiter cancellation.
#[must_use = "hold the permit for the admitted unit's complete lifetime"]
pub struct AdaptivePermit {
owner: Arc<AdaptiveAdmission>,
slot: usize,
}
impl Clone for AdaptivePermit {
fn clone(&self) -> Self {
// Every slot reference owns a strong owner reference. Clone the Arc
// first, so its overflow protection also bounds the slot refcount.
let owner = Arc::clone(&self.owner);
owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed);
Self { owner, slot: self.slot }
}
}
impl std::fmt::Debug for AdaptivePermit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdaptivePermit")
.field("slot", &self.slot)
.field("owner", &self.owner.snapshot())
.finish()
}
}
/// One coherent view of configured capacity and actual active units.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdaptiveAdmissionSnapshot {
/// Current cap on new admissions. Zero pauses all new units.
pub limit: u32,
/// Existing units, which may temporarily exceed a reduced limit.
pub active: u32,
}
/// Node scheduling capacity, separate from physical I/O and buffer ownership.
/// Its non-cloneable units end with the async operation; a native tail must
/// retain its own physical-resource unit, never this scheduling unit.
pub struct NodeAdmission {
capacity: Arc<AdaptiveAdmission>,
completed: AtomicU64,
}
/// One async operation's scheduling unit. Dropping a cancelled or failed
/// operation releases capacity without claiming completed work.
#[must_use = "retain the unit until the async operation ends"]
pub struct NodePermit {
_capacity: AdaptivePermit,
owner: Arc<NodeAdmission>,
}
impl NodeAdmission {
/// Construct one node owner with no capacity until a resource sample arrives.
pub fn new(maximum: NonZeroU16) -> Arc<Self> {
Arc::new(Self {
capacity: AdaptiveAdmission::new(maximum, 0),
completed: AtomicU64::new(0),
})
}
/// Publish feedback with this owner's actual completed-work counter.
pub fn update(&self, controller: &mut FeedbackController, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let observation = observation.map(|mut observation| {
observation.completed_units = self.completed();
observation
});
controller.update(&self.capacity, observation)
}
/// Current leased scheduling cap and held async units.
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
self.capacity.snapshot()
}
/// Completed operations, or unknown after the counter exhausts its range.
pub fn completed(&self) -> Option<u64> {
let value = self.completed.load(Ordering::Acquire);
(value != u64::MAX).then_some(value)
}
/// Try without waiting, including when the caller already owns a lock.
pub fn try_acquire(self: &Arc<Self>) -> Option<NodePermit> {
self.capacity.try_acquire().map(|capacity| NodePermit {
_capacity: capacity,
owner: self.clone(),
})
}
/// Wait in FIFO order. Callers must provide a deadline/cancellation and
/// acquire before storage locks, not while retaining another node unit.
pub async fn acquire(self: &Arc<Self>) -> NodePermit {
NodePermit {
_capacity: self.capacity.acquire().await,
owner: self.clone(),
}
}
}
impl NodePermit {
/// Count a completed operation exactly once, then release its node unit.
/// This counter is scheduling progress, not an authoritative heal receipt.
pub fn complete(self) {
let _ = self
.owner
.completed
.try_update(Ordering::AcqRel, Ordering::Acquire, |value| value.checked_add(1));
}
}
/// Confidence-qualified pressure for one complete sampling window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourcePressure {
/// Missing, stale, reset, or insufficient observations; never evidence of idle.
Unknown,
/// A resource or foreground-protection threshold was reached.
Busy,
/// All required observations were fresh and below their low watermarks.
Stable,
}
/// Resource-owner input to the controller, after applying operator and kernel
/// caps. This describes one owner; callers must not duplicate a device's budget
/// for aliases, pools, or remote requesters.
#[derive(Debug, Clone, Copy)]
pub struct FeedbackObservation {
/// Identity of the resource/telemetry generation. Replacement resets probes.
pub generation: u64,
/// Upper bound imposed by resources and explicit configuration; zero blocks.
pub max_concurrency: u32,
/// Complete-window pressure, including the confidence of its sources.
pub pressure: ResourcePressure,
/// Cumulative completed work in this generation, not just scheduled work.
pub completed_units: Option<u64>,
}
/// Why the latest concurrency limit was selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedbackReason {
/// The resource cap is unavailable; no new unit can be bounded safely.
Unavailable,
/// A changed owner or reset work counter requires a new baseline.
NewBaseline,
/// The resource or operator cap reduced the permitted concurrency.
CapReduced,
/// Pressure caused an immediate multiplicative reduction.
Pressure,
/// Incomplete telemetry retains at most one maintenance unit.
LowConfidence,
/// Waiting for consecutive stable windows, work progress and cooldown.
Recovering,
/// One additional unit is being probed after a stable recovery period.
Probe,
}
/// One controller decision, suitable for metrics without dynamic labels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeedbackDecision {
/// New admissions use this cap; existing permits remain valid.
pub limit: u32,
/// Decision cause, distinct from a proof of work completion or storage health.
pub reason: FeedbackReason,
}
/// Single-owner additive probe/multiplicative decrease controller. It adjusts
/// only concurrency. Every source must be fresh before declaring `Stable`.
///
/// The sampler runs every second. A gap over two seconds resets recovery; eight
/// consecutive stable seconds and observed completed work allow one extra unit.
/// Pressure or lower caps apply immediately. This policy is not a performance
/// claim; its parameters require the same workload/foreground-SLO acceptance.
pub struct FeedbackController {
previous: Option<(Instant, FeedbackObservation)>,
stable_since: Option<Instant>,
last_change: Instant,
limit: u32,
}
impl Default for FeedbackController {
fn default() -> Self {
Self::new()
}
}
impl FeedbackController {
/// Start without a resource observation or an admission grant.
pub fn new() -> Self {
Self {
previous: None,
stable_since: None,
last_change: Instant::now(),
limit: 0,
}
}
/// Consume one sample and publish a three-second lease to the same gate.
/// If this function stops being called, admission expires independently.
pub fn update(&mut self, gate: &AdaptiveAdmission, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let observation = observation.map(|mut observation| {
observation.max_concurrency = observation.max_concurrency.min(gate.maximum);
observation
});
let decision = self.decide(Instant::now(), observation);
gate.set_sampled_limit(decision.limit, Duration::from_secs(3));
decision
}
fn decide(&mut self, now: Instant, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let Some(observation) = observation else {
self.previous = None;
self.stable_since = None;
self.last_change = now;
self.limit = 0;
return FeedbackDecision {
limit: 0,
reason: FeedbackReason::Unavailable,
};
};
let previous = self.previous.replace((now, observation));
let contiguous = previous.is_some_and(|(at, old)| {
now.saturating_duration_since(at) <= Duration::from_secs(2)
&& old.generation == observation.generation
&& matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b >= a)
});
let advanced = previous
.is_some_and(|(_, old)| matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b > a));
let old_limit = self.limit;
let reason = if observation.max_concurrency == 0 {
self.limit = 0;
self.stable_since = None;
FeedbackReason::CapReduced
} else if observation.pressure == ResourcePressure::Busy {
self.limit = (self.limit / 2).max(1).min(observation.max_concurrency);
self.stable_since = None;
// Repeated busy windows restart cooldown, even at the minimum cap.
self.last_change = now;
FeedbackReason::Pressure
} else if observation.max_concurrency < self.limit {
self.limit = observation.max_concurrency;
self.stable_since = None;
FeedbackReason::CapReduced
} else if observation.pressure == ResourcePressure::Unknown || observation.completed_units.is_none() {
self.limit = 1.min(observation.max_concurrency);
self.stable_since = None;
FeedbackReason::LowConfidence
} else if !contiguous || self.limit == 0 {
self.limit = 1.min(observation.max_concurrency);
self.stable_since = None;
self.last_change = now;
FeedbackReason::NewBaseline
} else {
let since = *self.stable_since.get_or_insert(now);
if advanced
&& now.saturating_duration_since(since) >= Duration::from_secs(8)
&& now.saturating_duration_since(self.last_change) >= Duration::from_secs(8)
&& self.limit < observation.max_concurrency
{
self.limit += 1;
FeedbackReason::Probe
} else {
FeedbackReason::Recovering
}
};
if old_limit != self.limit {
self.last_change = now;
}
FeedbackDecision {
limit: self.limit,
reason,
}
}
}
impl AdaptiveAdmission {
/// Construct a bounded owner, with zero initial capacity allowed for unknown
/// input. The immutable maximum reserves at most 65,535 lease slots once;
/// acquiring or cloning a permit does not allocate a per-unit Arc.
pub fn new(maximum: NonZeroU16, limit: u32) -> Arc<Self> {
let maximum_u32 = u32::from(maximum.get());
let maximum = usize::from(maximum.get());
let slots = (0..maximum.div_ceil(64))
.map(|chunk| {
let count = (maximum - chunk * 64).min(64);
PermitSlots {
free: AtomicU64::new(if count == 64 { u64::MAX } else { (1_u64 << count) - 1 }),
references: std::array::from_fn(|_| AtomicUsize::new(0)),
}
})
.collect::<Vec<_>>()
.into_boxed_slice();
Arc::new(Self {
state: AtomicU64::new(u64::from(limit.min(maximum_u32)) << 32),
origin: Instant::now(),
valid_until_millis: AtomicU64::new(u64::MAX),
changed: Notify::new(),
queued: AtomicUsize::new(0),
turn: Semaphore::new(1),
maximum: maximum_u32,
slots,
next_chunk: AtomicUsize::new(0),
})
}
/// Publish a cap bounded by the immutable maximum, without losing held work
/// or changing owner identity.
pub fn set_limit(&self, limit: u32) {
let limit = limit.min(self.maximum);
let _ = self.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
Some((u64::from(limit) << 32) | (state & u64::from(u32::MAX)))
});
self.changed.notify_one();
}
/// Publish a cap with a finite observation lifetime. Expired observations
/// block new admissions even if the sampling worker has stopped. Existing
/// permits are retained. A single resource owner must serialize publishers.
///
/// Capacity is published before freshness, so a concurrent reader can
/// temporarily reject a fresh update but cannot use an increased lifetime
/// with the previous, potentially larger capacity.
pub fn set_sampled_limit(&self, limit: u32, valid_for: Duration) {
self.set_limit(limit);
let deadline = self
.elapsed_millis()
.saturating_add(u64::try_from(valid_for.as_millis()).unwrap_or(u64::MAX));
self.valid_until_millis.store(deadline, Ordering::Release);
self.changed.notify_one();
}
fn elapsed_millis(&self) -> u64 {
u64::try_from(self.origin.elapsed().as_millis()).unwrap_or(u64::MAX)
}
fn fresh(&self) -> bool {
self.elapsed_millis() < self.valid_until_millis.load(Ordering::Acquire)
}
/// Read the cap and active count from the same atomic observation.
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
// Acquire freshness first: a newly published lease must not be paired
// with capacity loaded before its preceding reduction.
let fresh = self.fresh();
let state = self.state.load(Ordering::Acquire);
AdaptiveAdmissionSnapshot {
limit: if fresh {
u32::try_from(state >> 32).unwrap_or(u32::MAX)
} else {
0
},
active: u32::try_from(state & u64::from(u32::MAX)).unwrap_or(u32::MAX),
}
}
/// Admit immediately if capacity remains, without a per-unit allocation.
/// The uncontended path uses atomics; refunding a racing acquisition may
/// wake the FIFO head through Tokio's notification queue.
pub fn try_acquire(self: &Arc<Self>) -> Option<AdaptivePermit> {
if self.queued.load(Ordering::Acquire) != 0 {
return None;
}
let permit = self.acquire_capacity()?;
// A waiter can register between the first queue check and the capacity
// CAS. Refund before exposing the permit rather than stealing its turn.
(self.queued.load(Ordering::Acquire) == 0).then_some(permit)
}
fn acquire_capacity(self: &Arc<Self>) -> Option<AdaptivePermit> {
if !self.fresh() {
return None;
}
let state = self.state.load(Ordering::Acquire);
if state & u64::from(u32::MAX) >= state >> 32 {
return None;
}
let slot = self.reserve_slot()?;
if self
.state
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
let active = state & u64::from(u32::MAX);
(active < state >> 32).then(|| state + 1)
})
.is_err()
{
self.release_slot(slot);
self.changed.notify_one();
return None;
}
self.slots[slot / 64].references[slot % 64].store(1, Ordering::Release);
let permit = AdaptivePermit {
owner: self.clone(),
slot,
};
// The capacity CAS can retry across an observation expiry. Recheck
// before giving ownership to the caller; dropping refunds this unit.
self.fresh().then_some(permit)
}
fn reserve_slot(&self) -> Option<usize> {
let first = self.next_chunk.fetch_add(1, Ordering::Relaxed) % self.slots.len();
for offset in 0..self.slots.len() {
let chunk_index = (first + offset) % self.slots.len();
let free = &self.slots[chunk_index].free;
if let Ok(bits) = free.try_update(Ordering::AcqRel, Ordering::Acquire, |bits| (bits != 0).then(|| bits & (bits - 1)))
{
// trailing_zeros is in 0..64 because the zero word is rejected.
return Some(chunk_index * 64 + bits.trailing_zeros() as usize);
}
}
None
}
fn release_slot(&self, slot: usize) {
self.slots[slot / 64].free.fetch_or(1_u64 << (slot % 64), Ordering::Release);
}
/// Wait for capacity. The future owns no permit until it returns; dropping
/// a pending future leaves capacity untouched. Wrap in the caller's existing
/// cancellation/deadline select before acquiring any storage commit locks.
pub async fn acquire(self: &Arc<Self>) -> AdaptivePermit {
if let Some(permit) = self.try_acquire() {
return permit;
}
self.queued.fetch_add(1, Ordering::AcqRel);
let _queued = QueuedAdmission(self);
// Retain the successful permit inside its Result. No code can close
// this private semaphore, so acquisition cannot return an error.
let _turn = self.turn.acquire().await;
loop {
let notified = self.changed.notified();
tokio::pin!(notified);
// Register before checking capacity to avoid a lost release/resize.
notified.as_mut().enable();
if let Some(permit) = self.acquire_capacity() {
return permit;
}
notified.await;
}
}
}
impl Drop for AdaptivePermit {
fn drop(&mut self) {
if self.owner.slots[self.slot / 64].references[self.slot % 64].fetch_sub(1, Ordering::AcqRel) != 1 {
return;
}
// Publish the free slot before exposing another admission capacity.
self.owner.release_slot(self.slot);
self.owner.state.fetch_sub(1, Ordering::AcqRel);
self.owner.changed.notify_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn node_progress_requires_explicit_completion_and_never_wraps() {
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
node.capacity.set_limit(2);
drop(node.acquire().await);
assert_eq!(node.completed(), Some(0), "cancellation is not progress");
node.acquire().await.complete();
assert_eq!(node.completed(), Some(1));
node.completed.store(u64::MAX - 1, Ordering::Release);
node.acquire().await.complete();
node.acquire().await.complete();
assert_eq!(node.completed(), None, "exhausted progress stays unknown");
assert_eq!(node.snapshot().active, 0);
}
#[tokio::test]
async fn cancelled_node_work_does_not_reserve_capacity_for_unrelated_native_tail() {
let node = NodeAdmission::new(NonZeroU16::new(1).expect("one slot"));
node.capacity.set_limit(1);
let physical = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one disk unit"), 1);
let scheduling = node.acquire().await;
let native_tail = physical.acquire().await;
drop(scheduling);
assert_eq!(node.completed(), Some(0));
assert!(node.try_acquire().is_some(), "healthy resources can still be scheduled");
assert!(physical.try_acquire().is_none(), "the stalled disk remains charged");
drop(native_tail);
assert!(physical.try_acquire().is_some());
}
#[tokio::test(start_paused = true)]
async fn node_feedback_expires_without_revoking_held_work_or_fabricating_progress() {
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
let mut controller = FeedbackController::new();
let input = FeedbackObservation {
generation: 1,
max_concurrency: 2,
pressure: ResourcePressure::Stable,
completed_units: Some(99),
};
node.update(&mut controller, Some(input));
let held = node.acquire().await;
assert_eq!(node.completed(), Some(0));
tokio::time::advance(Duration::from_secs(4)).await;
assert_eq!(node.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
assert!(node.try_acquire().is_none());
held.complete();
assert_eq!(node.completed(), Some(1));
assert_eq!(node.snapshot().active, 0);
}
#[tokio::test]
async fn shared_unit_stays_admitted_until_native_worker_releases_its_last_clone() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let outer = gate.acquire().await;
let native = outer.clone();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let worker = tokio::task::spawn_blocking(move || {
started_tx.send(()).expect("worker started");
release_rx.blocking_recv().expect("release native worker");
drop(native);
});
started_rx.await.expect("native worker is live");
drop(outer);
assert_eq!(gate.snapshot().active, 1);
assert!(gate.try_acquire().is_none());
gate.set_limit(0);
assert_eq!(gate.snapshot().active, 1);
release_tx.send(()).expect("release tail");
worker.await.expect("native tail completed");
assert_eq!(gate.snapshot().active, 0);
gate.set_limit(1);
assert!(gate.try_acquire().is_some());
}
#[tokio::test]
async fn bounded_slots_cross_word_boundaries_and_recycle_only_after_final_drop() {
let maximum = NonZeroU16::new(65).expect("partial second chunk");
let gate = AdaptiveAdmission::new(maximum, u32::MAX);
assert_eq!(gate.snapshot().limit, 65);
for _ in 0..4 {
let units: Vec<_> = (0..65).map(|_| gate.try_acquire().expect("free slot")).collect();
let tails = units.clone();
assert_eq!(gate.snapshot().active, 65);
assert!(gate.try_acquire().is_none());
drop(units);
assert_eq!(gate.snapshot().active, 65);
gate.set_limit(0);
for (remaining, tail) in (0..65).rev().zip(tails) {
drop(tail);
assert_eq!(gate.snapshot().active, remaining);
}
gate.set_limit(u32::MAX);
assert_eq!(gate.snapshot().limit, 65);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn shared_units_survive_concurrent_clone_drop_reuse_and_resize() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded owner"), 8);
let mut workers = Vec::new();
for _ in 0..16 {
let gate = gate.clone();
workers.push(tokio::spawn(async move {
for _ in 0..64 {
let root = gate.acquire().await;
let one = root.clone();
let two = one.clone();
drop(root);
tokio::task::yield_now().await;
drop(two);
tokio::task::yield_now().await;
drop(one);
}
}));
}
for limit in [0, 1, 8, 2, 0, 8] {
gate.set_limit(limit);
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(5), async {
for worker in workers {
worker.await.expect("lease worker");
}
})
.await
.expect("all lease workers complete");
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
let full: Vec<_> = (0..8).map(|_| gate.try_acquire().expect("all slots returned")).collect();
assert!(gate.try_acquire().is_none());
drop(full);
}
#[tokio::test]
async fn queued_work_cannot_be_starved_by_new_fast_acquisitions() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 1);
let held = gate.acquire().await;
let mut first = Box::pin(gate.acquire());
let mut second = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll(&mut context).is_pending());
assert!(second.as_mut().poll(&mut context).is_pending());
drop(held);
assert!(gate.try_acquire().is_none(), "new work must not steal a parked worker's turn");
assert!(second.as_mut().poll(&mut context).is_pending(), "later work cannot pass the queue head");
let Poll::Ready(first_permit) = first.as_mut().poll(&mut context) else {
panic!("queue head must run")
};
drop(first_permit);
let Poll::Ready(second_permit) = second.as_mut().poll(&mut context) else {
panic!("second waiter must run")
};
drop(second_permit);
assert!(gate.try_acquire().is_some(), "drained queue restores the atomic fast path");
}
#[tokio::test]
async fn cancelling_queued_work_preserves_fifo_capacity_and_fast_path() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut head = Box::pin(gate.acquire());
let mut middle = Box::pin(gate.acquire());
let mut tail = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(head.as_mut().poll(&mut context).is_pending());
assert!(middle.as_mut().poll(&mut context).is_pending());
assert!(tail.as_mut().poll(&mut context).is_pending());
drop(middle);
drop(head);
gate.set_limit(1);
assert!(
gate.try_acquire().is_none(),
"cancelled head must not expose the surviving waiter's capacity"
);
let Poll::Ready(permit) = tail.as_mut().poll(&mut context) else {
panic!("surviving waiter must run")
};
drop(permit);
assert!(gate.try_acquire().is_some());
gate.set_limit(0);
let mut cancelled = Box::pin(gate.acquire());
assert!(cancelled.as_mut().poll(&mut context).is_pending());
drop(cancelled);
gate.set_limit(1);
assert!(
gate.try_acquire().is_some(),
"cancelling the final waiter must not strand an admission turn"
);
}
#[tokio::test]
async fn cancelling_a_notified_head_hands_capacity_to_the_next_waiter() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
let mut head = Box::pin(gate.acquire());
let mut next = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(head.as_mut().poll(&mut context).is_pending());
assert!(next.as_mut().poll(&mut context).is_pending());
gate.set_limit(1);
// Cancel after notification, before the head consumes its capacity.
drop(head);
let Poll::Ready(permit) = next.as_mut().poll(&mut context) else {
panic!("successor must observe capacity without another notification")
};
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
drop(permit);
}
#[tokio::test(start_paused = true)]
async fn one_resize_serves_multiple_fifo_turns_but_stored_wakes_cannot_bypass_expiry() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
let mut first = Box::pin(gate.acquire());
let mut second = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll(&mut context).is_pending());
assert!(second.as_mut().poll(&mut context).is_pending());
gate.set_sampled_limit(2, Duration::from_secs(1));
let Poll::Ready(one) = first.as_mut().poll(&mut context) else {
panic!("first turn must observe resize")
};
let Poll::Ready(two) = second.as_mut().poll(&mut context) else {
panic!("second turn must observe resize without releasing the first unit")
};
tokio::time::advance(Duration::from_secs(1)).await;
drop(one);
drop(two);
let mut expired = Box::pin(gate.acquire());
assert!(expired.as_mut().poll(&mut context).is_pending(), "stored wake is not a fresh lease");
gate.set_sampled_limit(1, Duration::from_secs(1));
let Poll::Ready(renewed) = expired.as_mut().poll(&mut context) else {
panic!("fresh observation must wake expired admission")
};
drop(renewed);
assert_eq!(gate.snapshot().active, 0);
}
use std::future::Future;
use std::task::Poll;
#[tokio::test]
async fn shrink_preserves_all_held_units_and_blocks_until_below_new_limit() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 3);
let first = owner.acquire().await;
let second = owner.acquire().await;
let third = owner.acquire().await;
owner.set_limit(1);
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 3 });
drop(first);
assert!(owner.try_acquire().is_none());
drop(second);
assert!(owner.try_acquire().is_none());
drop(third);
let fourth = owner.try_acquire().expect("last old permit returned to original owner");
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
drop(fourth);
assert_eq!(owner.snapshot().active, 0);
}
#[tokio::test]
async fn zero_capacity_and_cancelled_waiter_do_not_leak_or_bypass() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut waiting = Box::pin(owner.acquire());
std::future::poll_fn(|cx| {
assert!(waiting.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
drop(waiting);
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 0 });
assert!(owner.try_acquire().is_none());
owner.set_limit(1);
let permit = owner.acquire().await;
assert!(owner.try_acquire().is_none());
drop(permit);
assert_eq!(owner.snapshot().active, 0);
}
#[tokio::test]
async fn resize_wakes_a_registered_waiter_without_an_unrelated_release() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut waiting = Box::pin(owner.acquire());
std::future::poll_fn(|cx| {
assert!(waiting.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
owner.set_limit(1);
let permit = tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
.await
.expect("resize notification");
assert_eq!(owner.snapshot().active, 1);
drop(permit);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_resize_release_and_acquire_keep_the_owner_count_exact() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 8);
let mut tasks = Vec::new();
for _ in 0..16 {
let owner = owner.clone();
tasks.push(tokio::spawn(async move {
for _ in 0..100 {
let permit = owner.acquire().await;
tokio::task::yield_now().await;
drop(permit);
}
}));
}
for limit in [1, 0, 4, 2, 8, 0, u32::MAX, 1] {
owner.set_limit(limit);
tokio::task::yield_now().await;
}
owner.set_limit(8);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
for task in tasks {
task.await.expect("admission task");
}
})
.await
.expect("all waiters make progress");
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
}
#[tokio::test(start_paused = true)]
async fn expired_observation_blocks_new_work_without_revoking_held_permits() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
owner.set_sampled_limit(2, Duration::from_secs(2));
let held = owner.acquire().await;
tokio::time::advance(Duration::from_secs(2)).await;
assert!(owner.try_acquire().is_none());
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
drop(held);
assert_eq!(owner.snapshot().active, 0);
owner.set_sampled_limit(1, Duration::from_secs(2));
let fresh = owner.try_acquire().expect("fresh observation reopens same owner");
owner.set_sampled_limit(8, Duration::ZERO);
assert!(owner.try_acquire().is_none());
drop(fresh);
assert_eq!(owner.snapshot().active, 0);
}
fn healthy(completed: u64) -> FeedbackObservation {
FeedbackObservation {
generation: 1,
max_concurrency: 8,
pressure: ResourcePressure::Stable,
completed_units: Some(completed),
}
}
#[tokio::test(start_paused = true)]
async fn controller_grows_one_unit_per_cooldown_and_cuts_pressure_immediately() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
assert_eq!(controller.update(&gate, Some(healthy(0))).limit, 1);
for completed in 1..=25 {
tokio::time::advance(Duration::from_secs(1)).await;
let limit = controller.update(&gate, Some(healthy(completed))).limit;
assert_eq!(
limit,
if completed < 9 {
1
} else if completed < 17 {
2
} else if completed < 25 {
3
} else {
4
}
);
}
let held = gate.try_acquire().expect("existing work");
let mut pressure = healthy(26);
pressure.pressure = ResourcePressure::Busy;
assert_eq!(
controller.update(&gate, Some(pressure)),
FeedbackDecision {
limit: 2,
reason: FeedbackReason::Pressure
}
);
pressure.max_concurrency = 0;
assert_eq!(controller.update(&gate, Some(pressure)).limit, 0);
assert_eq!(gate.snapshot().active, 1);
assert!(gate.try_acquire().is_none());
drop(held);
}
#[tokio::test(start_paused = true)]
async fn controller_missing_low_sample_stalled_or_changed_sources_never_expand() {
for kind in 0..5 {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
for tick in 0..32 {
let mut observation = healthy(tick);
match kind {
0 => observation.pressure = ResourcePressure::Unknown,
1 => observation.completed_units = None,
2 => observation.completed_units = Some(0),
3 => observation.generation = tick,
_ => {}
}
tokio::time::advance(Duration::from_secs(if kind == 4 { 3 } else { 1 })).await;
assert!(controller.update(&gate, Some(observation)).limit <= 1, "kind {kind}");
}
assert_eq!(
controller.update(&gate, None),
FeedbackDecision {
limit: 0,
reason: FeedbackReason::Unavailable
}
);
assert!(gate.try_acquire().is_none());
}
}
#[tokio::test(start_paused = true)]
async fn controller_resumes_after_zero_cap_without_requiring_impossible_progress() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
let mut sample = healthy(0);
sample.max_concurrency = 0;
assert_eq!(controller.update(&gate, Some(sample)).limit, 0);
tokio::time::advance(Duration::from_secs(1)).await;
sample.max_concurrency = 8;
assert_eq!(controller.update(&gate, Some(sample)).limit, 1);
assert!(gate.try_acquire().is_some(), "minimum progress restarts inside the restored cap");
}
#[tokio::test(start_paused = true)]
async fn controller_applies_pressure_reduction_and_lower_cap_in_the_same_window() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
for completed in 0..=25 {
controller.update(&gate, Some(healthy(completed)));
tokio::time::advance(Duration::from_secs(1)).await;
}
assert_eq!(gate.snapshot().limit, 4);
let mut sample = healthy(26);
sample.max_concurrency = 3;
sample.pressure = ResourcePressure::Busy;
assert_eq!(controller.update(&gate, Some(sample)).limit, 2);
}
}
-568
View File
@@ -1,568 +0,0 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
//! Bounded foreground completion windows for background resource feedback.
//! Recording uses fixed atomic storage, without an allocation, mutex or task
//! per request. A single reader rotates banks and waits for the retired bank's
//! writers before reading it. Delayed writers delay publication, not the
//! recorded window boundary. Percentiles are intervals, never exact values.
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
const SUB_BUCKETS: usize = 64;
const FINITE_BUCKETS: usize = 27 * SUB_BUCKETS;
const BUCKETS: usize = FINITE_BUCKETS + 1;
const MAX_MICROS: u64 = u32::MAX as u64;
/// Bounded foreground categories; no bucket, object or request identifiers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ForegroundClass {
/// Object body reads.
Read,
/// Object writes, including multipart and copy operations.
Write,
/// Metadata and listing operations.
Metadata,
/// Other operations, including rejection before typed dispatch.
Other,
}
impl ForegroundClass {
/// Stable order shared by window summaries.
pub const ALL: [Self; 4] = [Self::Read, Self::Write, Self::Metadata, Self::Other];
/// Fixed metric label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Metadata => "metadata",
Self::Other => "other",
}
}
const fn index(self) -> usize {
match self {
Self::Read => 0,
Self::Write => 1,
Self::Metadata => 2,
Self::Other => 3,
}
}
}
/// Outcome at the observed response-body boundary, not a client ACK.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CompletionOutcome {
/// Successful response body completed.
Success,
/// Error response, service error, or body-stream failure.
Failed,
/// The request/body was dropped before completion.
Cancelled,
}
/// Inclusive bounds containing the observed percentile.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LatencyBounds {
/// Inclusive lower bound in nanoseconds.
pub lower_nanos: u64,
/// Inclusive upper bound; absent for the overflow bucket.
pub upper_nanos: Option<u64>,
}
/// One class in a complete observation window.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ClassCompletions {
/// All finishes, including errors and cancellation.
pub finished: u64,
/// Error responses, service errors and body-stream failures.
pub failed: u64,
/// Censored observations; these must not be treated as successful latency.
pub cancelled: u64,
/// P99 of non-cancelled finishes, when at least 100 samples exist.
/// This count permits computing the rank; it is not statistical confidence.
pub p99: Option<LatencyBounds>,
}
/// Clock readings bracketing an atomic event-window cut. A scheduler pause
/// between these readings is visible, rather than disguised as precise timing.
#[derive(Clone, Copy, Debug)]
pub struct WindowBoundary {
/// Read immediately before the event cut.
pub earliest: Instant,
/// Read immediately after the event cut.
pub latest: Instant,
}
/// Complete windows retain their actual cut brackets, even if publication waits.
#[derive(Clone, Copy, Debug)]
pub struct ForegroundWindow {
/// Start of this event window.
pub start: WindowBoundary,
/// Cut of the retired bank, possibly older than publication time.
pub end: WindowBoundary,
/// Request starts in this window, including pre-dispatch rejection.
pub started: Option<u64>,
/// Requests still alive at the cut; absent after accounting overflow.
pub in_flight: Option<u64>,
/// Per-class completions in ForegroundClass::ALL order.
pub classes: [Option<ClassCompletions>; 4],
/// False after a counter overflow or an inconsistent event count.
pub valid: bool,
}
struct ClassBank {
latencies: [AtomicU64; BUCKETS],
failed: AtomicU64,
cancelled: AtomicU64,
}
impl ClassBank {
fn new() -> Self {
Self {
latencies: std::array::from_fn(|_| AtomicU64::new(0)),
failed: AtomicU64::new(0),
cancelled: AtomicU64::new(0),
}
}
}
struct Bank {
writers: AtomicUsize,
started: AtomicU64,
overflowed: AtomicBool,
classes: [ClassBank; 4],
}
impl Bank {
fn new() -> Self {
Self {
writers: AtomicUsize::new(0),
started: AtomicU64::new(0),
overflowed: AtomicBool::new(false),
classes: std::array::from_fn(|_| ClassBank::new()),
}
}
fn increment(&self, counter: &AtomicU64) {
if counter.fetch_add(1, Ordering::Relaxed) == u64::MAX {
self.overflowed.store(true, Ordering::Relaxed);
}
}
}
struct Writer<'a>(&'a Bank);
impl Drop for Writer<'_> {
fn drop(&mut self) {
self.0.writers.fetch_sub(1, Ordering::SeqCst);
}
}
/// One process-wide recorder. Its fixed storage is allocated with the owner.
/// A request holds only a reference, timestamps and bounded enum state.
pub struct ForegroundRecorder {
created_at: Instant,
epoch: AtomicU64,
reader_taken: AtomicBool,
banks: [Bank; 2],
}
impl Default for ForegroundRecorder {
fn default() -> Self {
Self::new()
}
}
impl ForegroundRecorder {
/// Construct empty accounting for one lifetime of the owning process.
pub fn new() -> Self {
Self {
created_at: Instant::now(),
epoch: AtomicU64::new(0),
reader_taken: AtomicBool::new(false),
banks: std::array::from_fn(|_| Bank::new()),
}
}
fn writer(&self) -> Writer<'_> {
loop {
let epoch = self.epoch.load(Ordering::SeqCst);
let bank = &self.banks[usize::from(epoch & 1 != 0)];
bank.writers.fetch_add(1, Ordering::SeqCst);
let writer = Writer(bank);
// Sequential ordering joins registration, the reader's cut, and
// its zero-writer check. A late old-epoch registrant cannot write
// into a bank that the reader has begun draining or reusing.
if self.epoch.load(Ordering::SeqCst) == epoch {
return writer;
}
}
}
/// Begin at the external service-call boundary. Drop records cancellation
/// unless finish has already recorded a response-body outcome.
pub fn start(&self) -> ForegroundObservation<'_> {
let started_at = Instant::now();
let writer = self.writer();
writer.0.increment(&writer.0.started);
ForegroundObservation {
recorder: self,
started_at: Some(started_at),
class: ForegroundClass::Other,
}
}
fn finish(&self, class: ForegroundClass, elapsed: Duration, outcome: CompletionOutcome) {
let writer = self.writer();
let bank = writer.0;
let class = &bank.classes[class.index()];
if outcome == CompletionOutcome::Cancelled {
bank.increment(&class.cancelled);
return;
}
let bucket = latency_bucket(elapsed);
bank.increment(&class.latencies[bucket]);
if outcome == CompletionOutcome::Failed {
bank.increment(&class.failed);
}
}
/// Claim the sole reader. It cannot be replaced after being dropped:
/// resetting its carried in-flight count would falsify later windows.
pub fn take_reader(&self) -> Option<ForegroundReader<'_>> {
self.reader_taken
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.ok()?;
Some(ForegroundReader {
recorder: self,
start: WindowBoundary {
earliest: self.created_at,
latest: self.created_at,
},
retired: None,
in_flight: Some(0),
})
}
}
/// Non-cloneable request ownership, moved from the service future to its body.
pub struct ForegroundObservation<'a> {
recorder: &'a ForegroundRecorder,
started_at: Option<Instant>,
class: ForegroundClass,
}
impl ForegroundObservation<'_> {
/// Set the first dispatched operation's bounded category.
pub fn set_class(&mut self, class: ForegroundClass) {
self.class = class;
}
/// Finish once. Later calls or Drop do not produce another observation.
pub fn finish(&mut self, outcome: CompletionOutcome) {
if let Some(started_at) = self.started_at.take() {
self.recorder.finish(self.class, started_at.elapsed(), outcome);
}
}
}
impl Drop for ForegroundObservation<'_> {
fn drop(&mut self) {
self.finish(CompletionOutcome::Cancelled);
}
}
/// Single-reader state; never acquired by a request path.
pub struct ForegroundReader<'a> {
recorder: &'a ForegroundRecorder,
start: WindowBoundary,
retired: Option<(usize, WindowBoundary)>,
in_flight: Option<u64>,
}
impl ForegroundReader<'_> {
/// Cut a new window, or finish publishing an earlier cut. A live retired
/// writer returns None without blocking, discarding data or moving its cut.
pub fn sample(&mut self) -> Option<ForegroundWindow> {
if self.retired.is_none() {
let epoch = self.recorder.epoch.load(Ordering::SeqCst);
let next = epoch.checked_add(1)?;
let earliest = Instant::now();
self.recorder.epoch.store(next, Ordering::SeqCst);
let cut = WindowBoundary {
earliest,
latest: Instant::now(),
};
self.retired = Some((usize::from(epoch & 1 != 0), cut));
}
let (index, end) = self.retired?;
let bank = &self.recorder.banks[index];
if bank.writers.load(Ordering::SeqCst) != 0 {
return None;
}
let started = bank.started.swap(0, Ordering::Relaxed);
let overflowed = bank.overflowed.swap(false, Ordering::Relaxed);
let classes = std::array::from_fn(|index| {
let class = &bank.classes[index];
let failed = class.failed.swap(0, Ordering::Relaxed);
let cancelled = class.cancelled.swap(0, Ordering::Relaxed);
let buckets = std::array::from_fn::<_, BUCKETS, _>(|i| class.latencies[i].swap(0, Ordering::Relaxed));
let non_cancelled = buckets.iter().try_fold(0_u64, |sum, count| sum.checked_add(*count));
let finished = non_cancelled.and_then(|total| total.checked_add(cancelled));
let finished = finished.filter(|_| !overflowed && non_cancelled.is_some_and(|total| failed <= total))?;
Some(ClassCompletions {
finished,
failed,
cancelled,
p99: non_cancelled
.filter(|total| *total >= 100)
.and_then(|total| percentile_99(&buckets, total)),
})
});
let started = (!overflowed).then_some(started);
let finished = classes
.iter()
.try_fold(0_u64, |sum, class| sum.checked_add(class.as_ref()?.finished));
self.in_flight = self
.in_flight
.zip(finished)
.and_then(|(active, finished)| active.checked_add(started?)?.checked_sub(finished));
let window = ForegroundWindow {
start: self.start,
end,
started,
in_flight: self.in_flight,
classes,
valid: self.in_flight.is_some(),
};
self.start = end;
self.retired = None;
Some(window)
}
}
fn latency_bucket(elapsed: Duration) -> usize {
let micros = elapsed.as_nanos().div_ceil(1_000);
let Ok(micros) = u64::try_from(micros) else {
return FINITE_BUCKETS;
};
if micros > MAX_MICROS {
return FINITE_BUCKETS;
}
if micros < SUB_BUCKETS as u64 {
return usize::try_from(micros).unwrap_or(FINITE_BUCKETS);
}
let shift = 63 - micros.leading_zeros() - 6;
usize::try_from((u64::from(shift) + 1) * SUB_BUCKETS as u64 + (micros >> shift) - SUB_BUCKETS as u64)
.unwrap_or(FINITE_BUCKETS)
}
fn bucket_bounds(index: usize) -> LatencyBounds {
if index == FINITE_BUCKETS {
return LatencyBounds {
lower_nanos: MAX_MICROS * 1_000 + 1,
upper_nanos: None,
};
}
let (lower, upper) = if index < SUB_BUCKETS {
(index as u64, index as u64)
} else {
let shift = index / SUB_BUCKETS - 1;
let lower = (SUB_BUCKETS + index % SUB_BUCKETS) as u64 * (1_u64 << shift);
(lower, lower + (1_u64 << shift) - 1)
};
LatencyBounds {
lower_nanos: lower.saturating_sub(1) * 1_000 + u64::from(lower != 0),
upper_nanos: Some(upper * 1_000),
}
}
fn percentile_99(buckets: &[u64; BUCKETS], total: u64) -> Option<LatencyBounds> {
let rank = (u128::from(total) * 99).div_ceil(100);
let mut cumulative = 0_u128;
for (index, count) in buckets.iter().enumerate() {
cumulative += u128::from(*count);
if cumulative >= rank {
return Some(bucket_bounds(index));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn percentile_intervals_contain_each_bucket_edge_and_neighbour() {
for index in 0..BUCKETS {
let bounds = bucket_bounds(index);
assert_eq!(latency_bucket(Duration::from_nanos(bounds.lower_nanos)), index);
if let Some(upper) = bounds.upper_nanos {
assert!(upper >= bounds.lower_nanos);
assert_eq!(latency_bucket(Duration::from_nanos(upper)), index);
assert_eq!(latency_bucket(Duration::from_nanos(upper + 1)), index + 1);
}
}
assert_eq!(latency_bucket(Duration::MAX), FINITE_BUCKETS);
}
#[test]
fn body_ownership_carries_in_flight_counts_across_windows() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
let mut request = recorder.start();
request.set_class(ForegroundClass::Read);
let first = reader.sample().expect("cut with a live request");
assert!(first.valid);
assert_eq!(first.started, Some(1));
assert_eq!(first.in_flight, Some(1));
assert_eq!(first.classes[0].expect("valid read count").finished, 0);
request.finish(CompletionOutcome::Success);
request.finish(CompletionOutcome::Failed);
drop(request);
let second = reader.sample().expect("body finished");
assert!(second.valid);
assert_eq!(second.started, Some(0));
assert_eq!(second.in_flight, Some(0));
let read = second.classes[0].expect("valid read summary");
assert_eq!((read.finished, read.failed, read.cancelled, read.p99), (1, 0, 0, None));
assert_eq!(first.end.earliest, second.start.earliest);
assert_eq!(first.end.latest, second.start.latest);
}
#[test]
fn cancellations_are_counted_without_fabricating_success_latencies() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
for _ in 0..150 {
let mut request = recorder.start();
request.set_class(ForegroundClass::Write);
}
let window = reader.sample().expect("cancelled requests");
let write = window.classes[1].expect("known write count");
assert!(window.valid);
assert_eq!(window.in_flight, Some(0));
assert_eq!((write.finished, write.cancelled, write.failed, write.p99), (150, 150, 0, None));
}
#[test]
fn delayed_retired_writer_preserves_the_original_cut_and_next_bank() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
let writer = recorder.writer();
assert!(reader.sample().is_none(), "never read a live writer's bank");
let original_cut = reader.retired.expect("pending cut").1;
let request = recorder.start(); // a start after the cut belongs to the new bank
writer.0.increment(&writer.0.started);
drop(writer);
let previous = reader.sample().expect("retired writer drained");
assert_eq!(previous.end.earliest, original_cut.earliest);
assert_eq!(previous.end.latest, original_cut.latest);
assert_eq!(previous.started, Some(1));
assert_eq!(previous.in_flight, Some(1));
drop(request);
let current = reader.sample().expect("next bank");
assert_eq!(current.started, Some(1));
assert_eq!(current.in_flight, Some(1));
assert_eq!(current.classes[3].expect("unclassified cancellation").cancelled, 1);
}
#[test]
fn completed_percentile_uses_rank_and_never_turns_maximum_into_p99() {
let recorder = ForegroundRecorder::new();
let mut reader = recorder.take_reader().expect("first reader");
for i in 0..100 {
let writer = recorder.writer();
writer.0.increment(&writer.0.started);
drop(writer);
recorder.finish(
ForegroundClass::Read,
if i == 99 {
Duration::from_secs(60)
} else {
Duration::from_millis(2)
},
if i == 99 {
CompletionOutcome::Failed
} else {
CompletionOutcome::Success
},
);
}
let window = reader.sample().expect("complete window");
let read = window.classes[0].expect("read summary");
assert_eq!((read.finished, read.failed, read.cancelled), (100, 1, 0));
let p99 = read.p99.expect("100 non-cancelled samples");
assert!(p99.lower_nanos <= 2_000_000);
assert!(p99.upper_nanos.is_some_and(|upper| (2_000_000..3_000_000).contains(&upper)));
}
#[test]
fn overflow_stays_unknown_and_reader_cannot_reset_event_accounting() {
let recorder = ForegroundRecorder::new();
{
let mut reader = recorder.take_reader().expect("first reader");
recorder.banks[0].started.store(u64::MAX, Ordering::Relaxed);
drop(recorder.start());
let invalid = reader.sample().expect("overflow window");
assert!(!invalid.valid);
assert_eq!(invalid.started, None);
assert_eq!(invalid.in_flight, None);
assert_eq!(invalid.classes, [None; 4]);
let later = reader.sample().expect("later empty window");
assert!(!later.valid, "a lost carried count cannot recover by inventing zero active work");
}
assert!(recorder.take_reader().is_none(), "reader replacement would lose live request ownership");
}
#[test]
fn concurrent_rotation_preserves_every_start_finish_and_cancellation() {
let recorder = Arc::new(ForegroundRecorder::new());
let mut reader = recorder.take_reader().expect("first reader");
let workers: Vec<_> = (0..4)
.map(|_| {
let recorder = Arc::clone(&recorder);
std::thread::spawn(move || {
for index in 0..10_000 {
let mut request = recorder.start();
request.set_class(ForegroundClass::Read);
if index % 10 != 0 {
request.finish(CompletionOutcome::Success);
}
}
})
})
.collect();
let (mut started, mut finished, mut cancelled) = (0, 0, 0);
let mut add_window = |window: ForegroundWindow| {
assert!(window.valid, "concurrent event cuts must stay balanced");
started += window.started.expect("valid start count");
let reads = window.classes[0].expect("valid read count");
finished += reads.finished;
cancelled += reads.cancelled;
};
while workers.iter().any(|worker| !worker.is_finished()) {
if let Some(window) = reader.sample() {
add_window(window);
}
std::thread::yield_now();
}
for worker in workers {
worker.join().expect("writer must finish");
}
add_window(reader.sample().expect("drain after writers join"));
// A previously retired window may have been published by the last
// sample; cut once more to include the current, still open bank.
let final_window = reader.sample().expect("final open bank");
assert_eq!(final_window.in_flight, Some(0));
add_window(final_window);
assert_eq!((started, finished, cancelled), (40_000, 40_000, 4_000));
}
}
-2
View File
@@ -36,10 +36,8 @@
// Re-exported io-core type used by downstream timeout implementations.
pub use rustfs_io_core::OperationProgress;
pub mod adaptive;
mod backpressure;
mod deadlock;
pub mod foreground;
mod queue;
pub mod workers;
pub mod workload;
+2 -55
View File
@@ -49,28 +49,12 @@ impl Workers {
/// Acquire a worker slot, waiting until one becomes available.
pub async fn take(&self) {
struct AcquireCancelGuard<'a> {
workers: &'a Workers,
completed: bool,
}
impl Drop for AcquireCancelGuard<'_> {
fn drop(&mut self) {
if !self.completed {
self.workers.in_use.send_modify(|_| {});
}
}
}
let mut guard = AcquireCancelGuard {
workers: self,
completed: false,
};
let permit = match self.semaphore.acquire().await {
Ok(permit) => permit,
// The semaphore is owned by `self` and never closed.
Err(_) => unreachable!("worker semaphore is never closed"),
};
permit.forget(); // returned manually via give()
guard.completed = true;
self.in_use.send_modify(|held| *held += 1);
trace!(
event = "worker_slot.acquire",
@@ -98,7 +82,6 @@ impl Workers {
});
if released {
self.semaphore.add_permits(1);
self.in_use.send_modify(|_| {});
}
trace!(
event = "worker_slot.release",
@@ -116,9 +99,7 @@ impl Workers {
pub async fn wait(&self) {
let mut rx = self.in_use.subscribe();
// The sender is owned by `self`, so it outlives this borrow.
let _ = rx
.wait_for(|&held| held == 0 && self.semaphore.available_permits() == self.limit)
.await;
let _ = rx.wait_for(|&held| held == 0).await;
debug!(
event = "worker_slot.wait",
component = "concurrency",
@@ -133,9 +114,7 @@ impl Workers {
/// Return the current number of available worker slots.
pub async fn available(&self) -> usize {
self.limit
.saturating_sub(*self.in_use.borrow())
.min(self.semaphore.available_permits())
self.limit.saturating_sub(*self.in_use.borrow())
}
}
@@ -238,36 +217,4 @@ mod tests {
}
assert_eq!(workers.available().await, 0);
}
#[tokio::test]
async fn test_wait_does_not_complete_while_permit_is_in_transit_to_queued_taker() {
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
let workers = Workers::new(1).unwrap();
workers.take().await;
let mut taker = pin!(workers.take());
let mut cx = Context::from_waker(Waker::noop());
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Pending));
let mut waiter = pin!(workers.wait());
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
// Hand off the slot to the queued taker before `taker` is polled again.
workers.give().await;
assert_eq!(workers.available().await, 0);
assert!(
matches!(waiter.as_mut().poll(&mut cx), Poll::Pending),
"wait() must not complete while a permit is in transit to a woken taker"
);
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Ready(())));
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
workers.give().await;
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Ready(())));
assert_eq!(workers.available().await, 1);
}
}
+1 -16
View File
@@ -116,29 +116,14 @@ when restoring `/rustfs/console` for a custom OEM build.
- `RUSTFS_DATA_SCANNER_START_DELAY_SECS` (deprecated alias for compatibility)
- `RUSTFS_SCANNER_IDLE_MODE` (canonical)
- `RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS` (canonical)
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
- `RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS` (canonical)
- `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` (canonical)
- `RUSTFS_SCANNER_WALKERS_PER_DISK` (canonical)
Scanner cycle budget controls:
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
Scanner recovery retry controls:
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` caps the wait between automatic startup recovery retries. The persistent admin key is `scanner.recovery_backoff_cap`; the environment takes precedence over the persistent value, and the unset default remains 600 seconds (10 minutes). Values must be positive integer seconds within the platform timer range; `0`, malformed values, and overflow are rejected. A valid environment override does not hide an invalid persistent value from `admin config set` validation.
- The cap bounds the existing exponential retry schedule. Updating the persistent value applies to the next computed retry; it does not reschedule a wait already in progress. Retryable observations continue retrying after the short retry budget. Admin scanner status reports the effective value and source at `runtime_config.recovery_backoff_cap_seconds`.
Scanner walker controls:
- `RUSTFS_SCANNER_WALKERS_PER_DISK` sets how many bucket walkers each local disk of an erasure set runs; the default `1` walks one bucket per disk at a time, which is the right value for rotational media. On flash, `2` to `4` lets one disk walk several buckets concurrently, which matters most for single-disk deployments with many buckets, where the walk is otherwise fully serial. Values outside `1` through `16` are rejected by `admin config set`; an out-of-range or non-numeric environment value is logged as a scanner runtime config error (`state = "startup_apply_failed"`) and the scanner keeps the default. The walkers of one disk share that disk's scanner admission, so peers still see one scan session per physical disk, and `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` still caps the total.
- The same value is available as the persistent `scanner.walkers_per_disk` key and is reported under `runtime_config.walkers_per_disk` in the admin scanner status; see [Scanner Runtime Controls](../../docs/operations/scanner-runtime-controls.md).
- A timed-out cycle cancels cooperative scanner work, then fences its leader epoch before releasing the lease. An uncooperative I/O operation is dropped after the bounded shutdown window; its cursor is not claimed to be durable and the scanner reports `recovery-required` when the worker cannot stop cooperatively, the cycle state was not confirmed durable, or epoch fencing cannot be persisted.
## Mmap read environment aliases
@@ -281,7 +266,7 @@ Drive timeout profile preset:
- `warn` (default): log warning and continue startup.
- `fail`: abort startup with an error.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard. On Windows, endpoints on network drives (UNC shares such as `\\server\share` and mapped drive letters) are treated the same way, even though Windows reports the server's filesystem (usually `NTFS`) for them.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard.
## 📄 License
+3 -25
View File
@@ -69,17 +69,15 @@ pub const ENV_CONSOLE_RATE_LIMIT_RPM: &str = "RUSTFS_CONSOLE_RATE_LIMIT_RPM";
pub const DEFAULT_CONSOLE_RATE_LIMIT_RPM: u32 = 100;
/// Set the console authentication timeout in seconds
/// Bounds how long the console waits for a single authenticated HTTP request before it is
/// considered timed out. This is a request timeout, not the session lifetime: the Console
/// credential lifetime issued by the OIDC callback is `RUSTFS_CONSOLE_OIDC_SESSION_DURATION`.
/// Specifies how long a console authentication session remains valid
/// Default: 3600 seconds (1 hour)
/// Minimum: 300 seconds (5 minutes)
/// Maximum: 86400 seconds (24 hours)
pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT";
/// Default console authentication timeout in seconds
/// This is the default timeout for console authentication requests.
/// After this timeout, a pending console request is abandoned.
/// This is the default timeout for console authentication sessions.
/// After this timeout, users need to re-authenticate to access the console.
/// Default value: 3600 seconds (1 hour)
/// Environment variable: RUSTFS_CONSOLE_AUTH_TIMEOUT
/// Command line argument: --console-auth-timeout
@@ -87,26 +85,6 @@ pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT";
/// Example: --console-auth-timeout 3600
pub const DEFAULT_CONSOLE_AUTH_TIMEOUT: u64 = 3600;
/// Console credential lifetime, in seconds, for Console credentials issued by the OIDC callback.
///
/// Process-level, Console-scoped setting: it applies to every OIDC provider and deliberately
/// lives outside the per-provider `RUSTFS_IDENTITY_OPENID_*` namespace, so it is never
/// suffix-scanned into a provider id and has no `identity_openid` KVS equivalent. The same value
/// also sets how long the Console logout token stays valid.
pub const ENV_CONSOLE_OIDC_SESSION_DURATION: &str = "RUSTFS_CONSOLE_OIDC_SESSION_DURATION";
/// Default Console credential lifetime in seconds, used when the environment variable is unset,
/// not a positive integer, or zero. Matches the value this path hard-coded before it was
/// parameterised.
pub const DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS: usize = 3600;
/// Upper bound on the Console credential lifetime in seconds (7 days).
///
/// Values above this are clamped with a warning. The bound keeps the moka logout cache's
/// `time_to_live` well under its 1000-year assertion, and stops an operator from minting an
/// effectively permanent Console STS credential on every node.
pub const CONSOLE_OIDC_SESSION_DURATION_MAX_SECS: usize = 604_800;
/// Toggle update check
/// It controls whether to check for newer versions of rustfs
/// Default value: true
-4
View File
@@ -197,10 +197,6 @@ pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
/// Default behavior keeps the MRF intent pipeline enabled.
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
/// Opt in to lifecycle V2 transitions after qualifying every reader and the rollback binary.
pub const ENV_MRF_LIFECYCLE_V2_ENABLE: &str = "RUSTFS_MRF_LIFECYCLE_V2_ENABLE";
pub const DEFAULT_MRF_LIFECYCLE_V2_ENABLE: bool = false;
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
-14
View File
@@ -60,20 +60,6 @@ pub const DEFAULT_HEALTH_LOCK_ONLINE_TIMEOUT_MS: u64 = 1000;
pub const ENV_STARTUP_READINESS_MAX_WAIT_SECS: &str = "RUSTFS_STARTUP_READINESS_MAX_WAIT_SECS";
pub const DEFAULT_STARTUP_READINESS_MAX_WAIT_SECS: u64 = 120;
/// Wait for peer re-admission before a distributed node publishes startup
/// readiness.
///
/// After this node reaches local readiness it asks the peers in each erasure
/// set it serves, over the internode RPC channel, whether they count its
/// drives as online. Startup readiness is published once a strict majority of
/// each set's peers do, or when the startup budget
/// ([`ENV_STARTUP_READINESS_MAX_WAIT_SECS`]) elapses, or when unreachable or
/// older peers make that majority impossible. The gate always fails open; it
/// never affects steady-state `/health/ready` or request handling. Disable it
/// to restore the local-view-only startup readiness.
pub const ENV_STARTUP_PEER_READMISSION_ENABLE: &str = "RUSTFS_STARTUP_PEER_READMISSION_ENABLE";
pub const DEFAULT_STARTUP_PEER_READMISSION_ENABLE: bool = true;
/// Enable minimal health payload mode for GET `/health*` responses.
/// When enabled, only `status` and `ready` fields are returned.
pub const ENV_HEALTH_MINIMAL_RESPONSE_ENABLE: &str = "RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE";
+3 -7
View File
@@ -242,7 +242,7 @@ const _: () = assert!(!DEFAULT_INTERNODE_OFFLINE_BYPASS);
/// read-after-write window can erode the read quorum and surface as a spurious
/// `InsufficientReadQuorum` (issue #2761). One bounded retry absorbs that transient failure.
/// **Only** idempotent reads use this; write/lock RPCs (`WriteAll`/`RenameData`/`Delete*`/`Lock`
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `AGENTS.md`).
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `CLAUDE.md`).
pub const ENV_INTERNODE_IDEMPOTENT_READ_RETRIES: &str = "RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES";
pub const DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES: usize = 1;
@@ -276,9 +276,6 @@ pub const DEFAULT_INTERNODE_BULK_CHANNELS: usize = 2;
pub const ENV_INTERNODE_HTTP_TUNING_PROFILE: &str = "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE";
pub const DEFAULT_INTERNODE_HTTP_TUNING_PROFILE: &str = "legacy";
/// Internode HTTP data-plane protocol: `http1` (default) or `auto` (TLS ALPN).
pub const ENV_INTERNODE_HTTP_PROTOCOL: &str = "RUSTFS_INTERNODE_HTTP_PROTOCOL";
/// Internode HTTP connection pool maximum idle connections per host.
pub const ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST: &str = "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST";
@@ -290,9 +287,8 @@ pub const ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_HT
/// NOTE (backlog#805-C3): all `RUSTFS_INTERNODE_HTTP2_*` tuning (window sizes,
/// adaptive window, keepalive timeout) only takes effect when the internode
/// connection actually negotiates HTTP/2, which happens via TLS-ALPN. Over
/// plaintext internode transport or with the default `http1` protocol policy,
/// these settings are inert. Select `RUSTFS_INTERNODE_HTTP_PROTOCOL=auto` and
/// enable internode TLS to use them.
/// plaintext internode transport the connection is HTTP/1.1 and these settings
/// are silently inert — enable internode TLS to use them.
pub const ENV_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE";
/// Internode HTTP/2 initial connection window size in bytes.
-66
View File
@@ -53,24 +53,12 @@ pub const SCANNER_IDLE_MODE: &str = "idle_mode";
/// Scanner config key controlling scanner cache save timeout in seconds.
pub const SCANNER_CACHE_SAVE_TIMEOUT: &str = "cache_save_timeout";
/// Scanner config key capping automatic recovery retry waits in seconds.
pub const SCANNER_RECOVERY_BACKOFF_CAP: &str = "recovery_backoff_cap";
/// Scanner config key capping concurrent scanner set tasks.
pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
/// Scanner config key capping concurrent scanner disk bucket walks per set.
pub const SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "max_concurrent_disk_scans";
/// Scanner config key setting how many bucket walkers each local disk runs.
pub const SCANNER_WALKERS_PER_DISK: &str = "walkers_per_disk";
/// Per-local-disk scanner I/O ceiling in operations per second.
pub const SCANNER_DISK_IO_BUDGET_IOPS: &str = "disk_io_budget_iops";
/// Per-local-disk scanner I/O ceiling in bytes per second.
pub const SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "disk_io_budget_bytes_per_sec";
/// Scanner config key controlling how often object loops yield.
pub const SCANNER_YIELD_EVERY_N_OBJECTS: &str = "yield_every_n_objects";
@@ -96,12 +84,8 @@ pub const SCANNER_KEYS: &[&str] = &[
SCANNER_BITROT_CYCLE,
SCANNER_IDLE_MODE,
SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_RECOVERY_BACKOFF_CAP,
SCANNER_MAX_CONCURRENT_SET_SCANS,
SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_WALKERS_PER_DISK,
SCANNER_DISK_IO_BUDGET_IOPS,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC,
SCANNER_YIELD_EVERY_N_OBJECTS,
SCANNER_ALERT_EXCESS_VERSIONS,
SCANNER_ALERT_EXCESS_VERSION_SIZE,
@@ -220,14 +204,6 @@ pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE
/// Default scanner cache save timeout in seconds.
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
/// Environment variable that caps automatic scanner recovery retry waits.
/// The value must be a positive integer number of seconds within the timer range.
/// - Example: `export RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS=600`
pub const ENV_SCANNER_RECOVERY_BACKOFF_CAP_SECS: &str = "RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS";
/// Default maximum wait between automatic scanner recovery retries.
pub const DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS: u64 = 10 * 60;
/// Environment variable that caps concurrent scanner set tasks.
/// A value of `0` keeps the existing topology-based concurrency.
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS=2`
@@ -238,18 +214,6 @@ pub const ENV_SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCU
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS=1`
pub const ENV_SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS";
/// Environment variable that sets how many bucket walkers each local disk runs.
/// The walkers of one disk share its scanner admission, so a peer still sees one
/// scan session per physical disk. Remote disks keep one walker. The product of
/// local disks and this factor is what `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS`
/// caps; the disk count stays the floor. Valid range is `1` through `16`.
/// - Example: `export RUSTFS_SCANNER_WALKERS_PER_DISK=4`
pub const ENV_SCANNER_WALKERS_PER_DISK: &str = "RUSTFS_SCANNER_WALKERS_PER_DISK";
pub const ENV_SCANNER_DISK_IO_BUDGET_IOPS: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_IOPS";
pub const ENV_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC";
/// Environment variable that controls how often scanner object loops yield to the async runtime.
/// A value of `0` disables this extra object-count yield.
/// - Example: `export RUSTFS_SCANNER_YIELD_EVERY_N_OBJECTS=32`
@@ -264,36 +228,6 @@ pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
/// Default disk scan concurrency budget.
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
/// Default bucket walkers per local disk.
///
/// One walker per disk keeps today's I/O profile, which is what rotational
/// media needs. Flash is meant to default higher once a local disk can report
/// its media type; `rustfs_utils::os::Info::rotational` is never populated
/// today, so every deployment starts from one walker.
pub const DEFAULT_SCANNER_WALKERS_PER_DISK: usize = 1;
/// Upper bound for `walkers_per_disk`; a larger factor only multiplies
/// contention on one spindle or NVMe queue without more disks to spread it.
pub const MAX_SCANNER_WALKERS_PER_DISK: usize = 16;
/// Default spelling of both per-disk scanner I/O budget keys: resolve the
/// rate from the detected media. `off` removes the cap; a number is explicit.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET: &str = "auto";
/// Rotational media budget, also used when media cannot be detected: about
/// half of a 7200 rpm spindle's random reads stay available to foreground
/// requests, and the byte ceiling sits well above the steady ~2 MB/s a paced
/// single-disk scan was measured at once admission landed (rustfs/rustfs#8377).
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_IOPS: u64 = 100;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_BYTES_PER_SEC: u64 = 16 * 1024 * 1024;
/// Flash media budget: a few percent of a SATA SSD's random-read capacity,
/// enough to keep metadata walks far from saturating the device.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_IOPS: u64 = 4000;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_BYTES_PER_SEC: u64 = 256 * 1024 * 1024;
/// Default object interval for cooperative scanner yields.
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
-75
View File
@@ -569,25 +569,6 @@ pub struct DataUsageInfo {
/// which last-known-good generation contributed to the view.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_snapshot_set_states: Vec<DataUsageSnapshotSetState>,
/// Buckets whose dirty-usage generation moved while this complete
/// snapshot was being walked. Their `buckets_usage` values are the walk
/// result, which the cycle could not certify as current; the next cycle
/// rescans them. Sorted. Empty on a converged snapshot and on an
/// observational one. A bucket created during the walk may be listed
/// here without a `buckets_usage` entry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_snapshot_pending_buckets: Vec<String>,
/// Length of `usage_snapshot_pending_buckets`, carried for readers that
/// only need the count. The scanner writes both from one set.
#[serde(default, skip_serializing_if = "u64_is_zero")]
pub usage_snapshot_pending_bucket_count: u64,
/// Set by the scanner on a snapshot whose authoritative publication was
/// vetoed or could not be verified. It is routed to the observed object
/// and never replaces the authoritative snapshot. Absent on snapshots
/// written before per-bucket certification, which keep
/// `usage_snapshot_converged == Some(false)` as their observed marker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub usage_snapshot_observational: bool,
/// An observational view may contain only the sets that completed this
/// cycle (or retained a compatible last-known-good cache).
#[serde(default)]
@@ -603,10 +584,6 @@ pub struct DataUsageInfo {
pub disk_usage_status: Vec<DiskUsageStatus>,
}
fn u64_is_zero(value: &u64) -> bool {
*value == 0
}
/// Stable identity fields changed by both coordinated scanner publication and
/// backward-compatible bucket namespace cleanup.
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -3034,58 +3011,6 @@ mod tests {
assert_eq!(current.usage_snapshot_converged, Some(false));
}
#[test]
fn pending_bucket_certification_fields_round_trip_and_stay_additive() {
let certified = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(false),
usage_snapshot_pending_buckets: vec!["hot-a".to_string(), "hot-b".to_string()],
usage_snapshot_pending_bucket_count: 2,
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&certified).expect("encode certified snapshot");
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode certified snapshot");
assert_eq!(decoded, certified);
assert!(!decoded.usage_snapshot_observational);
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore pending fields");
assert_eq!(legacy.buckets_count, 0);
let observed = DataUsageInfo {
usage_snapshot_observational: true,
..certified
};
let encoded = rmp_serde::to_vec_named(&observed).expect("encode observed snapshot");
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode observed snapshot");
assert!(decoded.usage_snapshot_observational);
// Older snapshots carry none of the fields and decode to the defaults;
// a converged snapshot writes none of them (named encoding carries
// field names, so a skipped field leaves no trace in the bytes).
let converged = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(true),
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&converged).expect("encode converged snapshot");
for key in [
"usage_snapshot_pending_buckets",
"usage_snapshot_pending_bucket_count",
"usage_snapshot_observational",
] {
assert!(
!encoded.windows(key.len()).any(|window| window == key.as_bytes()),
"{key} must be skipped when empty"
);
}
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode converged snapshot");
assert!(decoded.usage_snapshot_pending_buckets.is_empty());
assert_eq!(decoded.usage_snapshot_pending_bucket_count, 0);
assert!(!decoded.usage_snapshot_observational);
assert!(decoded.is_complete_bucket_usage_snapshot());
}
#[test]
fn convergence_marker_defaults_to_unknown_for_older_snapshots() {
let encoded = rmp_serde::to_vec_named(&DataUsageInfo {
+1 -5
View File
@@ -107,7 +107,7 @@ flate2.workspace = true
http.workspace = true
http-body-util.workspace = true
hyper = { workspace = true, features = ["http2", "http1", "server"] }
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "client-legacy", "tracing"] }
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
rustfs-signer.workspace = true
@@ -146,8 +146,4 @@ zip.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
same-file.workspace = true
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
rustfs-heal.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["test-util"] }
+4 -5
View File
@@ -22,7 +22,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **functional** | top-level `*_test.rs` | S3 data plane: `list_objects_*`, `copy_object_*`, `delete_objects_versioning`, `head_object_*`, `checksum_upload`, `compression`, `content_encoding`, `special_chars`, `leading_slash_key`, `create_bucket_region`, `quota`, `data_usage`, `snowball_auto_extract`, `mc_mirror_small_bucket`, `archive_download_integrity`, `version_id_regression`, `delete_marker_migration_semantics` |
| **object_lock** | [`src/object_lock/`](src/object_lock) | Retention / legal-hold / WORM semantics |
| **kms** | [`src/kms/`](src/kms) | SSE-S3 / SSE-KMS / SSE-C, local + Vault backends, multipart encryption. Own guide: [`src/kms/README.md`](src/kms/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `conditional_write_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
@@ -200,11 +200,10 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest 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
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
# Distributed 4-node 4-disk lane uses the fault-test hooks server; the
# scanner_heal_topology_ fault cases fail closed without it.
# Distributed 4-node 4-disk lane uses the default server.
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
python3 scripts/e2e_binary.py build
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
# Replication nightly uses the default server; awscurl is required for STS.
python3 scripts/e2e_binary.py build
-4
View File
@@ -29,10 +29,6 @@
//! 3. Starting with the default `rustfsadmin` credentials emits the
//! default-credentials startup warning (observable operator signal).
// Keep inventory acceptance in the admin-auth PR smoke selection.
#[cfg(test)]
mod target_inventory_test;
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
@@ -1,205 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Exercise the target inventory through HTTP authentication and authorization.
//! Handler-level tests separately control reconciliation locks and config faults.
use crate::common::{
RustFSTestEnvironment, admin_create_user, admin_request, admin_request_with_session_token, build_test_sts_client,
init_logging, local_http_client,
};
use http::header::{AUTHORIZATION, HOST};
use reqwest::StatusCode;
use rustfs_signer::{constants::UNSIGNED_PAYLOAD, sign_v4};
use s3s::Body;
use serde_json::Value;
use std::{error::Error, time::Duration};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const SECRET: &str = "inventory-acceptance-token";
const LIMITED_KEY: &str = "inventory-limited";
const LIMITED_SECRET: &str = "inventory-limited-secret";
const PATHS: [&str; 5] = [
"/rustfs/admin/v3/target/list",
"/rustfs/admin/v3/audit/target/list",
"/rustfs/admin/v4/plugins/instances",
"/rustfs/admin/v4/plugins/instances/builtin:webhook:notify:acceptance",
"/rustfs/admin/v4/extensions/instances",
];
async fn duplicate_authorization(
env: &RustFSTestEnvironment,
path: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}{path}", env.url);
let uri: http::Uri = url.parse()?;
let authority = uri.authority().ok_or("inventory URL missing authority")?.to_string();
let request = http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
.body(Body::empty())?;
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
let mut headers = signed.headers().clone();
let authorization = headers.get(AUTHORIZATION).ok_or("signer omitted authorization")?.clone();
headers.append(AUTHORIZATION, authorization);
assert_eq!(headers.get_all(AUTHORIZATION).iter().count(), 2);
Ok(local_http_client().get(url).headers(headers).send().await?)
}
fn assert_unloaded_target(entry: &Value, path: &str) {
assert_eq!(entry["service"], "webhook", "{path}: unexpected target service");
assert!(
entry["account_id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance")),
"{path}: fixture target is missing"
);
assert_eq!(entry["status"], "offline", "{path}: unloaded targets must remain visible as offline");
if !path.contains("/extensions/") {
assert_eq!(entry["health_reason"], "not_loaded_in_runtime", "{path}: unexpected health reason");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn target_inventory_preserves_unloaded_targets_and_http_auth_boundaries() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
// Enabled destinations remain configured while both delivery modules are
// disabled, so inventory reads must not require a loaded target runtime.
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_NOTIFY_ENABLE", "false"),
("RUSTFS_AUDIT_ENABLE", "false"),
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
("RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
("RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
("RUSTFS_AUDIT_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
("RUSTFS_AUDIT_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
],
)
.await?;
admin_create_user(&env, LIMITED_KEY, LIMITED_SECRET).await?;
tokio::time::timeout(Duration::from_secs(60), async {
let assumed = build_test_sts_client(&env.url, &env.access_key, &env.secret_key, None, "e2e-target-inventory")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/target-inventory")
.role_session_name("target-inventory-console-session")
.send()
.await?;
let temporary = assumed
.credentials()
.ok_or("AssumeRole should return temporary inventory credentials")?;
assert_ne!(
temporary.access_key_id(),
env.access_key,
"inventory must exercise a distinct session credential"
);
assert!(
!temporary.session_token().is_empty(),
"temporary inventory credentials must include a token"
);
for path in PATHS {
for (principal, access_key, secret_key, session_token) in [
("root", env.access_key.as_str(), env.secret_key.as_str(), None),
(
"temporary admin",
temporary.access_key_id(),
temporary.secret_access_key(),
Some(temporary.session_token()),
),
] {
let (status, body) = admin_request_with_session_token(
&env.url,
http::Method::GET,
path,
None,
access_key,
secret_key,
session_token,
)
.await?;
assert_eq!(status, StatusCode::OK, "{path}: signed {principal} inventory failed");
assert!(!body.contains(SECRET), "{path}: inventory exposed a target secret");
let value: Value = serde_json::from_str(&body)?;
let entries: Vec<&Value> = if path.ends_with("/target/list") {
let field = if path.contains("/audit/") {
"audit_endpoints"
} else {
"notification_endpoints"
};
value[field].as_array().ok_or("missing endpoint inventory")?.iter().collect()
} else if path.ends_with("/instances") {
value["instances"]
.as_array()
.ok_or("missing instance inventory")?
.iter()
.collect()
} else {
vec![&value]
};
let targets: Vec<_> = entries
.into_iter()
.filter(|entry| {
entry["account_id"]
.as_str()
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance"))
})
.collect();
let expected = if path.ends_with("/instances") { 2 } else { 1 };
assert_eq!(targets.len(), expected, "{path}: configured targets disappeared");
for entry in &targets {
assert_unloaded_target(entry, path);
if path.contains("/v4/") {
assert_eq!(entry["config"]["auth_token"], "***redacted***", "{path}: missing secret redaction");
}
}
if expected == 2 {
for domain in ["notify", "audit"] {
assert!(targets.iter().any(|entry| entry["domain"] == domain), "{path}: missing {domain} domain");
}
}
}
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN, "{path}: unsigned request was not denied");
let body = unsigned.text().await?;
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
let ambiguous = duplicate_authorization(&env, path).await?;
assert_eq!(
ambiguous.status(),
StatusCode::FORBIDDEN,
"{path}: duplicate authorization was not rejected"
);
let body = ambiguous.text().await?;
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
let (status, body) = admin_request(&env.url, http::Method::GET, path, None, LIMITED_KEY, LIMITED_SECRET).await?;
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: principal without admin policy was not denied");
assert!(body.contains("<Code>AccessDenied</Code>"), "{path}: unexpected authorization error");
assert!(!body.contains("Signature is required") && !body.contains("SignatureDoesNotMatch"));
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
})
.await??;
env.stop_server();
Ok(())
}
+14 -168
View File
@@ -34,7 +34,7 @@ use rustfs_signer::sign_v4;
use s3s::Body;
use serde_json;
use std::fs as stdfs;
use std::io::{self, ErrorKind};
use std::io::ErrorKind;
use std::net::{Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
@@ -1037,22 +1037,6 @@ pub async fn awscurl_post_sts_form_urlencoded(
Ok(response)
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
/// Returns `(access_key, secret_key, session_token)` from an STS `AssumeRole` XML response.
pub fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let access_key = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let secret_key = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let session_token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((access_key, secret_key, session_token))
}
/// Helper function for POST requests
pub async fn awscurl_post(
url: &str,
@@ -1272,7 +1256,6 @@ pub struct RustFSTestClusterEnvironment {
/// Optional socket proxies used for the corresponding node's volume
/// endpoints. Proxies must be installed before [`Self::start`].
volume_proxy_addresses: Vec<Option<SocketAddr>>,
tls_ca_pem: Option<Vec<u8>>,
}
impl RustFSTestClusterEnvironment {
@@ -1313,10 +1296,7 @@ impl RustFSTestClusterEnvironment {
pub async fn with_topology(topology: ClusterTopology) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
topology.validate()?;
let temp_dir = std::env::temp_dir()
.join(format!("rustfs_cluster_test_{}", Uuid::new_v4()))
.to_string_lossy()
.into_owned();
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
fs::create_dir_all(&temp_dir).await?;
// Map every node to its owning pool index.
@@ -1378,35 +1358,9 @@ impl RustFSTestClusterEnvironment {
node_capture_log_paths: vec![None; topology.node_count],
topology,
volume_proxy_addresses: vec![None; node_count],
tls_ca_pem: None,
})
}
/// Configure verified HTTPS for both volume endpoints and S3 clients.
/// The bundle must be generated before any node starts.
pub async fn enable_tls(&mut self, directory: &Path) -> std::io::Result<()> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"configure cluster TLS before starting nodes",
));
}
let directory = fs::canonicalize(directory).await?;
let ca_pem = fs::read(directory.join("ca.crt")).await?;
reqwest::Certificate::from_pem(&ca_pem).map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
self.set_env(
"RUSTFS_TLS_PATH",
directory
.to_str()
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "TLS directory is not UTF-8"))?,
);
for node in &mut self.nodes {
node.url = format!("https://{}", node.address);
}
self.tls_ca_pem = Some(ca_pem);
Ok(())
}
/// Add an extra environment variable applied to every cluster node process.
pub fn set_env<K, V>(&mut self, key: K, value: V)
where
@@ -1497,29 +1451,6 @@ impl RustFSTestClusterEnvironment {
Ok(proxy)
}
/// Route internode traffic through a method-aware HTTP proxy.
pub(crate) async fn start_rpc_proxy_for_node(
&mut self,
node_idx: usize,
) -> io::Result<crate::rpc_fault_proxy::RpcFaultProxy> {
self.ensure_node_index(node_idx)
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
if self.volume_proxy_addresses[node_idx].is_some() {
return Err(io::Error::new(
ErrorKind::AlreadyExists,
format!("a volume proxy is already configured for node {node_idx}"),
));
}
let target = self.nodes[node_idx]
.address
.parse::<SocketAddr>()
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
let listen = SocketAddr::from((Ipv6Addr::LOCALHOST, target.port()));
let proxy = crate::rpc_fault_proxy::RpcFaultProxy::start_on(listen, target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy)
}
fn volume_address(&self, node_idx: usize) -> String {
self.volume_proxy_addresses[node_idx]
.map(|address| address.to_string())
@@ -1528,7 +1459,6 @@ impl RustFSTestClusterEnvironment {
fn build_volumes_arg(&self) -> String {
let pools = self.topology.normalized_pools();
let scheme = if self.tls_ca_pem.is_some() { "https" } else { "http" };
if pools.len() <= 1 {
// Single pool: explicit enumeration of every drive on every node.
@@ -1538,7 +1468,7 @@ impl RustFSTestClusterEnvironment {
.enumerate()
.flat_map(|(node_idx, n)| {
let address = self.volume_address(node_idx);
n.data_dirs.iter().map(move |dir| format!("{scheme}://{address}{dir}"))
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
})
.collect::<Vec<_>>()
.join(" ");
@@ -1558,7 +1488,7 @@ impl RustFSTestClusterEnvironment {
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
.unwrap_or(&node.data_dir);
format!(
"{scheme}://{}{}/drive{{0...{}}}",
"http://{}{}/drive{{0...{}}}",
self.volume_address(node_idx),
base,
self.topology.drives_per_node - 1
@@ -1747,20 +1677,13 @@ impl RustFSTestClusterEnvironment {
if node_idx >= self.nodes.len() {
return Err("node_idx is invalid".into());
}
let mut config =
build_test_s3_config(&self.nodes[node_idx].url, &self.access_key, &self.secret_key, None, "cluster-test");
if let Some(ca_pem) = &self.tls_ca_pem {
use aws_smithy_http_client::tls::{Provider, TlsContext, TrustStore, rustls_provider::CryptoMode};
let tls = TlsContext::builder()
.with_trust_store(TrustStore::empty().with_pem_certificate(ca_pem.clone()))
.build()?;
let client = SmithyHttpClientBuilder::new()
.tls_provider(Provider::Rustls(CryptoMode::AwsLc))
.tls_context(tls)
.build_https();
config = config.to_builder().http_client(client).build();
}
Ok(Client::from_conf(config))
Ok(Client::from_conf(build_test_s3_config(
&self.nodes[node_idx].url,
&self.access_key,
&self.secret_key,
None,
"cluster-test",
)))
}
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
@@ -1885,7 +1808,7 @@ impl RustFSTestClusterEnvironment {
}
self.nodes.push(ClusterNode {
url: format!("{}://{address}", if self.tls_ca_pem.is_some() { "https" } else { "http" }),
url: format!("http://{address}"),
address,
data_dir: data_dirs[0].clone(),
data_dirs,
@@ -1907,47 +1830,6 @@ impl RustFSTestClusterEnvironment {
Ok(new_idx)
}
/// Remove retired single-node pools from a stopped multi-pool cluster.
///
/// A decommissioned pool must leave the server command line before the
/// next start; the server refuses to start while it is still listed. The
/// remaining pools keep their node order and are renumbered from zero.
/// Data directories are left on disk for the owning harness to clean up.
pub fn remove_single_node_pools(&mut self, retired: &[usize]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err("stop the cluster before removing a pool".into());
}
let pools = self.topology.normalized_pools();
if pools.iter().any(|nodes| nodes.len() != 1) {
return Err("remove_single_node_pools requires one node per pool".into());
}
if retired.iter().any(|pool| *pool >= pools.len()) || retired.len() >= pools.len() {
return Err(format!("cannot remove pools {retired:?} from {} pools", pools.len()).into());
}
let keep: Vec<usize> = (0..self.nodes.len())
.filter(|node_idx| !retired.contains(&self.nodes[*node_idx].pool_idx))
.collect();
fn retain_indices<T>(values: &mut Vec<T>, keep: &[usize]) {
let old = std::mem::take(values);
*values = old
.into_iter()
.enumerate()
.filter(|(idx, _)| keep.contains(idx))
.map(|(_, value)| value)
.collect();
}
retain_indices(&mut self.nodes, &keep);
retain_indices(&mut self.node_extra_env, &keep);
retain_indices(&mut self.node_capture_log_paths, &keep);
retain_indices(&mut self.volume_proxy_addresses, &keep);
for (pool_idx, node) in self.nodes.iter_mut().enumerate() {
node.pool_idx = pool_idx;
}
self.topology.node_count = self.nodes.len();
self.topology.pools = (0..self.nodes.len()).map(|node_idx| vec![node_idx]).collect();
Ok(())
}
/// Gracefully stop one cluster node and wait for its process to exit.
///
/// This is intentionally separate from [`Self::stop_node`]: the latter is
@@ -2499,7 +2381,6 @@ mod tests {
node_capture_log_paths: vec![None; topology.node_count],
topology,
volume_proxy_addresses: vec![None; node_count],
tls_ca_pem: None,
}
}
@@ -2517,8 +2398,8 @@ mod tests {
);
}
#[tokio::test]
async fn volumes_single_pool_multidrive_enumerates_every_drive() {
#[test]
fn volumes_single_pool_multidrive_enumerates_every_drive() {
// 4 nodes x 2 drives -> 8 explicit endpoints, one legacy pool. No
// ellipses, so the server parser keeps this as a single DistErasure pool.
let env = fake_cluster(ClusterTopology::single_pool_multidrive(4, 2));
@@ -2530,25 +2411,6 @@ mod tests {
.join(" ");
assert_eq!(env.build_volumes_arg(), expected);
assert_eq!(env.build_volumes_arg().split(' ').count(), 8);
let directory = tempfile::tempdir().expect("TLS fixture directory");
crate::tls_gen::run(crate::tls_gen::Args {
out_dir: directory.path().to_owned(),
days: 1,
force: false,
})
.expect("generate TLS bundle");
for topology in [
ClusterTopology::single_pool_multidrive(4, 4),
ClusterTopology::per_node_pools(4, vec![vec![0], vec![1]]),
] {
let mut cluster = fake_cluster(topology);
let plaintext_volumes = cluster.build_volumes_arg();
cluster.enable_tls(directory.path()).await.expect("configure TLS");
assert_eq!(cluster.build_volumes_arg(), plaintext_volumes.replace("http://", "https://"));
assert!(cluster.nodes.iter().all(|node| node.url.starts_with("https://")));
assert!(cluster.create_s3_client(0).is_ok(), "trusted SDK client should build");
}
}
#[test]
@@ -2636,22 +2498,6 @@ mod tests {
proxy.shutdown().await;
}
#[tokio::test]
async fn cluster_data_dirs_use_os_temporary_directory() {
let cluster = RustFSTestClusterEnvironment::new(1).await.expect("allocate isolated cluster");
let root = PathBuf::from(&cluster.temp_dir);
assert!(
root.starts_with(std::env::temp_dir()),
"cluster must honor the selected temporary filesystem"
);
for node in &cluster.nodes {
assert!(Path::new(&node.data_dir).starts_with(&root));
assert!(node.data_dirs.iter().all(|drive| Path::new(drive).starts_with(&root)));
}
drop(cluster);
assert!(!root.exists(), "dropping the fixture must remove its owned directories");
}
#[test]
fn cluster_node_env_supports_per_node_overrides() {
let mut env = fake_cluster(ClusterTopology::single_pool(4));
@@ -1,381 +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.
//! S3 wire semantics for per-object conditional bulk deletion.
use crate::common::RustFSTestEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::operation::delete_objects::DeleteObjectsOutput;
use aws_sdk_s3::operation::put_object::PutObjectOutput;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use std::collections::BTreeSet;
use std::error::Error;
type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
type VersionSnapshot = BTreeSet<(String, bool)>;
async fn put(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult<PutObjectOutput> {
let result = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body))
.send()
.await?;
assert!(result.e_tag().is_some(), "fixture PUT must provide an ETag for the following conditions");
Ok(result)
}
fn identifier(key: &str, etag: Option<&str>, version: Option<&str>) -> TestResult<ObjectIdentifier> {
Ok(ObjectIdentifier::builder()
.key(key)
.set_e_tag(etag.map(str::to_owned))
.set_version_id(version.map(str::to_owned))
.build()?)
}
async fn delete_batch(
client: &Client,
bucket: &str,
objects: Vec<ObjectIdentifier>,
quiet: bool,
) -> TestResult<DeleteObjectsOutput> {
Ok(client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects)).quiet(quiet).build()?)
.send()
.await?)
}
async fn assert_body(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
let result = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(result.body.collect().await?.into_bytes().as_ref(), expected);
Ok(())
}
async fn assert_absent(client: &Client, bucket: &str, key: &str) {
let error = client
.head_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("deleted key must be absent");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("NotFound"));
}
async fn versions(client: &Client, bucket: &str, key: &str) -> TestResult<VersionSnapshot> {
let result = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert!(!result.is_truncated().unwrap_or_default(), "focused version fixture fits a single page");
let mut snapshot = BTreeSet::new();
for version in result.versions().iter().filter(|version| version.key() == Some(key)) {
snapshot.insert((version.version_id().expect("version has an identity").to_owned(), false));
}
for marker in result.delete_markers().iter().filter(|marker| marker.key() == Some(key)) {
snapshot.insert((marker.version_id().expect("delete marker has an identity").to_owned(), true));
}
Ok(snapshot)
}
#[tokio::test]
async fn sdk_conditional_delete_objects_mixed_results_overwrites_duplicates_and_quiet() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "conditional-bulk-mixed";
client.create_bucket().bucket(bucket).send().await?;
let old = put(&client, bucket, "overwritten", b"generation A").await?;
let current = put(&client, bucket, "overwritten", b"generation B").await?;
let matched = put(&client, bucket, "matched", b"matching object").await?;
put(&client, bucket, "exists", b"existence object").await?;
put(&client, bucket, "unconditional", b"unconditional object").await?;
put(&client, bucket, "empty", b"empty condition must preserve this").await?;
put(&client, bucket, "empty-quoted", b"quoted empty condition must preserve this").await?;
let weak = put(&client, bucket, "weak", b"weak condition must preserve this").await?;
let weak_etag = format!("W/\"{}\"", weak.e_tag().expect("PUT supplies an ETag").trim_matches('"'));
assert_ne!(old.e_tag(), current.e_tag(), "overwrite fixture must have distinct ETags");
let matched_etag = matched.e_tag().expect("PUT supplies an ETag");
let result = delete_batch(
&client,
bucket,
vec![
identifier("matched", Some(matched_etag), None)?,
identifier("overwritten", old.e_tag(), None)?,
identifier("matched", Some("stale-etag"), None)?,
identifier("matched", Some(matched_etag), None)?,
identifier("unconditional", None, None)?,
identifier("exists", Some("*"), None)?,
identifier("missing", Some("*"), None)?,
identifier("empty", Some(""), None)?,
identifier("empty-quoted", Some("\"\""), None)?,
identifier("weak", Some(&weak_etag), None)?,
identifier("unconditional-missing", None, None)?,
identifier("missing-specific", Some("opaque-etag"), None)?,
],
false,
)
.await?;
assert_eq!(
result.deleted().iter().map(|object| object.key()).collect::<Vec<_>>(),
[
Some("matched"),
Some("matched"),
Some("unconditional"),
Some("exists"),
Some("unconditional-missing")
],
"duplicate successful inputs retain their separate response entries"
);
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("overwritten"), Some("PreconditionFailed")),
(Some("matched"), Some("PreconditionFailed")),
(Some("missing"), Some("NoSuchKey")),
(Some("empty"), Some("InvalidArgument")),
(Some("empty-quoted"), Some("InvalidArgument")),
(Some("weak"), Some("InvalidArgument")),
(Some("missing-specific"), Some("NoSuchKey")),
]
);
assert_body(&client, bucket, "overwritten", b"generation B").await?;
assert_body(&client, bucket, "empty", b"empty condition must preserve this").await?;
assert_body(&client, bucket, "empty-quoted", b"quoted empty condition must preserve this").await?;
assert_body(&client, bucket, "weak", b"weak condition must preserve this").await?;
for key in ["matched", "unconditional", "exists"] {
assert_absent(&client, bucket, key).await;
}
let quiet = put(&client, bucket, "quiet-match", b"quiet matching object").await?;
put(&client, bucket, "quiet-rejected", b"quiet rejected object").await?;
let result = delete_batch(
&client,
bucket,
vec![
identifier("quiet-match", quiet.e_tag(), None)?,
identifier("quiet-match", quiet.e_tag(), None)?,
identifier("quiet-rejected", Some("stale-etag"), None)?,
identifier("missing", Some("*"), None)?,
],
true,
)
.await?;
assert!(result.deleted().is_empty(), "Quiet suppresses successful entries");
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("quiet-rejected"), Some("PreconditionFailed")),
(Some("missing"), Some("NoSuchKey"))
]
);
assert_absent(&client, bucket, "quiet-match").await;
assert_body(&client, bucket, "quiet-rejected", b"quiet rejected object").await?;
Ok(())
}
#[tokio::test]
async fn sdk_conditional_delete_objects_versioning_current_markers_and_explicit_versions() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
for suspended in [false, true] {
let bucket = if suspended {
"conditional-bulk-suspended"
} else {
"conditional-bulk-enabled"
};
client.create_bucket().bucket(bucket).send().await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let historical = put(&client, bucket, "history", b"historical payload").await?;
let invalid_historical = put(&client, bucket, "invalid", b"historical rejected payload").await?;
if suspended {
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
}
let current = put(&client, bucket, "history", b"current payload").await?;
let invalid = put(&client, bucket, "invalid", b"current rejected payload").await?;
let invalid_before = versions(&client, bucket, "invalid").await?;
let historical_id = historical.version_id().expect("enabled fixture has a UUID version");
let invalid_historical_id = invalid_historical
.version_id()
.expect("enabled rejected fixture has a UUID version");
let result = delete_batch(
&client,
bucket,
vec![
identifier("history", current.e_tag(), None)?,
identifier("history", None, Some(historical_id))?,
identifier("history", current.e_tag(), None)?,
identifier("invalid", invalid.e_tag(), Some(invalid_historical_id))?,
identifier("invalid", Some("*"), Some("null"))?,
identifier("invalid", Some("stale-etag"), None)?,
],
false,
)
.await?;
assert_eq!(result.deleted().len(), 3, "both current input entries and the historical delete succeed");
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("invalid"), Some("InvalidRequest")),
(Some("invalid"), Some("InvalidRequest")),
(Some("invalid"), Some("PreconditionFailed")),
]
);
assert_eq!(result.errors()[0].version_id(), Some(invalid_historical_id));
assert_eq!(result.errors()[1].version_id(), Some("null"));
assert_eq!(versions(&client, bucket, "invalid").await?, invalid_before);
assert_body(&client, bucket, "invalid", b"current rejected payload").await?;
let marker = result.deleted()[0]
.delete_marker_version_id()
.expect("current conditional delete returns a marker");
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
assert_eq!(result.deleted()[2].delete_marker_version_id(), Some(marker));
assert_eq!(result.deleted()[1].version_id(), Some(historical_id));
if suspended {
assert_eq!(marker, "null", "a suspended current delete returns the created null marker identity");
}
let history_after = versions(&client, bucket, "history").await?;
assert_eq!(
history_after.iter().filter(|(_, marker)| *marker).count(),
1,
"duplicate actions create one marker"
);
assert!(
!history_after.contains(&(historical_id.to_string(), false)),
"distinct historical target is removed"
);
if suspended {
assert_eq!(history_after, BTreeSet::from([("null".to_string(), true)]));
} else {
assert_eq!(
history_after,
BTreeSet::from([
(current.version_id().expect("current enabled object has a UUID").to_string(), false),
(marker.to_string(), true),
]),
"enabled conditional current deletion retains the current data version behind the marker"
);
}
let result = delete_batch(
&client,
bucket,
vec![
identifier("history", Some("*"), None)?,
identifier("history", current.e_tag(), None)?,
identifier("missing", Some("*"), None)?,
],
false,
)
.await?;
assert!(
result.deleted().is_empty(),
"current markers and absent objects cannot satisfy a live-object condition"
);
assert_eq!(result.errors().len(), 3);
assert!(result.errors().iter().all(|error| error.code() == Some("NoSuchKey")));
assert_eq!(
versions(&client, bucket, "history").await?,
history_after,
"failed conditions create no marker"
);
assert!(versions(&client, bucket, "missing").await?.is_empty());
put(&client, bucket, "exists", b"versioned existence payload").await?;
let result = delete_batch(&client, bucket, vec![identifier("exists", Some("*"), None)?], false).await?;
assert!(result.errors().is_empty());
assert_eq!(result.deleted().len(), 1);
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
assert_eq!(
versions(&client, bucket, "exists")
.await?
.iter()
.filter(|(_, marker)| *marker)
.count(),
1
);
if !suspended {
let directory = put(&client, bucket, "dir/", b"directory null-version payload").await?;
assert_eq!(
versions(&client, bucket, "dir/").await?,
BTreeSet::from([("null".to_string(), false)]),
"the directory PUT fixture must identify the synthetic null data version"
);
let result = delete_batch(
&client,
bucket,
vec![identifier("dir/", directory.e_tag(), None)?, identifier("dir/", None, None)?],
false,
)
.await?;
assert!(result.errors().is_empty(), "both distinct directory actions must succeed");
assert_eq!(result.deleted().len(), 2);
assert_eq!(result.deleted()[0].key(), Some("dir/"));
assert_eq!(result.deleted()[1].key(), Some("dir/"));
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
let marker = result.deleted()[0]
.delete_marker_version_id()
.expect("conditional current-directory deletion creates a versioned marker");
assert!(
!uuid::Uuid::parse_str(marker)?.is_nil(),
"enabled current-directory deletion creates a UUID marker"
);
assert_eq!(result.deleted()[1].delete_marker(), None);
assert_eq!(result.deleted()[1].delete_marker_version_id(), None);
assert_eq!(result.deleted()[1].version_id(), None);
assert_eq!(
versions(&client, bucket, "dir/").await?,
BTreeSet::from([(marker.to_string(), true)]),
"current-marker creation and synthetic null-version removal are distinct actions: one marker remains and the null data version is gone"
);
}
}
Ok(())
}
@@ -1,377 +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.
//! E2E: conditional write enforcement with the `s3:if-none-match` and `s3:if-match`
//! condition keys in a **bucket policy**, an **IAM identity policy**, and an **STS
//! AssumeRole session policy**. Covers PutObject and CompleteMultipartUpload, the two
//! upload operations that accept the conditional headers. The plain precondition
//! semantics without a policy live in `reliant/conditional_writes.rs`.
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::http::HttpResponse;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use tracing::info;
use uuid::Uuid;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
fn assert_error<E: ProvideErrorMetadata + std::fmt::Debug>(
err: &SdkError<E, HttpResponse>,
code: &str,
status: u16,
context: &str,
) {
assert_eq!(
err.as_service_error().and_then(ProvideErrorMetadata::code),
Some(code),
"{context}: {err:?}"
);
assert_eq!(
err.raw_response().map(|response| response.status().as_u16()),
Some(status),
"{context}: {err:?}"
);
}
async fn admin_add_and_attach_policy(
env: &RustFSTestEnvironment,
policy_name: &str,
policy_json: &str,
username: &str,
) -> TestResult {
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
policy_name,
policy_json,
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, policy_name, username).await
}
/// Starts a one-part multipart upload as the admin so that only the complete step
/// runs under the policy being tested. CreateMultipartUpload and UploadPart are both
/// authorized as `s3:PutObject` and cannot carry a conditional header, so a policy
/// that requires `s3:if-none-match` denies them. AWS exempts them with
/// `s3:ObjectCreationOperation`, which RustFS does not support yet.
async fn admin_start_upload(
admin: &Client,
bucket: &str,
key: &str,
) -> Result<(String, CompletedMultipartUpload), Box<dyn std::error::Error + Send + Sync>> {
let upload = admin.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
let part = admin
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(1)
.body(ByteStream::from_static(b"multipart-conditional-write"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(part.e_tag().map(str::to_string))
.build(),
)
.build();
Ok((upload_id, completed))
}
/// One server for the three policy kinds; each scenario uses its own principal and bucket.
#[tokio::test]
async fn test_e2e_policies_enforce_conditional_write_keys() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
let suffix = Uuid::new_v4();
bucket_policy_requires_if_none_match(&env, &admin, suffix).await?;
iam_policy_requires_if_match(&env, &admin, suffix).await?;
sts_session_policy_requires_if_none_match(&env, &admin, suffix).await?;
info!("test_e2e_policies_enforce_conditional_write_keys passed");
Ok(())
}
/// Bucket policy in the shape of the AWS enforcement example: PutObject is allowed only
/// when the request carries `If-None-Match`, which makes the bucket create-only.
async fn bucket_policy_requires_if_none_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let user = format!("e2econdwrite-{suffix}");
let user_secret = "longSecretKeyForCondWrite1!";
let bucket = format!("e2e-cond-write-bkt-{suffix}");
admin_create_user(env, &user, user_secret).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let bucket_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": [user.clone()] },
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "Null": { "s3:if-none-match": "false" } }
}]
})
.to_string();
admin
.put_bucket_policy()
.bucket(&bucket)
.policy(&bucket_policy)
.send()
.await?;
let client = env.create_s3_client_with_credentials(&user, user_secret);
let denied = client
.put_object()
.bucket(&bucket)
.key("plain.txt")
.body(ByteStream::from_static(b"plain"))
.send()
.await
.expect_err("PutObject without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional PutObject");
let created = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"created"))
.send()
.await?;
let etag = created.e_tag().ok_or("missing ETag on conditional PutObject")?.to_string();
let overwrite = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("If-None-Match: * must not overwrite an existing key");
assert_error(
&overwrite,
"PreconditionFailed",
412,
"allowed PutObject still evaluates the precondition",
);
// A non-`*` value satisfies `Null: false` but must not act as an ETag comparison
// that lets the write overwrite the key.
let overwrite = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("\"0123456789abcdef0123456789abcdef\"")
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("If-None-Match with an ETag value must be rejected");
assert_error(&overwrite, "NotImplemented", 501, "PutObject with an ETag If-None-Match");
let current = admin.head_object().bucket(&bucket).key("created.txt").send().await?;
assert_eq!(current.e_tag(), Some(etag.as_str()), "created.txt must not be overwritten");
let (upload_id, completed) = admin_start_upload(admin, &bucket, "multipart.bin").await?;
let denied = client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("CompleteMultipartUpload without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional CompleteMultipartUpload");
client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.if_none_match("*")
.multipart_upload(completed)
.send()
.await?;
let (upload_id, completed) = admin_start_upload(admin, &bucket, "multipart.bin").await?;
let overwrite = client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.if_none_match("*")
.multipart_upload(completed)
.send()
.await
.expect_err("CompleteMultipartUpload with If-None-Match: * must not overwrite an existing key");
assert_error(
&overwrite,
"PreconditionFailed",
412,
"allowed CompleteMultipartUpload still evaluates the precondition",
);
let _ = admin
.abort_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.send()
.await;
for key in ["created.txt", "multipart.bin"] {
let _ = admin.delete_object().bucket(&bucket).key(key).send().await;
}
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
/// IAM identity policy: PutObject is allowed only when the request carries `If-Match`.
async fn iam_policy_requires_if_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let user = format!("e2econdmatch-{suffix}");
let user_secret = "longSecretKeyForCondMatch2!";
let policy_name = format!("e2e-cond-match-pol-{suffix}");
let bucket = format!("e2e-cond-match-bkt-{suffix}");
let key = "guarded.txt";
admin_create_user(env, &user, user_secret).await?;
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "Null": { "s3:if-match": "false" } }
}]
})
.to_string();
admin_add_and_attach_policy(env, &policy_name, &policy, &user).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let original = admin
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"v1"))
.send()
.await?;
let etag = original.e_tag().ok_or("missing ETag on initial PutObject")?.to_string();
let client = env.create_s3_client_with_credentials(&user, user_secret);
let denied = client
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"blind-overwrite"))
.send()
.await
.expect_err("PutObject without If-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional PutObject");
client
.put_object()
.bucket(&bucket)
.key(key)
.if_match(&etag)
.body(ByteStream::from_static(b"v2"))
.send()
.await?;
let _ = admin.delete_object().bucket(&bucket).key(key).send().await;
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
/// STS session policy: AssumeRole must accept `s3:if-none-match`, and the session may
/// only create objects with `If-None-Match: *`.
async fn sts_session_policy_requires_if_none_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let parent = format!("e2e-sts-cond-par-{suffix}");
let parent_secret = "longSecretKeyForCondParent3!";
let policy_name = format!("e2e-sts-cond-rw-{suffix}");
let bucket = format!("e2e-sts-cond-bkt-{suffix}");
admin_create_user(env, &parent, parent_secret).await?;
let parent_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:*"], "Resource": ["arn:aws:s3:::*"] },
{ "Effect": "Allow", "Action": ["sts:AssumeRole"], "Resource": ["arn:aws:s3:::*"] }
]
})
.to_string();
admin_add_and_attach_policy(env, &policy_name, &parent_policy, &parent).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let session_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "StringEquals": { "s3:if-none-match": "*" } }
}]
})
.to_string();
let body = format!(
"Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600&Policy={}",
urlencoding::encode(&session_policy)
);
let url = format!("{}/", env.url.trim_end_matches('/'));
let xml = awscurl_post_sts_form_urlencoded(&url, &body, &parent, parent_secret).await?;
let (access_key, secret_key, session_token) = parse_assume_role_credentials(&xml)?;
let session = Client::from_conf(build_test_s3_config(
&env.url,
&access_key,
&secret_key,
Some(&session_token),
"e2e-sts-session",
));
let denied = session
.put_object()
.bucket(&bucket)
.key("plain.txt")
.body(ByteStream::from_static(b"plain"))
.send()
.await
.expect_err("session PutObject without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional session PutObject");
session
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"created"))
.send()
.await?;
let _ = admin.delete_object().bucket(&bucket).key("created.txt").send().await;
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Region-aware bucket creation and location regressions: backlog#629(b), rustfs#8729.
//! Regression test for rustfs/backlog#629(b): region-aware `CreateBucket`.
//!
//! `minio-go`'s `MakeBucket(bucket, "us-east-1")` was reported to fail SigV4
//! validation. The SigV4 signature is verified by `s3s` over the raw request
@@ -23,13 +23,7 @@
#[cfg(test)]
mod tests {
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
build_test_s3_config, build_test_sts_client, init_logging, local_http_client, signed_s3_request_with_headers,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::Region;
use aws_sdk_s3::error::ProvideErrorMetadata;
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::types::{BucketLocationConstraint, CreateBucketConfiguration};
use std::error::Error;
@@ -39,7 +33,7 @@ mod tests {
async fn test_create_bucket_with_us_east_1_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
client
@@ -54,13 +48,6 @@ mod tests {
.await
.expect("region-aware CreateBucket must pass SigV4 validation and succeed");
let location = client.get_bucket_location().bucket("region-aware-bucket").send().await?;
assert_eq!(
location.location_constraint().map(|value| value.as_str()),
Some(""),
"us-east-1 must serialize an empty LocationConstraint element"
);
// The bucket exists and is listed.
let listed = client.list_buckets().send().await?;
let names: Vec<&str> = listed.buckets().iter().filter_map(|b| b.name()).collect();
@@ -76,7 +63,7 @@ mod tests {
async fn test_create_bucket_without_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
client
@@ -89,423 +76,4 @@ mod tests {
env.stop_server();
Ok(())
}
#[tokio::test]
async fn test_create_bucket_rejects_unserved_location_constraints() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
for (index, region) in ["eu-1", "not-a-real-region-42", "us-west-2", "EU"].iter().enumerate() {
let bucket = format!("rejected-region-{index}");
let error = client
.create_bucket()
.bucket(&bucket)
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(*region))
.build(),
)
.send()
.await
.expect_err("a region not served by this instance must be rejected");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidLocationConstraint")
);
let missing = client
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("rejected bucket must not exist");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let missing = client
.get_bucket_location()
.bucket("missing-region-bucket")
.send()
.await
.expect_err("missing bucket must remain an error");
assert_eq!(missing.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchBucket"));
Ok(())
}
#[tokio::test]
async fn test_create_bucket_uses_configured_region() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
for region in ["eu-1", "eu-west-1"] {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", region]).await?;
let client = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new(region))
.build(),
);
let mut configurations = vec![None, Some(CreateBucketConfiguration::builder().build())];
for constraint in ["", region].into_iter().chain((region == "eu-west-1").then_some("EU")) {
configurations.push(Some(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(constraint))
.build(),
));
}
// Omitted and empty constraints retain the S3-compatible server's site default.
for (index, configuration) in configurations.into_iter().enumerate() {
let bucket = format!("configured-region-{index}");
client
.create_bucket()
.bucket(&bucket)
.set_create_bucket_configuration(configuration)
.send()
.await?;
let location = client.get_bucket_location().bucket(&bucket).send().await?;
assert_eq!(location.location_constraint().map(|value| value.as_str()), Some(region));
}
// A correctly signed request still cannot choose another bucket region.
let error = client
.create_bucket()
.bucket("wrong-default-region")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from("us-east-1"))
.build(),
)
.send()
.await
.expect_err("the default region must not bypass a custom site region");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidLocationConstraint")
);
let missing = client
.head_bucket()
.bucket("wrong-default-region")
.send()
.await
.expect_err("rejected bucket must not exist");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
Ok(())
}
async fn location_policy_client(
env: &RustFSTestEnvironment,
user: &str,
statements: serde_json::Value,
) -> Result<Client, Box<dyn Error + Send + Sync>> {
let secret = "location-policy-test-secret";
admin_create_user(env, user, secret).await?;
let policy = serde_json::json!({"Version":"2012-10-17", "Statement":statements});
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
user,
&policy.to_string(),
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await?;
Ok(Client::from_conf(
build_test_s3_config(&env.url, user, secret, None, "location-policy")
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
))
}
#[tokio::test]
async fn test_create_bucket_location_constraint_policy_allow() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let configuration = |region: &str| {
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(region))
.build()
};
for (user, condition, accepts_region, accepts_alias, accepts_missing) in [
(
"location-allow",
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}),
true,
false,
false,
),
(
"location-alias",
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"EU"}}),
false,
true,
false,
),
(
"location-null",
serde_json::json!({"Null":{"s3:LocationConstraint":"true"}}),
false,
false,
true,
),
] {
let client = location_policy_client(
&env,
user,
serde_json::json!([{
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*", "Condition":condition,
}]),
)
.await?;
for (index, (config, allowed)) in [
(Some(configuration("eu-west-1")), accepts_region),
(Some(configuration("EU")), accepts_alias),
(None, accepts_missing),
(Some(CreateBucketConfiguration::builder().build()), accepts_missing),
(Some(configuration("")), accepts_missing),
]
.into_iter()
.enumerate()
{
let bucket = format!("{user}-{index}");
let result = client
.create_bucket()
.bucket(&bucket)
.set_create_bucket_configuration(config)
.send()
.await;
if allowed {
result.unwrap_or_else(|error| panic!("{bucket} should be allowed: {error:?}"));
root.head_bucket().bucket(&bucket).send().await?;
} else {
let denied = result.expect_err("the requested XML constraint must satisfy the conditional Allow");
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403), "{bucket}");
assert_eq!(
denied.as_service_error().and_then(ProvideErrorMetadata::code),
Some("AccessDenied"),
"{bucket}"
);
let absent = root
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("denied bucket must not be created");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
}
}
}
let object_lock_client = location_policy_client(
&env,
"location-lock",
serde_json::json!([
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
{"Effect":"Allow", "Action":["s3:PutBucketObjectLockConfiguration", "s3:PutBucketVersioning"],
"Resource":"arn:aws:s3:::location-*", "Condition":{"Null":{"s3:LocationConstraint":"true"}}}
]),
)
.await?;
object_lock_client
.create_bucket()
.bucket("location-with-lock")
.create_bucket_configuration(configuration("eu-west-1"))
.object_lock_enabled_for_bucket(true)
.send()
.await
.expect("only CreateBucket receives the XML condition; dependent actions see it absent");
let object_lock = root
.get_object_lock_configuration()
.bucket("location-with-lock")
.send()
.await?;
assert_eq!(
object_lock
.object_lock_configuration()
.and_then(|configuration| configuration.object_lock_enabled())
.map(|value| value.as_str()),
Some("Enabled")
);
let versioning = root.get_bucket_versioning().bucket("location-with-lock").send().await?;
assert_eq!(versioning.status().map(|value| value.as_str()), Some("Enabled"));
let user = "location-session";
location_policy_client(
&env,
user,
serde_json::json!([{
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"
}]),
)
.await?;
let session_policy = serde_json::json!({"Version":"2012-10-17", "Statement":[
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}
]});
let assumed = build_test_sts_client(&env.url, user, "location-policy-test-secret", None, "location-policy-sts")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/location-policy")
.role_session_name("location-policy-session")
.policy(session_policy.to_string())
.send()
.await?;
let credentials = assumed.credentials().expect("AssumeRole returns temporary credentials");
let session = Client::from_conf(
build_test_s3_config(
&env.url,
credentials.access_key_id(),
credentials.secret_access_key(),
Some(credentials.session_token()),
"location-policy-session",
)
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let denied = session
.create_bucket()
.bucket("location-session-denied")
.create_bucket_configuration(configuration("eu-west-1"))
.send()
.await
.expect_err("the STS session's conditional Deny must be enforced");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
let absent = root
.head_bucket()
.bucket("location-session-denied")
.send()
.await
.expect_err("session denial must not create a bucket");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
session.create_bucket().bucket("location-session-allowed").send().await?;
Ok(())
}
#[tokio::test]
async fn test_create_bucket_location_constraint_policy_deny() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let user = "location-deny";
let client = location_policy_client(
&env,
user,
serde_json::json!([
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
]),
)
.await?;
let denied = client
.create_bucket()
.bucket("location-deny-matching")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from("eu-west-1"))
.build(),
)
.send()
.await
.expect_err("a matching explicit Deny must override the unconditional Allow");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
let absent = root
.head_bucket()
.bucket("location-deny-matching")
.send()
.await
.expect_err("denied bucket must not exist");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
client.create_bucket().bucket("location-deny-missing").send().await?;
let xml = r#"<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>eu-west-1</LocationConstraint></CreateBucketConfiguration>"#;
let mut headers = http::HeaderMap::new();
headers.insert("locationconstraint", http::HeaderValue::from_static("us-east-1"));
for (index, body) in [xml.to_owned(), xml.replace("eu-west-1", "eu-west-&#49;")]
.into_iter()
.enumerate()
{
let bucket = format!("location-deny-spoofed-{index}");
let spoofed = signed_s3_request_with_headers(
http::Method::PUT,
&format!("{}/{bucket}?LocationConstraint=us-east-1", env.url),
Some(body),
Some("application/xml"),
user,
"location-policy-test-secret",
&headers,
)
.await?;
assert_eq!(spoofed.status(), reqwest::StatusCode::FORBIDDEN);
assert!(spoofed.text().await?.contains("<Code>AccessDenied</Code>"));
let absent = root
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("spoofed conditions must not create a bucket");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let bucket = "location-anonymous";
root.create_bucket().bucket(bucket).send().await?;
for (effect, match_status, missing_status) in [
("Allow", reqwest::StatusCode::CONFLICT, reqwest::StatusCode::FORBIDDEN),
("Deny", reqwest::StatusCode::FORBIDDEN, reqwest::StatusCode::CONFLICT),
] {
let mut statements = vec![];
if effect == "Deny" {
statements.push(serde_json::json!({"Effect":"Allow", "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}")}));
}
statements.push(serde_json::json!({"Effect":effect, "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}"),
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}));
root.put_bucket_policy()
.bucket(bucket)
.policy(serde_json::json!({"Version":"2012-10-17", "Statement":statements}).to_string())
.send()
.await?;
let unsigned = local_http_client();
for (body, expected) in [(xml, match_status), ("", missing_status)] {
let response = unsigned
.put(format!("{}/{bucket}", env.url))
.header("content-type", "application/xml")
.body(body)
.send()
.await?;
assert_eq!(response.status(), expected, "anonymous {effect}, XML present={}", !body.is_empty());
let response = response.text().await?;
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
"<Code>AccessDenied</Code>"
} else {
"<Code>BucketAlreadyExists</Code>"
}));
}
}
Ok(())
}
}
+10 -18
View File
@@ -14,7 +14,6 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use futures::{StreamExt, TryStreamExt, stream};
use rustfs_data_usage::DataUsageInfo;
use tokio::time::{Duration, sleep};
@@ -75,23 +74,16 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
// Create bucket and upload objects
client.create_bucket().bucket(TEST_BUCKET).send().await?;
// Keep the full fixture and await every durable PUT before checking publication.
stream::iter(0..1000)
.map(Ok)
.try_for_each_concurrent(16, |i| {
let client = &client;
async move {
client
.put_object()
.bucket(TEST_BUCKET)
.key(format!("obj-{i:04}"))
.body(ByteStream::from_static(b"hello-world"))
.send()
.await
.map(|_| ())
}
})
.await?;
for i in 0..1000 {
let key = format!("obj-{i:04}");
client
.put_object()
.bucket(TEST_BUCKET)
.key(key)
.body(ByteStream::from_static(b"hello-world"))
.send()
.await?;
}
let usage = wait_for_bucket_usage(&env, TEST_BUCKET, |usage| {
usage
@@ -20,14 +20,15 @@
//! offline before heal converges, a strict latest-listing quorum of 3 can only
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853). The
//! listing now follows MinIO's rule: an entry needs `ceil(asked/2)` copies
//! among the online drives it asked, so three online drives need two copies
//! (see `SetDisks::list_path` in `crates/ecstore/src/store/list_objects.rs`).
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
//! product fix relaxes the listing's required object quorum by the number of
//! set drives the listing could not consult (see
//! `latest_listing_required_object_quorum` in
//! `crates/ecstore/src/store/list_objects.rs`).
#[cfg(test)]
mod tests {
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment, init_logging};
use crate::common::{RustFSTestClusterEnvironment, init_logging};
use aws_sdk_s3::Client;
use bytes::Bytes;
use std::collections::HashSet;
@@ -50,13 +51,13 @@ mod tests {
format!("degraded-object-{idx:02}")
}
async fn list_all_keys(client: &Client, bucket: &str) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
let mut keys = HashSet::new();
let mut continuation_token: Option<String> = None;
loop {
let response = client
.list_objects_v2()
.bucket(bucket)
.bucket(BUCKET)
.set_continuation_token(continuation_token.clone())
.send()
.await?;
@@ -146,7 +147,7 @@ mod tests {
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
let deadline = Instant::now() + LISTING_DEADLINE;
let listed = loop {
let listed = match list_all_keys(&client, BUCKET).await {
let listed = match list_all_keys(&client).await {
Ok(keys) => keys,
Err(error) if Instant::now() < deadline => {
info!("retrying degraded listing: {error}");
@@ -170,97 +171,4 @@ mod tests {
Ok(())
}
/// 4-node x 4-drive cluster (one 16-drive set), #8828 shape:
/// 1. PUT objects while the storage class is EC:8, so each is 8+8.
/// 2. Restart with the EC:4 default, as after a storage-class change.
/// 3. Stop two nodes (8 drives). Each object keeps eight data shards, so
/// GET succeeds and ListObjectsV2 must list every key: the listing
/// quorum is half of the eight drives it can ask.
/// 4. Stop a third node. Four online drives are below the set's read
/// quorum, so LIST must report 503 instead of a partial page.
#[tokio::test]
async fn ec8_objects_stay_listable_with_half_of_an_ec4_set_offline() -> TestResult {
const EC8_BUCKET: &str = "degraded-listing-ec8";
init_logging();
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:8");
cluster.start().await?;
cluster.create_test_bucket(EC8_BUCKET).await?;
let client = cluster.create_s3_client(0)?;
for idx in 0..OBJECT_COUNT {
let key = object_key(idx);
client
.put_object()
.bucket(EC8_BUCKET)
.key(&key)
.body(Bytes::from(format!("ec8 listing payload {idx}")).into())
.send()
.await
.map_err(|error| format!("EC:8 PUT for {key} failed: {error}"))?;
}
info!("restarting with the EC:4 default storage class");
cluster.stop();
// A later entry for the same variable wins when the node process starts.
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4");
cluster.start().await?;
let client = cluster.create_s3_client(0)?;
info!("stopping nodes 2 and 3 (eight of sixteen drives)");
cluster.stop_node(2)?;
cluster.stop_node(3)?;
for idx in 0..OBJECT_COUNT {
let key = object_key(idx);
let deadline = Instant::now() + GET_RETRY_DEADLINE;
loop {
match client.get_object().bucket(EC8_BUCKET).key(&key).send().await {
Ok(response) => {
let body = response.body.collect().await?.into_bytes();
assert_eq!(body.as_ref(), format!("ec8 listing payload {idx}").as_bytes());
break;
}
Err(error) if Instant::now() < deadline => {
info!("retrying EC:8 GET for {key}: {error}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(error) => return Err(format!("EC:8 object {key} failed GET with eight drives: {error}").into()),
}
}
}
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
let deadline = Instant::now() + LISTING_DEADLINE;
loop {
match list_all_keys(&client, EC8_BUCKET).await {
Ok(listed) if listed == expected => break,
Ok(listed) => assert!(
Instant::now() < deadline,
"EC:8 objects readable with eight drives stayed missing from ListObjectsV2: listed={listed:?}"
),
Err(error) => assert!(
Instant::now() < deadline,
"ListObjectsV2 kept failing with eight of sixteen drives online: {error}"
),
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
info!("stopping node 1 (four of sixteen drives remain)");
cluster.stop_node(1)?;
let deadline = Instant::now() + LISTING_DEADLINE;
loop {
match client.list_objects_v2().bucket(EC8_BUCKET).send().await {
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => break,
Err(error) => assert!(Instant::now() < deadline, "below the set read quorum LIST must report 503, got {error}"),
Ok(response) => panic!("below the set read quorum LIST must fail, but listed {} keys", response.contents().len()),
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Ok(())
}
}
@@ -24,163 +24,6 @@
//! `xl.meta` immediately visible, and the old file-cache shortcut has been
//! removed from the read path.
use aws_sdk_s3::Client;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use std::collections::BTreeMap;
async fn delete_exact_marker_versions(
client: &Client,
bucket: &str,
key: &str,
versions: &[(String, bool)],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let objects = versions
.iter()
.map(|(version, _)| ObjectIdentifier::builder().key(key).version_id(version).build())
.collect::<Result<Vec<_>, _>>()?;
let response = client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects)).build()?)
.send()
.await?;
assert!(response.errors().is_empty(), "explicit batch returned errors: {response:?}");
let mut actual = BTreeMap::new();
for deleted in response.deleted() {
assert_eq!(deleted.key(), Some(key));
*actual
.entry((
deleted.version_id().ok_or("Deleted entry omitted version ID")?.to_string(),
deleted.delete_marker().unwrap_or(false),
deleted.delete_marker_version_id().map(str::to_string),
))
.or_insert(0_usize) += 1;
}
let mut expected = BTreeMap::new();
for (version, marker) in versions {
*expected
.entry((version.clone(), *marker, marker.then(|| version.clone())))
.or_insert(0_usize) += 1;
}
assert_eq!(actual, expected, "every requested occurrence must report its own version identity");
Ok(())
}
pub(crate) async fn assert_explicit_marker_batch_cleanup(
clients: &[Client],
bucket: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
assert!(!clients.is_empty());
let clients: Vec<_> = clients
.iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
let writer = &clients[0];
let marker_writer = &clients[1 % clients.len()];
let deleter = &clients[2 % clients.len()];
let key = "same-key-history";
writer.create_bucket().bucket(bucket).send().await?;
writer
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let mut data = Vec::new();
for byte in [41_u8, 73_u8] {
let body = vec![byte; 1024 * 1024];
let uploaded = writer
.put_object()
.bucket(bucket)
.key(key)
.body(body.clone().into())
.send()
.await?;
data.push((uploaded.version_id().ok_or("versioned PUT omitted ID")?.to_string(), body));
}
let mut markers = Vec::with_capacity(16);
for _ in 0..16 {
let deleted = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
markers.push(deleted.version_id().ok_or("marker creation omitted ID")?.to_string());
}
let mut requested: Vec<_> = markers.iter().map(|id| (id.clone(), true)).collect();
requested.push((markers[0].clone(), true));
requested.push((markers[15].clone(), true));
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
for client in &clients {
let versions = client.list_object_versions().bucket(bucket).send().await?;
assert!(versions.delete_markers().is_empty(), "marker survived successful batch: {versions:?}");
let mut actual: Vec<_> = versions
.versions()
.iter()
.filter_map(|version| version.version_id())
.collect();
actual.sort_unstable();
let mut expected: Vec<_> = data.iter().map(|(id, _)| id.as_str()).collect();
expected.sort_unstable();
assert_eq!(actual, expected);
let current = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(current.body.collect().await?.into_bytes().as_ref(), data[1].1.as_slice());
let historical = client
.get_object()
.bucket(bucket)
.key(key)
.version_id(&data[0].0)
.send()
.await?;
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), data[0].1.as_slice());
}
let uuid_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
let uuid_marker = uuid_marker.version_id().ok_or("marker creation omitted UUID")?.to_string();
writer
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
let null_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(null_marker.delete_marker(), Some(true));
assert_eq!(null_marker.version_id(), Some("null"));
let requested = [
(uuid_marker.clone(), true),
(data[1].0.clone(), false),
("null".to_string(), true),
(data[0].0.clone(), false),
(uuid_marker, true),
];
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
for client in &clients {
let versions = client.list_object_versions().bucket(bucket).send().await?;
assert!(versions.versions().is_empty() && versions.delete_markers().is_empty(), "{versions:?}");
}
clients
.last()
.expect("nonempty clients")
.delete_bucket()
.bucket(bucket)
.send()
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
@@ -192,16 +35,6 @@ mod tests {
env.create_s3_client()
}
#[tokio::test]
async fn test_explicit_delete_marker_batch_removes_versions_without_client_retries()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
.await?;
super::assert_explicit_marker_batch_cleanup(&[env.create_s3_client()], "explicit-marker-batch").await
}
/// Regression test for Issue #1878.
///
/// Verifies that after calling `delete_objects` (the batch plural API) on
+3 -132
View File
@@ -13,20 +13,18 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
wait_for_ready, wait_until,
};
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
use crate::common::{build_test_s3_config, init_logging};
use crate::fault_proxy::FaultMode;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Barrier, mpsc};
use tokio::time::{Instant, timeout};
use tokio::time::timeout;
#[tokio::test]
async fn kill_and_restart_node_preserves_objects() -> TestResult {
@@ -46,133 +44,6 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
Ok(())
}
#[tokio::test]
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
init_logging();
let mut dist = DistCluster::start_with_env(
DistLayout::FourByFour,
&[
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
],
)
.await?;
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: serde_json::Value = serde_json::from_str(&storage)?;
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
assert_eq!(storage.disks.len(), 16);
assert_eq!(storage.backend.total_sets, vec![1]);
assert_eq!(storage.backend.drives_per_set, vec![16]);
assert_eq!(storage.backend.standard_sc_parity, Some(4));
let bucket = unique_bucket("partial-ec4");
dist.create_bucket(&bucket).await?;
let client = s3_probe_client(&dist.client(0)?);
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in &dist.cluster.nodes {
for directory in &node.data_dirs {
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
if !census.is_complete() {
return Ok(false);
}
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
}
}
Ok(true)
},
"the baseline is complete on all sixteen EC:4 drives",
)
.await?;
dist.cluster.stop_node(2)?;
dist.cluster.stop_node(3)?;
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
.await?
.expect_err("eight unavailable drives must prevent the baseline GET");
assert!(
matches!(
s3_probe_error_code(read_error.as_ref()),
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
),
"quorum loss must produce an availability error: {read_error:?}"
);
assert_eq!(
read_error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"read quorum loss must remain a service availability failure"
);
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
.await?
.expect_err("eight unavailable drives must prevent a successful PUT");
assert!(
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
"quorum loss must produce an availability error: {write_error:?}"
);
assert_eq!(
write_error
.downcast_ref::<SdkError<PutObjectError>>()
.and_then(SdkError::raw_response)
.map(|response| response.status().as_u16()),
Some(503),
"write quorum loss must remain a service availability failure"
);
// Recover only four drives. The remaining node must stay stopped while
// every live coordinator serves old data and acknowledges new writes.
dist.cluster.start_node_process(2).await?;
let recovery_deadline = Instant::now() + Duration::from_secs(90);
let mut recovered_objects = Vec::new();
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
let client = s3_probe_client(&dist.client(node)?);
retrying_get_equals(
&client,
&bucket,
"baseline.bin",
&baseline,
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
let key = format!("partial-{node}.bin");
let body = payload_for(&key, 1024 * 1024);
retrying_put(
&client,
&bucket,
&key,
body.clone(),
recovery_deadline.saturating_duration_since(Instant::now()),
)
.await?;
recovered_objects.push((key, body));
}
for node in 0..3 {
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
let client = s3_probe_client(&dist.client(node)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
}
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let client = s3_probe_client(&dist.client(3)?);
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
for (key, body) in &recovered_objects {
assert_object_bytes(&client, &bucket, key, body).await?;
}
Ok(())
}
#[tokio::test]
async fn full_cluster_restart_preserves_objects() -> TestResult {
init_logging();
+29 -241
View File
@@ -34,12 +34,11 @@ use crate::common::{
};
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, retry::RetryConfig};
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 futures::future::try_join_all;
use http::{Method, StatusCode};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@@ -78,7 +77,6 @@ pub(crate) struct DistCluster {
pub cluster: RustFSTestClusterEnvironment,
pool_storage_roots: Option<Vec<PathBuf>>,
owned_pool_dirs: Vec<PathBuf>,
capture_log_dir: Option<PathBuf>,
}
impl DistCluster {
@@ -130,12 +128,11 @@ impl DistCluster {
for &(key, value) in extra_env {
cluster.set_env(key, value);
}
configure_node_logs(&mut cluster, None)?;
configure_node_logs(&mut cluster)?;
Ok(Self {
cluster,
pool_storage_roots,
owned_pool_dirs,
capture_log_dir: None,
})
}
@@ -173,34 +170,17 @@ impl DistCluster {
Ok(())
}
/// Append every node's stdout/stderr to `directory/node-<idx>.log`, including
/// nodes added by later pool expansion. Call before the first start so startup
/// events such as the resolved durability mode are captured.
pub fn capture_logs_under(&mut self, directory: &Path) -> TestResult {
self.capture_log_dir = Some(directory.to_path_buf());
configure_node_logs(&mut self.cluster, Some(directory))
}
pub fn client_with_credentials(&self, node_idx: usize, access_key: &str, secret_key: &str) -> TestResult<Client> {
if node_idx >= self.cluster.nodes.len() {
return Err("node_idx is invalid".into());
}
let config = self.cluster.create_s3_client(node_idx)?.config().to_builder();
Ok(Client::from_conf(
config
.credentials_provider(Credentials::new(access_key, secret_key, None, None, "cluster-iam-test"))
.build(),
))
}
/// Stop the fleet, drop retired pools from the command line, and restart
/// the remaining pools, which are renumbered from zero.
pub async fn remove_retired_pools_and_restart(&mut self, retired: &[usize]) -> TestResult {
self.stop_all_gracefully().await?;
self.cluster.remove_single_node_pools(retired)?;
self.cluster.start().await?;
wait_for_ready(&self.cluster).await?;
Ok(())
Ok(Client::from_conf(build_test_s3_config(
&self.cluster.nodes[node_idx].url,
access_key,
secret_key,
None,
"cluster-iam-test",
)))
}
pub async fn append_pool_and_restart(&mut self) -> TestResult<usize> {
@@ -212,7 +192,7 @@ impl DistCluster {
.ok_or("pool expansion requires configured isolated pool filesystems")?;
self.owned_pool_dirs
.push(relocate_pool_storage(&mut self.cluster, node_idx, roots)?);
configure_node_logs(&mut self.cluster, self.capture_log_dir.as_deref())?;
configure_node_logs(&mut self.cluster)?;
self.cluster.start().await?;
wait_for_ready(&self.cluster).await?;
Ok(node_idx)
@@ -346,22 +326,7 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
Ok(pool_run_root)
}
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
cluster.set_env("RUST_LOG", filter);
}
if let Some(directory) = capture_dir {
std::fs::create_dir_all(directory)?;
for node_idx in 0..cluster.nodes.len() {
// Overrides a path assigned from RUSTFS_E2E_LOG_DIR so every node of
// the run, including the seed node, logs into the same directory.
let path = directory.join(format!("node-{node_idx}.log"));
if cluster.node_capture_log_paths[node_idx].as_deref() != Some(path.to_string_lossy().as_ref()) {
cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
}
}
return Ok(());
}
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
};
@@ -691,50 +656,13 @@ pub(crate) async fn wait_for_replicated_bytes(
.await
}
/// Quota stats for `bucket` from every node at once. Each node answers from
/// its own cached copy of the persisted usage snapshot, and only the scanner
/// leader's save invalidates that copy. Leadership goes to whichever node
/// starts its scanner first, so a probe pinned to one node can wait out the
/// whole cache TTL on a node that never saves.
pub(crate) async fn quota_stats_on_every_node(
cluster: &RustFSTestClusterEnvironment,
bucket: &str,
) -> TestResult<Vec<(StatusCode, String)>> {
let path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
try_join_all(
cluster
.nodes
.iter()
.map(|node| admin_request(&node.url, Method::GET, &path, None, &cluster.access_key, &cluster.secret_key)),
)
.await
}
/// `current_usage` from a quota-stats body. `None` means the node has no
/// scanner usage for the bucket yet: a quota admitted by the reservation
/// ledger reports `null` rather than a guess.
pub(crate) fn quota_stats_current_usage(body: &str) -> TestResult<Option<u64>> {
let stats: serde_json::Value =
serde_json::from_str(body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
match stats.get("current_usage") {
Some(serde_json::Value::Null) => Ok(None),
Some(usage) => Ok(Some(
usage
.as_u64()
.ok_or_else(|| format!("quota stats current_usage is not a byte count: {stats}"))?,
)),
None => Err(format!("quota stats omitted current_usage: {stats}").into()),
}
}
pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
wait_until(
Duration::from_secs(30),
|| async {
let stats = quota_stats_on_every_node(cluster, bucket).await?;
Ok(stats
.iter()
.any(|(status, _)| status.is_success() || *status == StatusCode::NOT_FOUND))
let (status, _) =
cluster_admin(cluster, Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
Ok(status.is_success() || status == StatusCode::NOT_FOUND)
},
"quota stats ready",
)
@@ -758,44 +686,23 @@ pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, buc
.await
}
/// The existing decommission cases wait up to 180 s for movement to finish; a
/// start refused because a movement gate is busy is retried within the same budget.
const DECOMMISSION_START_BUSY_BUDGET: Duration = Duration::from_secs(180);
const DECOMMISSION_START_PROOF_BUDGET: Duration = Duration::from_secs(45);
/// Start decommission and fail closed unless the admin API acknowledges it.
pub(crate) async fn start_decommission(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult {
let path = format!("/rustfs/admin/v3/pools/decommission?pool={pool_id}&by-id=true");
post_decommission_start(cluster, &path, DECOMMISSION_START_BUSY_BUDGET).await?;
Ok(())
}
/// POST a decommission start and return the response body. A start that meets
/// a busy movement gate answers a retryable 503 and is retried within
/// `busy_budget`; the init stage's missing fleet capability proof (500) is
/// retried for 45 s. Every other status is fatal.
pub(crate) async fn post_decommission_start(
cluster: &RustFSTestClusterEnvironment,
path: &str,
busy_budget: Duration,
) -> TestResult<String> {
let started = Instant::now();
let mut delay = Duration::from_millis(250);
let deadline = Instant::now() + Duration::from_secs(45);
loop {
let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?;
let (status, response) = cluster_admin(cluster, Method::POST, &path, None).await?;
if status.is_success() {
return Ok(response);
return Ok(());
}
let elapsed = started.elapsed();
let busy = status == StatusCode::SERVICE_UNAVAILABLE && elapsed < busy_budget;
let awaiting_proof = status == StatusCode::INTERNAL_SERVER_ERROR
if status == StatusCode::INTERNAL_SERVER_ERROR
&& response.contains("requires a live fleet capability proof")
&& elapsed < DECOMMISSION_START_PROOF_BUDGET;
if !busy && !awaiting_proof {
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
&& Instant::now() < deadline
{
sleep(Duration::from_secs(1)).await;
continue;
}
sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
}
}
@@ -978,21 +885,6 @@ pub(crate) async fn rebalance_status_json(cluster: &RustFSTestClusterEnvironment
Ok(serde_json::from_str(&body)?)
}
/// Mirrors `REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX` in `crates/ecstore/src/services/rebalance`.
const REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX: &str = "deferred transient rebalance entry failure:";
/// A transient entry failure (for example a write quorum miss on a busy
/// target) is recorded as `lastError` and retried at the end of the run; the
/// worker clears it when the pool completes. It is only acceptable while the
/// pool is still started: a terminal pool keeping it is a real failure.
fn is_retrying_deferred_rebalance_error(pool: &serde_json::Value, error: &str) -> bool {
error.starts_with(REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX)
&& pool
.get("status")
.and_then(serde_json::Value::as_str)
.is_some_and(|status| status.eq_ignore_ascii_case("started"))
}
fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &str) -> TestResult<&'a [serde_json::Value]> {
let id = status
.get("id")
@@ -1020,7 +912,7 @@ fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &st
if pool
.get("lastError")
.and_then(serde_json::Value::as_str)
.is_some_and(|error| !error.is_empty() && !is_retrying_deferred_rebalance_error(pool, error))
.is_some_and(|error| !error.is_empty())
{
return Err(format!("rebalance reported lastError: {status}").into());
}
@@ -1181,16 +1073,11 @@ pub(crate) async fn retrying_get_equals(
)
.into()),
Err(error) => {
let retryable = match s3_probe_error_code(error.as_ref()) {
Some("SlowDown" | "ServiceUnavailable") => true,
// GET preserves this compatibility code for a lost data quorum.
Some("SlowDownRead") => error
.downcast_ref::<SdkError<GetObjectError>>()
.and_then(SdkError::raw_response)
.is_some_and(|response| response.status().as_u16() == 503),
_ => false,
};
if retryable { Ok(false) } else { Err(error) }
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
Ok(false)
} else {
Err(error)
}
}
}
},
@@ -1329,62 +1216,6 @@ mod retry_tests {
Ok(())
}
#[tokio::test(start_paused = true)]
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
use aws_sdk_s3::config::Region;
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
use aws_smithy_types::body::SdkBody;
let endpoint = "http://read-quorum.example.invalid";
let event = |status: u16, body: &'static str| {
ReplayEvent::new(
http::Request::builder()
.method(Method::GET)
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
.body(SdkBody::empty())
.expect("build replay request"),
http::Response::builder()
.status(status)
.header("content-type", "application/xml")
.header("content-length", body.len())
.body(SdkBody::from(body))
.expect("build replay response"),
)
};
for status in [503, 403, 500] {
let transport = StaticReplayClient::new(vec![
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
event(200, "expected bytes"),
]);
let client = Client::from_conf(
aws_sdk_s3::Config::builder()
.behavior_version_latest()
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(RetryConfig::disabled())
.http_client(transport.clone())
.build(),
);
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
if status == 503 {
result?;
assert_eq!(
transport.actual_requests().count(),
2,
"the probe must retry the read-quorum error exactly once"
);
transport.assert_requests_match(&[]);
} else {
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
}
}
Ok(())
}
#[tokio::test]
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
let target = FakeS3Target::start().await?;
@@ -1428,29 +1259,6 @@ async fn append_single_node_pool_extends_ellipses_volumes() {
assert!(volumes.contains("/drive{0...1}"), "expanded layout must keep drive ellipses: {volumes}");
}
#[tokio::test]
async fn remove_single_node_pools_drops_retired_pools_and_renumbers() {
let mut env =
RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1], vec![2], vec![3]]))
.await
.expect("four-pool topology");
let kept: Vec<String> = env.nodes[2..].iter().map(|node| node.address.clone()).collect();
env.remove_single_node_pools(&[0, 1]).expect("remove two retired pools");
assert_eq!(env.nodes.len(), 2);
assert_eq!(env.nodes.iter().map(|node| node.address.clone()).collect::<Vec<_>>(), kept);
assert_eq!(env.nodes.iter().map(|node| node.pool_idx).collect::<Vec<_>>(), vec![0, 1]);
let volumes = env.rustfs_volumes_arg();
assert_eq!(volumes.split(' ').count(), 2, "expected two pool arguments, got: {volumes}");
assert!(
volumes.contains(&kept[0]) && volumes.contains(&kept[1]),
"retired pools must leave the command line: {volumes}"
);
env.remove_single_node_pools(&[0, 1])
.expect_err("the last remaining pool cannot be removed");
}
#[tokio::test]
async fn append_single_node_pool_rejects_striped_single_pool() {
let mut env = RustFSTestClusterEnvironment::new(4).await.expect("four-node single pool");
@@ -1604,23 +1412,3 @@ fn rebalance_complete_accepts_non_participating_pools_but_requires_progress() {
});
assert!(!rebalance_complete(&no_movement, "run-1").unwrap());
}
#[test]
fn rebalance_status_tolerates_deferred_entry_error_only_while_started() {
let deferred = "deferred transient rebalance entry failure: Storage resources are insufficient for the write operation: b/k";
let mut status = serde_json::json!({
"id": "run-1",
"pools": [{ "id": 0, "status": "Started", "stopping": false, "lastError": deferred, "progress": { "objects": 1, "bytes": 1 } }]
});
assert!(rebalance_active(&status, "run-1").expect("a retried deferral keeps the run active"));
status["pools"][0]["status"] = serde_json::json!("Completed");
assert!(
rebalance_complete(&status, "run-1").is_err(),
"a completed pool must have cleared its deferred entry error"
);
status["pools"][0]["status"] = serde_json::json!("Started");
status["pools"][0]["lastError"] = serde_json::json!("rebalance entry failed: Storage resources are insufficient");
assert!(rebalance_active(&status, "run-1").is_err(), "a non-deferred lastError stays fatal");
}
@@ -1,291 +0,0 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
use crate::tls_gen;
use aws_sdk_s3::{Client, config::retry::RetryConfig, primitives::ByteStream};
use rand::{Rng, SeedableRng, rngs::StdRng};
use sha2::{Digest, Sha256};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::task::JoinSet;
const MIB: u64 = 1024 * 1024;
const READ_TIMEOUT: Duration = Duration::from_secs(180);
struct ExpectedRead {
range: Option<(u64, u64)>,
length: u64,
sha256: String,
}
struct ObjectFixture {
key: String,
path: PathBuf,
length: u64,
reads: Vec<ExpectedRead>,
}
fn fixture(directory: &Path, length: u64) -> TestResult<ObjectFixture> {
let key = format!("object-{length}");
let path = directory.join(&key);
let mut file = std::fs::File::create(&path)?;
let mut rng = StdRng::seed_from_u64(length);
let mut block = vec![0; usize::try_from(MIB)?];
let mut remaining = length;
let mut digest = Sha256::new();
while remaining != 0 {
let count = usize::try_from(remaining.min(MIB))?;
rng.fill_bytes(&mut block[..count]);
file.write_all(&block[..count])?;
digest.update(&block[..count]);
remaining -= u64::try_from(count)?;
}
drop(file);
let mut reads = vec![ExpectedRead {
range: None,
length,
sha256: hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower),
}];
// Unaligned ranges cross TLS frames and EC blocks; the tail detects
// truncation and incorrect range offsets independently of a full GET.
for (start, end) in [(17, (MIB + 31).min(length - 1)), (length.saturating_sub(65537), length - 1)] {
let mut input = std::fs::File::open(&path)?;
input.seek(SeekFrom::Start(start))?;
let range_length = end - start + 1;
let mut hasher = Sha256::new();
let mut input = input.take(range_length);
loop {
let count = input.read(&mut block)?;
if count == 0 {
break;
}
hasher.update(&block[..count]);
}
reads.push(ExpectedRead {
range: Some((start, end)),
length: range_length,
sha256: hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower),
});
}
Ok(ObjectFixture {
key,
path,
length,
reads,
})
}
async fn verified_get(
client: &Client,
node: usize,
bucket: &str,
object: &ObjectFixture,
expected: &ExpectedRead,
concurrent: bool,
) -> TestResult<serde_json::Value> {
let started = Instant::now();
let mut received = 0u64;
let mut stage = "response headers";
tokio::time::timeout(READ_TIMEOUT, async {
let mut request = client.get_object().bucket(bucket).key(&object.key);
if let Some((start, end)) = expected.range {
request = request.range(format!("bytes={start}-{end}"));
}
let mut output = request.send().await?;
assert_eq!(output.content_length(), Some(i64::try_from(expected.length)?));
if let Some((start, end)) = expected.range {
assert_eq!(output.content_range(), Some(format!("bytes {start}-{end}/{}", object.length).as_str()));
} else {
assert_eq!(output.content_range(), None);
}
stage = "response body";
let mut digest = Sha256::new();
while let Some(chunk) = output.body.next().await {
let chunk = chunk.inspect_err(|error| {
eprintln!(
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
object.key, expected.range, expected.length, started.elapsed()
);
})?;
received += u64::try_from(chunk.len())?;
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
digest.update(&chunk);
}
let actual_sha256 = hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower);
assert_eq!(received, expected.length, "node {node}: truncated GET {}", object.key);
assert_eq!(actual_sha256, expected.sha256, "node {node}: corrupt GET {}", object.key);
eprintln!(
"TLS acceptance: GET node={node}, key={}, range={:?}, concurrent={concurrent}, bytes={received}, elapsed={:?}",
object.key, expected.range, started.elapsed()
);
Ok(serde_json::json!({
"operation": "get", "node": node, "key": object.key,
"range": expected.range, "bytes": received, "sha256": actual_sha256,
"concurrent": concurrent, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0,
}))
})
.await
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"TLS GET timed out: node={node}, key={}, range={:?}, concurrent={concurrent}, stage={stage}, received={received}/{}, elapsed={:?}: {error}",
object.key, expected.range, expected.length, started.elapsed()
),
)
})?
}
/// This case always exercises the full acceptance sizes; do not substitute
/// tiny objects and count that as a large distributed-read pass.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
let mode = std::env::var("RUSTFS_E2E_INTERNODE_HTTP_MODE").unwrap_or_else(|_| "http1".into());
let protocol = match mode.as_str() {
"http1" => "http1",
"auto" | "unpooled" => "auto",
_ => return Err("RUSTFS_E2E_INTERNODE_HTTP_MODE must be http1, auto or unpooled".into()),
};
let mut dist = DistCluster::new_stopped_with_env(
DistLayout::FourByFour,
&[
("RUSTFS_INTERNODE_HTTP_PROTOCOL", protocol),
("RUSTFS_SCANNER_ENABLED", "true"),
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_HEAL_ENABLED", "true"),
("RUST_LOG", "warn"),
],
)
.await?;
if mode == "unpooled" {
dist.cluster.set_env("RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST", "0");
}
let directory = PathBuf::from(&dist.cluster.temp_dir);
let tls_directory = directory.join("tls");
tls_gen::run(tls_gen::Args {
out_dir: tls_directory.clone(),
days: 1,
force: false,
})?;
dist.cluster.enable_tls(&tls_directory).await?;
let report_path = std::env::var_os("RUSTFS_INTERNODE_ACCEPTANCE_REPORT").map(PathBuf::from);
if let Some(path) = &report_path
&& (path.exists() || path.with_extension("pids.json").exists())
{
return Err("acceptance report paths must be unused".into());
}
let log_directory = report_path.as_ref().map_or_else(
|| {
std::env::var_os("RUSTFS_E2E_LOG_DIR")
.map_or_else(|| directory.join("logs"), |root| PathBuf::from(root).join(unique_bucket("internode-tls")))
},
|path| path.with_extension("logs"),
);
std::fs::create_dir(&log_directory)?;
dist.capture_logs_under(&log_directory)?;
dist.cluster.start().await?;
let clients = dist
.clients()?
.into_iter()
.map(|client| Client::from_conf(client.config().to_builder().retry_config(RetryConfig::disabled()).build()))
.collect::<Vec<_>>();
let bucket = unique_bucket("internode-tls");
clients[0].create_bucket().bucket(&bucket).send().await?;
let pids = dist
.cluster
.nodes
.iter()
.map(|node| node.process.as_ref().map(std::process::Child::id))
.collect::<Vec<_>>();
if let Some(path) = &report_path {
std::fs::write(path.with_extension("pids.json"), serde_json::to_vec(&pids)?)?;
}
let mut results = Vec::new();
for length in [32 * 1024, 100 * MIB, 1024 * MIB] {
let fixture_directory = directory.clone();
let object = Arc::new(tokio::task::spawn_blocking(move || fixture(&fixture_directory, length)).await??);
eprintln!("TLS acceptance: fixture key={}, bytes={length}", object.key);
let started = Instant::now();
tokio::time::timeout(
READ_TIMEOUT,
clients[0]
.put_object()
.bucket(&bucket)
.key(&object.key)
.body(ByteStream::from_path(&object.path).await?)
.send(),
)
.await
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"TLS PUT timed out: key={}, bytes={length}, elapsed={:?}: {error}",
object.key,
started.elapsed()
),
)
})??;
eprintln!("TLS acceptance: PUT key={}, bytes={length}, elapsed={:?}", object.key, started.elapsed());
results.push(
serde_json::json!({"operation": "put", "bytes": length, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0}),
);
if length == 32 * 1024 {
let alternate = dist.client_with_credentials(0, &dist.cluster.access_key, &dist.cluster.secret_key)?;
let alternate = Client::from_conf(alternate.config().to_builder().retry_config(RetryConfig::disabled()).build());
results.push(verified_get(&alternate, 0, &bucket, &object, &object.reads[0], false).await?);
let invalid = dist.client_with_credentials(0, "invalid-tls-user", "invalid-tls-secret-password")?;
let invalid = Client::from_conf(invalid.config().to_builder().retry_config(RetryConfig::disabled()).build());
let denied = invalid
.list_buckets()
.send()
.await
.expect_err("alternate credentials must not inherit the root identity");
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
}
for expected in &object.reads {
for (node, client) in clients.iter().enumerate() {
results.push(verified_get(client, node, &bucket, &object, expected, false).await?);
}
}
// Each reader owns a Tokio task and its cooperative budget. Polling
// every body and synchronous digest in one parent task can let an
// always-ready reader starve its siblings and their deadlines.
let mut readers = JoinSet::new();
for (node, client) in clients.iter().enumerate() {
let client = client.clone();
let bucket = bucket.clone();
let object = Arc::clone(&object);
readers.spawn(async move { verified_get(&client, node, &bucket, &object, &object.reads[0], true).await });
}
while let Some(result) = readers.join_next().await {
results.push(result??);
}
tokio::fs::remove_file(&object.path).await?;
}
for node in 0..clients.len() {
let log = tokio::fs::read_to_string(log_directory.join(format!("node-{node}.log"))).await?;
assert!(
!log.contains("internode body stalled"),
"node {node}: internode body stall during verified reads"
);
}
let report = serde_json::json!({
"schema": 1, "mode": mode, "tls_verified": true, "nodes": 4, "drives_per_node": 4,
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"source_dirty": env!("RUSTFS_E2E_BUILD_DIRTY"),
"scanner_enabled": true, "heal_enabled": true,
"scanner_progress_verified": false, "heal_progress_verified": false,
"results": results,
});
if let Some(path) = report_path {
std::fs::write(path, serde_json::to_vec_pretty(&report)?)?;
}
eprintln!("internode_tls_acceptance={report}");
Ok(())
}
-7
View File
@@ -26,19 +26,12 @@ mod expand_decommission_rebalance_test;
mod extra_test;
mod harness;
mod heal_test;
mod internode_tls_test;
mod multipart_fence_acceptance_test;
mod multipart_rpc_delay_test;
mod object_lock_test;
mod observability_test;
mod publication_log;
mod replication_delete_marker_test;
mod replication_quota_test;
mod s3_basic_test;
mod s3_during_data_movement_test;
mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod scanner_heal_topology_tier_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,413 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
//! Follow the same append-only log files without rescanning previous JSON rows.
use same_file::Handle;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use std::fs::File;
use std::io::{self, Read};
use std::path::PathBuf;
// Captured RPC spans already produce valid rows above 2 MiB. Keep complete
// rows bounded independently from the much smaller selected-event history.
const MAX_LINE_BYTES: usize = 4 << 20;
const MAX_EVENTS: usize = 128;
const MAX_EVENT_BYTES: usize = 1 << 20;
fn invalid(reason: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, reason)
}
#[derive(Default, Deserialize)]
struct Selector {
#[serde(default)]
event: Value,
#[serde(default)]
op: Value,
#[serde(default)]
bucket: Value,
#[serde(default)]
object: Value,
}
impl Selector {
fn matches(&self, bucket: &str, object: &str) -> bool {
self.event.as_str() == Some("object_namespace_lock")
&& self.op.as_str() == Some("data_movement_publication")
&& self.bucket.as_str() == Some(bucket)
&& self.object.as_str() == Some(object)
}
}
#[derive(Deserialize)]
struct Probe {
#[serde(default)]
event: Value,
#[serde(default)]
op: Value,
#[serde(default)]
bucket: Value,
#[serde(default)]
object: Value,
// A present null is not a missing fields object. The old reader uses the
// root only when this key is absent, including for non-object JSON values.
#[serde(default, deserialize_with = "present_fields")]
fields: Option<Option<Selector>>,
}
fn present_fields<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<Selector>>, D::Error> {
Option::<Selector>::deserialize(d).map(Some)
}
impl Probe {
fn matches(&self, bucket: &str, object: &str) -> bool {
match &self.fields {
Some(Some(fields)) => fields.matches(bucket, object),
Some(None) => false,
None => {
self.event.as_str() == Some("object_namespace_lock")
&& self.op.as_str() == Some("data_movement_publication")
&& self.bucket.as_str() == Some(bucket)
&& self.object.as_str() == Some(object)
}
}
}
}
fn select(line: &[u8], bucket: &str, object: &str, full_decodes: &mut u64) -> io::Result<Option<Value>> {
let text = std::str::from_utf8(line).map_err(|_| invalid("invalid UTF-8 in a completed log row"))?;
// Skip unknown fields without allocating a Value tree for every RPC trace.
// A shape/duplicate-field error falls back to the original Value parser;
// it must not discard valid escaped strings or last-key-wins JSON.
if let Ok(probe) = serde_json::from_str::<Probe>(text)
&& !probe.matches(bucket, object)
{
return Ok(None);
}
*full_decodes += 1;
let Ok(row) = serde_json::from_str::<Value>(text) else {
return Ok(None);
};
let fields = row.get("fields").unwrap_or(&row);
if fields.get("event").and_then(Value::as_str) != Some("object_namespace_lock")
|| fields.get("op").and_then(Value::as_str) != Some("data_movement_publication")
|| fields.get("bucket").and_then(Value::as_str) != Some(bucket)
|| fields.get("object").and_then(Value::as_str) != Some(object)
{
return Ok(None);
}
let mut event = fields.clone();
event["timestamp"] = row["timestamp"].clone();
Ok(Some(event))
}
struct Tail {
path: PathBuf,
handle: Handle,
offset: u64,
pending: Vec<u8>,
}
impl Tail {
fn open(path: PathBuf) -> io::Result<Self> {
let file = File::open(&path)?;
if !file.metadata()?.is_file() {
return Err(invalid("publication log is not a regular file"));
}
Ok(Self {
path,
handle: Handle::from_file(file)?,
offset: 0,
pending: Vec::new(),
})
}
fn check_identity(&self) -> io::Result<()> {
if Handle::from_path(&self.path)? != self.handle {
return Err(invalid("publication log file was replaced"));
}
Ok(())
}
fn poll(&mut self, bytes_read: &mut u64, mut accept: impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> {
self.check_identity()?;
let end = self.handle.as_file().metadata()?.len();
if end < self.offset {
return Err(invalid("publication log file was truncated"));
}
// A fixed high-water snapshot bounds each poll even if writers append
// faster than the reader. Bytes after it remain for the next poll.
let mut chunk = [0_u8; 64 * 1024];
while self.offset < end {
let size = usize::try_from((end - self.offset).min(64 * 1024)).expect("a log chunk fits usize");
self.handle.as_file_mut().read_exact(&mut chunk[..size])?;
self.offset += u64::try_from(size).expect("a bounded log read fits u64");
*bytes_read += u64::try_from(size).expect("a bounded log read fits u64");
let appended_at = self.pending.len();
self.pending.extend_from_slice(&chunk[..size]);
let mut start = 0;
for end in self.pending[appended_at..]
.iter()
.enumerate()
.filter_map(|(i, byte)| (*byte == b'\n').then_some(appended_at + i + 1))
{
if end - start > MAX_LINE_BYTES {
return Err(invalid("publication log row exceeds its fixed bound"));
}
accept(&self.pending[start..end])?;
start = end;
}
self.pending.drain(..start);
if self.pending.len() > MAX_LINE_BYTES {
return Err(invalid("unfinished publication log row exceeds its fixed bound"));
}
}
self.check_identity()
}
}
pub(super) struct PublicationEvents {
tails: Vec<Tail>,
bucket: String,
object: String,
events: Vec<Value>,
per_file: Vec<Vec<Value>>,
event_count: usize,
event_bytes: usize,
bytes_read: u64,
full_decodes: u64,
failed: bool,
}
impl PublicationEvents {
pub(super) fn new(paths: Vec<PathBuf>, bucket: &str, object: &str) -> io::Result<Self> {
if paths.is_empty() || paths.len() > 4 {
return Err(invalid("publication capture requires one to four log files"));
}
let tails: Vec<Tail> = paths.into_iter().map(Tail::open).collect::<io::Result<_>>()?;
let mut reader = Self {
per_file: vec![Vec::new(); tails.len()],
tails,
bucket: bucket.to_owned(),
object: object.to_owned(),
events: Vec::new(),
event_bytes: 0,
event_count: 0,
bytes_read: 0,
full_decodes: 0,
failed: false,
};
// Prime before holding remote DELETE or starting a movement attempt.
// Retain earlier matching events too: the caller still applies its
// original timestamp boundary, and evidence keeps the complete history.
reader.poll()?;
Ok(reader)
}
pub(super) fn poll(&mut self) -> io::Result<&[Value]> {
if self.failed {
return Err(invalid("publication log reader was already invalidated"));
}
let result = (|| {
for (tail, history) in self.tails.iter_mut().zip(&mut self.per_file) {
tail.poll(&mut self.bytes_read, |line| {
if let Some(event) = select(line, &self.bucket, &self.object, &mut self.full_decodes)? {
let bytes = serde_json::to_vec(&event).map_err(io::Error::other)?.len();
if self.event_count >= MAX_EVENTS || bytes > MAX_EVENT_BYTES - self.event_bytes {
return Err(invalid("publication event history exceeds its fixed bound"));
}
self.event_bytes += bytes;
self.event_count += 1;
history.push(event);
}
Ok(())
})?;
}
Ok(())
})();
// Preserve the old file-then-row order, even when a later poll appends
// to an earlier file. Only bounded selected history is copied here.
self.events = self.per_file.iter().flatten().cloned().collect();
if let Err(error) = result {
self.failed = true;
return Err(error);
}
Ok(&self.events)
}
/// Diagnostics only: after a read error this history can be incomplete.
pub(super) fn cached(&self) -> &[Value] {
&self.events
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn event(id: &str) -> Value {
json!({"timestamp":"2026-10-26T00:00:00Z","event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object","lock_attempt_id":id,"state":"acquiring","extra":{"kept":[1,2]}})
}
fn append(path: &std::path::Path, raw: &[u8]) {
std::fs::OpenOptions::new()
.append(true)
.open(path)
.expect("open append log")
.write_all(raw)
.expect("append log bytes");
}
fn legacy(raw: &[u8]) -> Vec<Value> {
std::str::from_utf8(raw)
.expect("fixture UTF-8")
.lines()
.filter_map(|line| {
let row: Value = serde_json::from_str(line).ok()?;
let fields = row.get("fields").unwrap_or(&row);
if fields.get("event").and_then(Value::as_str) == Some("object_namespace_lock")
&& fields.get("op").and_then(Value::as_str) == Some("data_movement_publication")
&& fields.get("bucket").and_then(Value::as_str) == Some("bucket")
&& fields.get("object").and_then(Value::as_str) == Some("object")
{
let mut result = fields.clone();
result["timestamp"] = row["timestamp"].clone();
Some(result)
} else {
None
}
})
.collect()
}
#[test]
fn appended_events_keep_full_fields_without_history_rescans() {
let file = tempfile::NamedTempFile::new().expect("log file");
let mut raw = (json!({"message":"unrelated trace","payload":"x".repeat(4096)}).to_string() + "\n")
.repeat(64)
.into_bytes();
raw.extend_from_slice(
format!("{}\n", json!({"event":"object_namespace_lock","op":"get_object","bucket":"bucket","object":"object","spans":"x".repeat(2401832)})).as_bytes(),
);
raw.extend_from_slice(format!("{}\n", event("first")).as_bytes());
std::fs::write(file.path(), &raw).expect("seed log");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
assert_eq!(reader.cached(), legacy(&raw));
assert_eq!(reader.full_decodes, 1);
let bytes = reader.bytes_read;
assert_eq!(reader.poll().expect("unchanged poll"), legacy(&raw));
assert_eq!(reader.bytes_read, bytes);
let next = format!("{}\n", event("second"));
append(file.path(), next.as_bytes());
raw.extend_from_slice(next.as_bytes());
assert_eq!(reader.poll().expect("new rows"), legacy(&raw));
assert_eq!(reader.bytes_read, u64::try_from(raw.len()).expect("fixture length"));
assert_eq!(reader.full_decodes, 2);
}
#[test]
fn later_appends_preserve_the_old_file_then_row_order() {
let first = tempfile::NamedTempFile::new().expect("first log");
let second = tempfile::NamedTempFile::new().expect("second log");
std::fs::write(first.path(), format!("{}\n", event("a1"))).expect("seed first");
std::fs::write(second.path(), format!("{}\n", event("b1"))).expect("seed second");
let mut reader = PublicationEvents::new(vec![first.path().to_owned(), second.path().to_owned()], "bucket", "object")
.expect("prime both");
append(first.path(), format!("{}\n", event("a2")).as_bytes());
let mut expected = legacy(&std::fs::read(first.path()).expect("first bytes"));
expected.extend(legacy(&std::fs::read(second.path()).expect("second bytes")));
assert_eq!(reader.poll().expect("append first after second was read"), expected);
}
#[test]
fn root_nested_escaped_and_duplicate_fields_match_the_old_reader() {
let file = tempfile::NamedTempFile::new().expect("log file");
let root = event("root");
let mut nested = json!({"timestamp":root["timestamp"],"fields":root});
nested["fields"]["extra"] = json!({"unicode":"é"});
let null = json!({"fields":null,"event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object"});
let escaped = root
.to_string()
.replace("object_namespace_lock", "\\u006fbject_namespace_lock");
let duplicate = root.to_string().replacen("{", "{\"event\":\"earlier-value\",", 1);
let raw = format!("{root}\n{nested}\n{null}\n{escaped}\n{duplicate}\nnot json\n{{\"fields\":[]}}\n");
std::fs::write(file.path(), &raw).expect("mixed log");
let reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("probe and fallback");
assert_eq!(reader.cached(), legacy(raw.as_bytes()));
assert_eq!(reader.cached().len(), 4);
}
#[test]
fn incomplete_rows_and_split_utf8_wait_for_the_newline() {
let file = tempfile::NamedTempFile::new().expect("log file");
let mut row = event("partial");
row["extra"] = json!("é");
let raw = format!("{row}\n").into_bytes();
let cut = raw.iter().position(|b| *b == 0xc3).expect("UTF-8 prefix") + 1;
std::fs::write(file.path(), &raw[..cut]).expect("partial code point");
let mut reader =
PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("incomplete row is pending");
assert!(reader.cached().is_empty());
append(file.path(), &raw[cut..raw.len() - 1]);
assert!(reader.poll().expect("complete JSON without delimiter").is_empty());
append(file.path(), b"\n");
assert_eq!(reader.poll().expect("published row"), legacy(&raw));
}
#[test]
fn truncation_is_sticky_even_after_the_file_grows_again() {
let file = tempfile::NamedTempFile::new().expect("log file");
std::fs::write(file.path(), format!("{}\n", event("first"))).expect("seed log");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
file.as_file().set_len(0).expect("truncate");
assert!(reader.poll().is_err());
append(file.path(), format!("{}\n", event("longer replacement")).as_bytes());
assert!(reader.poll().is_err());
assert_eq!(reader.cached().len(), 1);
}
#[cfg(unix)]
#[test]
fn a_replaced_path_does_not_switch_to_the_new_log() {
let dir = tempfile::tempdir().expect("log dir");
let path = dir.path().join("node.log");
std::fs::write(&path, b"").expect("log file");
let mut reader = PublicationEvents::new(vec![path.clone()], "bucket", "object").expect("open log");
std::fs::rename(&path, dir.path().join("retired.log")).expect("replace log");
std::fs::write(&path, format!("{}\n", event("other-generation"))).expect("replacement");
assert!(reader.poll().is_err());
assert!(reader.cached().is_empty());
}
#[test]
fn invalid_utf8_and_oversized_or_excessive_records_fail_closed() {
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
append(file.path(), b"\xff\n");
assert!(reader.poll().is_err());
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
append(file.path(), &vec![b'x'; MAX_LINE_BYTES + 1]);
assert!(reader.poll().is_err());
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
for _ in 0..=MAX_EVENTS {
append(file.path(), format!("{}\n", event("attempt")).as_bytes());
}
assert!(reader.poll().is_err());
assert_eq!(reader.cached().len(), MAX_EVENTS);
assert!(reader.poll().is_err());
let file = tempfile::NamedTempFile::new().expect("log file");
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
let mut large = event("large");
large["extra"] = json!("x".repeat(600 * 1024));
for _ in 0..2 {
append(file.path(), format!("{large}\n").as_bytes());
}
assert!(reader.poll().is_err());
assert_eq!(reader.cached().len(), 1);
assert!(reader.poll().is_err());
}
}
@@ -13,14 +13,13 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, cluster_admin_ok, enable_versioning, put_bucket_replication, put_object,
quota_stats_current_usage, quota_stats_on_every_node, retrying_put, set_bucket_quota, set_remote_target, unique_bucket,
wait_for_ready, wait_for_replicated_bytes, wait_until,
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request, init_logging};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use http::{Method, StatusCode};
use http::Method;
use std::time::Duration;
async fn wait_for_replication_status(
@@ -110,165 +109,12 @@ async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult
Ok(())
}
#[tokio::test]
async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("quota-refresh");
dist.create_bucket(&bucket).await?;
let native_path = format!("/rustfs/admin/v3/quota/{bucket}");
let compat_set = format!("/rustfs/admin/v3/set-bucket-quota?bucket={bucket}");
let compat_get = format!("/rustfs/admin/v3/get-bucket-quota?bucket={bucket}");
// Warm every node's no-quota cache. The compatibility GET does not
// depend on a scanner usage snapshot for this newly created bucket.
for node in &dist.cluster.nodes {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
}
wait_until(
Duration::from_secs(30),
|| async {
let (status, body) = admin_request(
&dist.cluster.nodes[0].url,
Method::PUT,
&native_path,
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
if status == StatusCode::SERVICE_UNAVAILABLE {
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "{body}");
Ok(true)
},
"quota fleet capability ready",
)
.await?;
for (method, path, payload, expected) in [
(Method::GET, native_path.as_str(), None, Some(8192_u64)),
(
Method::PUT,
native_path.as_str(),
Some(serde_json::json!({"quota": 4096, "quota_type": "HARD"})),
Some(4096),
),
(Method::DELETE, native_path.as_str(), None, None),
(
Method::PUT,
compat_set.as_str(),
Some(serde_json::json!({"quota": 16384, "quotatype": "hard", "reservation_protocol": 2})),
Some(16384),
),
(
Method::PUT,
compat_set.as_str(),
Some(serde_json::json!({"quota": 0, "quotatype": "hard"})),
None,
),
] {
if method != Method::GET {
cluster_admin_ok(&dist.cluster, method, path, payload.map(|value| value.to_string())).await?;
}
// No polling: a successful mutation waits for healthy peers to reload.
for (index, node) in dist.cluster.nodes.iter().enumerate() {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body)?["quota"],
expected.unwrap_or(0),
"node {index} kept stale quota metadata",
);
if expected.is_some() {
let (status, body) = admin_request(
&node.url,
Method::GET,
&native_path,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"].as_u64(), expected);
}
}
}
cluster_admin_ok(
&dist.cluster,
Method::PUT,
&native_path,
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
)
.await?;
dist.cluster.stop_node(3)?;
// An unreachable peer cannot turn an already committed clear into a failure.
cluster_admin_ok(&dist.cluster, Method::DELETE, &native_path, None).await?;
for node in dist.cluster.nodes.iter().take(3) {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
}
Ok(())
}
#[tokio::test]
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
init_logging();
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
let bucket = unique_bucket("quota");
dist.create_bucket(&bucket).await?;
// A bucket without a quota reports usage only once a snapshot covers it.
// The scanner leader sees its own save at once; every other node holds a
// snapshot cached before the bucket existed and must reload it early
// rather than answer 503 for the rest of the 30 s cache TTL.
wait_until(
Duration::from_secs(30),
|| async {
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
Ok(stats.iter().any(|(status, _)| status.is_success()))
},
"first node reports quota stats for the new bucket",
)
.await?;
wait_until(
Duration::from_secs(15),
|| async {
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
Ok(stats.iter().all(|(status, _)| status.is_success()))
},
"every node reports quota stats for the new bucket",
)
.await?;
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
let client = dist.client(1)?;
@@ -276,12 +122,23 @@ async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult
wait_until(
Duration::from_secs(30),
|| async {
for (status, body) in quota_stats_on_every_node(&dist.cluster, &bucket).await? {
if status.is_success() && quota_stats_current_usage(&body)?.is_some_and(|usage| usage >= 1024) {
return Ok(true);
}
let (status, body) = super::harness::cluster_admin(
&dist.cluster,
Method::GET,
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
None,
)
.await?;
if !status.is_success() {
return Ok(false);
}
Ok(false)
let stats: serde_json::Value =
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
let usage = stats
.get("current_usage")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
Ok(usage >= 1024)
},
"quota stats observe small object",
)
File diff suppressed because it is too large Load Diff
@@ -1,761 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! `scanner_heal_topology_` protection matrix (backlog#2873, S00).
//!
//! Every case seeds the deterministic inventory from the fixture into one
//! pool, then drives expansion, rebalance or decommission while re-verifying
//! the recorded versions through every node and the physical pool placement
//! from `xl.meta`. Fault cases hold the mover at a proven window through the
//! `e2e-test-hooks` barriers before injecting a kill or a commit failure. The
//! gate mapping is documented in `docs/testing/scanner-heal-topology-gates.md`.
use super::harness::unique_bucket;
use super::harness::{
DECOMMISSION_POOL_ID, TestResult, decommission_status_json, rebalance_complete, rebalance_status_json, start_decommission,
start_rebalance, wait_for_decommission_active, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
wait_for_rebalance_active, wait_for_rebalance_complete,
};
use super::scanner_heal_topology_fixture::{
LiveWorkload, MOVEMENT_ACTIVE_TIMEOUT, MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL,
STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP, STAGE_BEFORE_TARGET_COMMIT, TopologyManifest, TopologyRun,
assert_bucket_durability_strict, assert_every_node_serves, assert_probe_objects, assert_process_durability_strict,
assert_seed_pool_census_unchanged, assert_single_pool_placement, cancel_decommission, capture_seed_pool_census,
clear_decommission, expect_decommission_rejected, expected_live_state, incarnation_sidecar_census, pool_count, pools_holding,
put_probe_objects, rebalance_stopped, require_test_hooks, run_live_workload, seed_manifest, start_decommission_targets,
stop_rebalance, verify_from_every_node, verify_live_state, verify_manifest, wait_for_decommission_flag,
};
use crate::common::{admin_request, init_logging};
use http::Method;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::time::Duration;
use tokio::time::{Instant, sleep};
const ALL_POOLS: [usize; 4] = [0, 1, 2, 3];
const TARGET_POOLS: [usize; 3] = [1, 2, 3];
const LIVE_WRITERS: usize = 6;
const LIVE_ROUNDS: usize = 6;
/// Locally the decommission start reaches its durable save in about a second
/// (POST to response ~0.8 s); 60 s separates a stalled start path from slow I/O.
const PRE_ACK_REACH_TIMEOUT: Duration = Duration::from_secs(60);
/// Cancel and stop are acknowledged before every in-flight entry finishes its
/// cleanup; terminal state and placement are polled within this budget.
const TERMINAL_TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
/// Recovery includes the fleet-proof retry before migration can resume. A
/// healthy CI restart completed after 253 s while still making progress; keep
/// this recovery budget below the distributed profile's 720 s process limit.
const DECOMMISSION_RESUME_COMPLETE_TIMEOUT: Duration = Duration::from_secs(360);
fn pools(ids: &[usize]) -> BTreeSet<usize> {
ids.iter().copied().collect()
}
fn seed_pool_sidecars(
census: &std::collections::BTreeMap<String, crate::chaos::VersionShardCensus>,
) -> Vec<(&String, &crate::chaos::VersionShardCensus)> {
census
.iter()
.filter(|(location, _)| location.starts_with(&format!("{SEED_POOL}/")))
.collect()
}
/// Expansion and restart must leave the seed pool's sidecar census unchanged.
fn assert_seed_sidecars_unchanged(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult {
for bucket in manifest.buckets() {
let now = incarnation_sidecar_census(&run.dist, bucket)?;
let before = &manifest.incarnation_sidecars[bucket];
if seed_pool_sidecars(&now) != seed_pool_sidecars(before) {
return Err(format!(
"{label}: bucket incarnation sidecar of {bucket} changed on the seed pool: before {before:?} now {now:?}"
)
.into());
}
}
Ok(())
}
/// After the seed pool is retired every bucket must keep its incarnation
/// sidecar on at least one target pool.
fn assert_sidecars_survive_on_targets(
run: &TopologyRun,
manifest: &TopologyManifest,
targets: &BTreeSet<usize>,
label: &str,
) -> TestResult<Value> {
let mut summary = serde_json::Map::new();
for bucket in manifest.buckets() {
let now = incarnation_sidecar_census(&run.dist, bucket)?;
let on_targets: Vec<&String> = now
.keys()
.filter(|location| {
location
.split('/')
.next()
.and_then(|pool| pool.parse::<usize>().ok())
.is_some_and(|pool| targets.contains(&pool))
})
.collect();
if on_targets.is_empty() {
return Err(
format!("{label}: bucket {bucket} has no incarnation sidecar on target pools {targets:?}: {now:?}").into(),
);
}
summary.insert(bucket.to_string(), json!(on_targets));
}
Ok(Value::Object(summary))
}
fn manifest_summary(manifest: &TopologyManifest) -> Value {
let mut shapes = std::collections::BTreeMap::new();
for entry in &manifest.entries {
*shapes.entry(entry.shape).or_insert(0usize) += 1;
}
json!({
"buckets": manifest.buckets(),
"entries": manifest.entries.len(),
"keys": manifest.entries.iter().map(|entry| format!("{}/{}", entry.bucket, entry.key)).collect::<BTreeSet<_>>().len(),
"shapes": shapes,
"bytes": manifest.entries.iter().map(|entry| entry.length).sum::<u64>(),
})
}
fn timeouts() -> Value {
json!({
"movement_active_secs": MOVEMENT_ACTIVE_TIMEOUT.as_secs(),
"movement_complete_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
"decommission_resume_complete_secs": DECOMMISSION_RESUME_COMPLETE_TIMEOUT.as_secs(),
"barrier_reach_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
"terminal_transition_secs": TERMINAL_TRANSITION_TIMEOUT.as_secs(),
})
}
/// Cancel and stop acknowledge before every in-flight entry has finished its
/// cleanup, so placement is polled until it settles within the terminal budget.
async fn wait_for_single_pool_placement(
run: &TopologyRun,
manifest: &TopologyManifest,
allowed: &BTreeSet<usize>,
label: &str,
) -> TestResult<std::collections::BTreeMap<String, usize>> {
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
loop {
match assert_single_pool_placement(&run.dist, manifest, allowed, label) {
Ok(placement) => return Ok(placement),
Err(error) if Instant::now() < deadline => {
tracing::info!("{label}: placement not settled yet: {error}");
sleep(Duration::from_secs(1)).await;
}
Err(error) => return Err(error),
}
}
}
/// A stopped run may leave a committed target beside its retained source.
/// Every version must still be held by at least one pool; returns the
/// versions held by more than one.
fn assert_held_by_some_pool(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult<Vec<String>> {
let mut duplicated = Vec::new();
for entry in &manifest.entries {
let held = pools_holding(&run.dist, &entry.bucket, &entry.key, entry.version_id.as_deref())?;
if held.is_empty() {
return Err(format!(
"{label}: {}/{} version {:?} is held by no pool",
entry.bucket, entry.key, entry.version_id
)
.into());
}
if held.len() > 1 {
duplicated.push(format!("{}/{}@{} {held:?}", entry.bucket, entry.key, entry.listed_version_id()));
}
}
Ok(duplicated)
}
async fn expand_and_verify(run: &mut TopologyRun, manifest: &TopologyManifest) -> TestResult {
run.dist.expand_to_four_pools().await?;
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "expected four pools after expansion");
verify_from_every_node(&run.dist, manifest, "after expansion to four pools").await?;
assert_single_pool_placement(&run.dist, manifest, &pools(&[SEED_POOL]), "before data movement")?;
Ok(())
}
/// G-EXP: 1→2→4 pools with scanner and heal enabled; old versions intact on
/// every node, seed-pool shards and set placement untouched, new writes land
/// in exactly one pool, and the pool map survives a full restart.
#[tokio::test]
async fn scanner_heal_topology_expand_one_to_four_pools_preserves_versions_and_placement() -> TestResult {
init_logging();
let mut run = TopologyRun::start("expand").await?;
let manifest = seed_manifest(&run).await?;
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let seed_census = capture_seed_pool_census(&run.dist, &manifest)?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
let mut steps = Vec::new();
for expected_pools in 2..=4 {
let new_node = run.dist.append_pool_and_restart().await?;
if new_node + 1 != expected_pools {
return Err(format!("appended node index {new_node} does not match pool count {expected_pools}").into());
}
let label = format!("after expanding to {expected_pools} pools");
assert_eq!(pool_count(&run.dist.cluster).await?, expected_pools, "{label}: pool list");
verify_from_every_node(&run.dist, &manifest, &label).await?;
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, &label)?;
assert_seed_sidecars_unchanged(&run, &manifest, &label)?;
steps.push(json!({ "pools": expected_pools, "verified_nodes": run.dist.cluster.nodes.len() }));
}
let probes = put_probe_objects(&run.dist, &scratch, "post-expand", 8).await?;
let probe_placement = assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes").await?;
run.dist.restart_current_binary_gracefully().await?;
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "pool map after restart");
verify_from_every_node(&run.dist, &manifest, "after full restart").await?;
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, "after full restart")?;
assert_seed_sidecars_unchanged(&run, &manifest, "after full restart")?;
assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes after restart").await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-exp-expand",
&json!({
"gate": "G-EXP",
"manifest": manifest_summary(&manifest),
"bucket_durability": bucket_durability,
"process_durability_strict_nodes": strict_nodes,
"expansion_steps": steps,
"probe_placement": probe_placement,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// Wait for the first rebalance run to report either `stopped` (the expected
/// outcome) or `completed` (the inventory moved before the stop arrived).
async fn wait_for_rebalance_stopped_or_completed(run: &TopologyRun, rebalance_id: &str) -> TestResult<(&'static str, Value)> {
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
loop {
let status = rebalance_status_json(&run.dist.cluster).await?;
if rebalance_stopped(&status, rebalance_id)? {
return Ok(("stopped", status));
}
if rebalance_complete(&status, rebalance_id).unwrap_or(false) {
return Ok(("completed_before_stop", status));
}
if Instant::now() >= deadline {
return Err(format!(
"rebalance {rebalance_id} reached neither stopped nor completed within {TERMINAL_TRANSITION_TIMEOUT:?}: {status}"
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
}
/// G-REB: rebalance with concurrent PUT/GET/DELETE and the scanner running;
/// stop mid-run, verify no content error and no lost acknowledged version,
/// restart the cluster, then rebalance to completion.
#[tokio::test]
async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart() -> TestResult {
init_logging();
let mut run = TopologyRun::start("rebalance").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
expand_and_verify(&mut run, &manifest).await?;
let baseline: Vec<(String, String)> = manifest
.plain_entries()
.filter(|entry| matches!(entry.shape, "inline" | "external" | "special"))
.map(|entry| (entry.key.clone(), entry.sha256.clone()))
.collect();
let first_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &first_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during_rebalance = put_probe_objects(&run.dist, &scratch, "during-rebalance", 4).await?;
let workload = tokio::spawn(run_live_workload(LiveWorkload {
clients: run.dist.clients()?,
bucket: scratch.clone(),
prefix: "live".to_string(),
writers: LIVE_WRITERS,
rounds: LIVE_ROUNDS,
baseline_bucket: manifest.plain_bucket.clone(),
baseline: baseline.clone(),
pause: Duration::from_millis(100),
}));
sleep(Duration::from_secs(2)).await;
stop_rebalance(&run.dist.cluster).await?;
let (first_outcome, first_status) = wait_for_rebalance_stopped_or_completed(&run, &first_id).await?;
let ops = workload.await??;
let live_state = expected_live_state(&ops);
for node_idx in 0..run.dist.cluster.nodes.len() {
let client = run.dist.client(node_idx)?;
verify_manifest(&client, &manifest, &format!("after rebalance {first_outcome} via node {node_idx}")).await?;
verify_live_state(
&client,
&scratch,
&live_state,
&format!("after rebalance {first_outcome} via node {node_idx}"),
)
.await?;
}
let retained_after_stop = assert_held_by_some_pool(&run, &manifest, &format!("after rebalance {first_outcome}"))?;
let during_placement = assert_probe_objects(
&run.dist,
&scratch,
&during_rebalance,
&pools(&TARGET_POOLS),
"writes issued while the seed pool was rebalancing must avoid it",
)
.await?;
run.dist.restart_current_binary_gracefully().await?;
assert_every_node_serves(&run.dist, &scratch, "after-stop-restart").await?;
verify_from_every_node(&run.dist, &manifest, "after restart following the stop").await?;
verify_live_state(&run.dist.client(0)?, &scratch, &live_state, "after restart following the stop").await?;
let second_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &second_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
wait_for_rebalance_complete(&run.dist.cluster, &second_id, MOVEMENT_COMPLETE_TIMEOUT).await?;
verify_from_every_node(&run.dist, &manifest, "after the second rebalance completed").await?;
verify_live_state(&run.dist.client(3)?, &scratch, &live_state, "after the second rebalance completed").await?;
let final_placement =
assert_single_pool_placement(&run.dist, &manifest, &pools(&ALL_POOLS), "after the second rebalance completed")?;
let moved = final_placement.values().filter(|pool| **pool != SEED_POOL).count();
if moved == 0 {
return Err("rebalance completed without moving any manifest version off the seed pool".into());
}
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-reb-rebalance",
&json!({
"gate": "G-REB",
"manifest": manifest_summary(&manifest),
"first_run": { "id": first_id, "outcome": first_outcome, "status": first_status, "versions_held_by_two_pools_after_stop": retained_after_stop },
"second_run": { "id": second_id, "versions_off_seed_pool": moved },
"live_ops": ops,
"probe_placement_during_rebalance": during_placement,
"process_durability_strict_nodes": strict_nodes,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// G-DEC: cancel an active decommission and keep every version; queue two
/// pools, retire them in order, reject retiring the last active pools, and
/// keep listings, history, markers and locks correct across a restart.
#[tokio::test]
async fn scanner_heal_topology_decommission_queue_cancel_resume_and_last_pool_guard() -> TestResult {
init_logging();
let mut run = TopologyRun::start("decommission").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
expand_and_verify(&mut run, &manifest).await?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_running_with_progress(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_ACTIVE_TIMEOUT).await?;
cancel_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let canceled =
wait_for_decommission_flag(&run.dist.cluster, DECOMMISSION_POOL_ID, "canceled", TERMINAL_TRANSITION_TIMEOUT).await?;
let after_cancel = wait_for_single_pool_placement(&run, &manifest, &pools(&ALL_POOLS), "after cancel").await?;
verify_from_every_node(&run.dist, &manifest, "after cancel").await?;
clear_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
start_decommission_targets(&run.dist.cluster, "0,1").await?;
let queued = wait_for_decommission_flag(&run.dist.cluster, 1, "queued", Duration::from_secs(5)).await?;
wait_for_decommission_active(&run.dist.cluster, 0, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during = put_probe_objects(&run.dist, &scratch, "during-decommission", 4).await?;
wait_for_decommission_complete(&run.dist.cluster, 0, MOVEMENT_COMPLETE_TIMEOUT).await?;
wait_for_decommission_complete(&run.dist.cluster, 1, MOVEMENT_COMPLETE_TIMEOUT).await?;
let (rejected_status, rejected_body) = expect_decommission_rejected(&run.dist.cluster, "2,3").await?;
verify_from_every_node(&run.dist, &manifest, "after retiring pools 0 and 1").await?;
let final_placement = assert_single_pool_placement(&run.dist, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
assert_probe_objects(&run.dist, &scratch, &during, &pools(&[2, 3]), "writes issued during decommission").await?;
let sidecars = assert_sidecars_survive_on_targets(&run, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
// A decommissioned pool must leave the command line; the server refuses to
// start while it is still listed. Pools 2 and 3 are renumbered to 0 and 1.
run.dist.remove_retired_pools_and_restart(&[0, 1]).await?;
let pools_after_restart = pool_count(&run.dist.cluster).await?;
if pools_after_restart != 2 {
return Err(format!("expected two pools after removing the retired pools, found {pools_after_restart}").into());
}
verify_from_every_node(&run.dist, &manifest, "after restart without the retired pools").await?;
assert_single_pool_placement(&run.dist, &manifest, &pools(&[0, 1]), "after restart without the retired pools")?;
let status_after_restart = decommission_status_json(&run.dist.cluster).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-dec-decommission",
&json!({
"gate": "G-DEC",
"manifest": manifest_summary(&manifest),
"cancel": canceled,
"placement_after_cancel_pools": after_cancel.values().collect::<BTreeSet<_>>(),
"queued_pool_1": queued,
"final_placement_pools": final_placement.values().collect::<BTreeSet<_>>(),
"sidecars_on_targets": sidecars,
"last_pool_rejection": { "status": rejected_status.as_u16(), "body": rejected_body },
"status_after_restart": status_after_restart,
"process_durability_strict_nodes": strict_nodes,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// Outcome of a fault case: the worker result and the data verdict are
/// reported separately so a fail-closed worker and a lost version never hide
/// each other.
async fn settle_decommission(
run: &TopologyRun,
manifest: &TopologyManifest,
label: &str,
completion_timeout: Duration,
) -> TestResult<Value> {
let completion = wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, completion_timeout).await;
let allowed = if completion.is_ok() {
pools(&TARGET_POOLS)
} else {
pools(&ALL_POOLS)
};
let integrity = async {
verify_from_every_node(&run.dist, manifest, label).await?;
wait_for_single_pool_placement(run, manifest, &allowed, label).await
}
.await;
let status = decommission_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
settle(label, completion, integrity, status)
}
async fn settle_rebalance(run: &TopologyRun, manifest: &TopologyManifest, rebalance_id: &str, label: &str) -> TestResult<Value> {
let completion = wait_for_rebalance_complete(&run.dist.cluster, rebalance_id, MOVEMENT_COMPLETE_TIMEOUT).await;
let integrity = async {
verify_from_every_node(&run.dist, manifest, label).await?;
wait_for_single_pool_placement(run, manifest, &pools(&ALL_POOLS), label).await
}
.await;
let status = rebalance_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
settle(label, completion, integrity, status)
}
fn settle(
label: &str,
completion: TestResult,
integrity: TestResult<std::collections::BTreeMap<String, usize>>,
status: Value,
) -> TestResult<Value> {
match (completion, integrity) {
(Ok(()), Ok(placement)) => {
Ok(json!({ "worker": "completed", "placement_pools": placement.values().collect::<BTreeSet<_>>(), "status": status }))
}
(Err(worker), Ok(_)) => {
Err(format!("{label}: data stayed intact but the worker did not complete: {worker}; status {status}").into())
}
(Ok(()), Err(data)) => Err(format!("{label}: worker completed but data diverged: {data}").into()),
(Err(worker), Err(data)) => {
Err(format!("{label}: worker did not complete: {worker}; and data diverged: {data}; status {status}").into())
}
}
}
fn record(run: &TopologyRun, case: &str, outcome: &TestResult<Value>, mut evidence: Value) -> TestResult {
evidence["outcome"] = match outcome {
Ok(detail) => detail.clone(),
Err(error) => json!({ "error": error.to_string() }),
};
run.write_evidence(case, &evidence)?;
Ok(())
}
/// G-DEC fault: SIGKILL the decommission leader while an entry is held after
/// its target commit and before source cleanup; the resumed worker must
/// finish without losing or duplicating the version.
#[tokio::test]
async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-kill").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("external", 3)?;
run.movement_barrier
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let reached = run
.movement_barrier
.wait_reached_during(
&run.dist.cluster,
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
MOVEMENT_COMPLETE_TIMEOUT,
)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if !window.contains(&SEED_POOL) || window.len() < 2 {
return Err(format!(
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
)
.into());
}
let killed_pid = run.kill_node(SEED_POOL)?;
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed leader resumed decommission";
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-kill-before-source-cleanup",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_BEFORE_SOURCE_CLEANUP,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"pools_holding_at_window": window,
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-DEC fault: inject a target commit failure after the multipart copy is
/// staged; the source must stay authoritative and a later attempt completes.
#[tokio::test]
async fn scanner_heal_topology_decommission_target_commit_failure_retains_source() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-target-fail").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("multipart", 0)?;
run.movement_barrier
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_TARGET_COMMIT)?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let reached = run
.movement_barrier
.wait_reached_during(
&run.dist.cluster,
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
MOVEMENT_COMPLETE_TIMEOUT,
)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if window != pools(&[SEED_POOL]) {
return Err(format!("the target must not be published before its commit; {key} is held by {window:?}").into());
}
run.movement_barrier.disarm()?;
run.movement_barrier.release_movement_with_io_error()?;
let label = "after the failed target commit was retried";
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-target-commit-failure",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_BEFORE_TARGET_COMMIT,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"injection": "io_error",
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-DEC fault: kill the leader after the decommission start transaction is
/// durable in `pool.bin` but before the admin request is acknowledged and
/// before any worker starts; the persisted responsibility must resume on restart.
#[tokio::test]
async fn scanner_heal_topology_decommission_kill_after_responsibility_persisted_before_ack_resumes() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-ack").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
run.movement_barrier
.arm_movement("decommission_start", ".rustfs.sys", "pool.bin", STAGE_AFTER_RESPONSIBILITY_PERSISTED)?;
let leader_url = run.dist.cluster.nodes[SEED_POOL].url.clone();
let access_key = run.dist.cluster.access_key.clone();
let secret_key = run.dist.cluster.secret_key.clone();
let start_request = tokio::spawn(async move {
// Mirror start_decommission: the init stage answers 500 with the fleet
// proof marker until the fleet has proven its capability.
let path = format!("/rustfs/admin/v3/pools/decommission?pool={DECOMMISSION_POOL_ID}&by-id=true");
// A busy movement gate answers a retryable 503; keep retrying within
// the window budget so a refused start is never read as a reached window.
let deadline = Instant::now() + PRE_ACK_REACH_TIMEOUT;
loop {
match admin_request(&leader_url, Method::POST, &path, None, &access_key, &secret_key).await {
Ok((status, body))
if (status.as_u16() == 503
|| (status.as_u16() == 500 && body.contains("requires a live fleet capability proof")))
&& Instant::now() < deadline =>
{
sleep(Duration::from_secs(1)).await;
}
Ok((status, body)) => return Ok(format!("{status} {body}")),
Err(error) => return Err(error.to_string()),
}
}
});
// The start request normally reaches the durable save within about a
// second. If it returns first the window was never entered; if it stays in
// flight past the budget, the start path itself stalled before the save.
let requested_at = Instant::now();
let mut start_request = start_request;
let reached = tokio::select! {
reached = run.movement_barrier.wait_reached(PRE_ACK_REACH_TIMEOUT) => match reached {
Ok(reached) => reached,
Err(error) => {
return Err(format!(
"the decommission start stayed in flight for {PRE_ACK_REACH_TIMEOUT:?} without reaching the durable start save: {error}"
)
.into());
}
},
finished = &mut start_request => {
return Err(format!("the decommission start returned before the pre-ACK window was reached: {:?}", finished?).into());
}
};
let reach_elapsed = requested_at.elapsed();
let killed_pid = run.kill_node(SEED_POOL)?;
let start_outcome = start_request.await?;
if let Ok(response) = &start_outcome {
return Err(format!(
"the decommission start was acknowledged ({response}) although the leader was killed in the pre-ACK window"
)
.into());
}
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the persisted decommission resumed";
let resumed = wait_for_decommission_active(&run.dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(60)).await;
let outcome = match resumed {
Ok(()) => settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await,
Err(error) => Err(format!("{label}: the persisted decommission did not become active after the restart: {error}").into()),
};
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-kill-before-ack",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_AFTER_RESPONSIBILITY_PERSISTED,
"nonce": run.movement_barrier.nonce(),
"reached": reached,
"reach_elapsed_ms": reach_elapsed.as_millis() as u64,
"start_request": { "outcome": "uncertain", "error": start_outcome.err() },
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-REB fault: SIGKILL the rebalancing node while an entry is held after its
/// target commit and before source cleanup; the resumed run must complete
/// without losing or duplicating the version.
#[tokio::test]
async fn scanner_heal_topology_rebalance_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("rebal-kill").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("empty", 0)?;
run.movement_barrier
.arm_movement("rebalance", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
let rebalance_id = start_rebalance(&run.dist.cluster).await?;
let reached = run
.movement_barrier
.wait_reached_during(&run.dist.cluster, MovementOperation::Rebalance(&rebalance_id), MOVEMENT_COMPLETE_TIMEOUT)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if !window.contains(&SEED_POOL) || window.len() < 2 {
return Err(format!(
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
)
.into());
}
let killed_pid = run.kill_node(SEED_POOL)?;
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed node resumed rebalance";
let outcome = settle_rebalance(&run, &manifest, &rebalance_id, label).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-reb-kill-before-source-cleanup",
&outcome,
json!({
"gate": "G-REB",
"window": STAGE_BEFORE_SOURCE_CLEANUP,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"pools_holding_at_window": window,
"rebalance_id": rebalance_id,
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
@@ -1,599 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! G-TIER: real mover processes contend with a held remote DELETE while a
//! free version owns the old remote tuple beside a live transitioned version.
//! The remote is the HTTP fake S3 target, not a provider interoperability test.
use super::harness::{
DECOMMISSION_POOL_ID, DRIVES_PER_NODE, TestResult, cluster_admin_ok, decommission_status_json, enable_versioning,
rebalance_status_json, sha256_hex, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_rebalance_complete,
};
use super::publication_log::PublicationEvents;
use super::scanner_heal_topology_fixture::{
MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP,
TopologyRun, assert_bucket_durability_strict, assert_process_durability_strict, require_test_hooks,
};
use crate::common::init_logging;
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation, RequestRecord};
use crate::storage_api::OBJECT_LOCK_TRACE_FILTER;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition, TransitionStorageClass,
};
use http::Method;
use rustfs_filemeta::{FileMeta, TransitionVersionState};
use rustfs_utils::http::{SUFFIX_TRANSITION_TIER_DESTINATION_ID, get_consistent_str};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::path::Path;
use std::time::Duration;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::time::{Instant, sleep, timeout, timeout_at};
const TIER: &str = "TOPOLOGYCOLD";
const KEY: &str = "000-tier-owner.bin";
const TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
// Free-version cleanup holds its locks for at most 30 s during remote DELETE.
// Fail before that deadline instead of confusing a retry with the held attempt.
const DELETE_OVERLAP_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RemoteTuple {
bucket: String,
key: String,
version: String,
tier: String,
destination: String,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
struct Owner {
pool: usize,
drive: usize,
version: String,
free: bool,
remote: RemoteTuple,
}
fn owners(run: &TopologyRun, bucket: &str, remote_bucket: &str) -> TestResult<Vec<Owner>> {
let mut owners = Vec::new();
for node in &run.dist.cluster.nodes {
for (drive, root) in node.data_dirs.iter().enumerate() {
let bytes = match std::fs::read(Path::new(root).join(bucket).join(KEY).join("xl.meta")) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
for version in FileMeta::load(&bytes)?.get_file_info_versions(bucket, KEY, true)?.versions {
if version.transition_status != "complete" && !version.tier_free_version() {
continue;
}
assert_eq!(version.transition_version_state, TransitionVersionState::Exact);
owners.push(Owner {
pool: node.pool_idx,
drive,
version: version.version_id.ok_or("tier owner omitted its local version")?.to_string(),
free: version.tier_free_version(),
remote: RemoteTuple {
bucket: remote_bucket.to_string(),
key: version.transitioned_objname.clone(),
version: version
.transition_version
.clone()
.ok_or("tier owner omitted its remote version")?,
tier: version.transition_tier.clone(),
destination: get_consistent_str(&version.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
.ok_or("tier owner omitted its destination identity")?
.to_string(),
},
});
}
}
}
Ok(owners)
}
fn assert_remote(target: &FakeS3Target, remote: &RemoteTuple, expected: &[u8]) -> TestResult {
assert_eq!(target.stored_versions(&remote.bucket, &remote.key), vec![(remote.version.clone(), false)]);
let (body, metadata) = target
.stored_object(&remote.bucket, &remote.key)
.ok_or("remote bytes are missing")?;
assert_eq!(body.as_ref(), expected, "remote bytes changed for {remote:?}");
assert_eq!(
get_consistent_str(&metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
Some(remote.destination.as_str())
);
assert_eq!(remote.tier, TIER);
Ok(())
}
async fn transition_version(
run: &TopologyRun,
target: &FakeS3Target,
bucket: &str,
remote_bucket: &str,
body: &[u8],
) -> TestResult<(String, RemoteTuple)> {
let client = run.dist.client(0)?;
let put = client
.put_object()
.bucket(bucket)
.key(KEY)
.body(ByteStream::from(body.to_vec()))
.send()
.await?;
let version = put.version_id().ok_or("versioned PUT omitted its version")?.to_string();
cluster_admin_ok(
&run.dist.cluster,
Method::POST,
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={KEY}&tier={TIER}&dryRun=false"),
None,
)
.await?;
let deadline = Instant::now() + TRANSITION_TIMEOUT;
loop {
let current = owners(run, bucket, remote_bucket)?;
let shards: Vec<_> = current
.iter()
.filter(|owner| owner.version == version && !owner.free)
.collect();
if let Some(first) = shards.first()
&& shards.len() == DRIVES_PER_NODE
&& shards
.iter()
.all(|owner| owner.pool == SEED_POOL && owner.remote == first.remote)
{
let head = client
.head_object()
.bucket(bucket)
.key(KEY)
.version_id(&version)
.send()
.await?;
assert_eq!(head.storage_class().map(|class| class.as_str()), Some(TIER));
assert_remote(target, &first.remote, body)?;
return Ok((version, first.remote.clone()));
}
if Instant::now() >= deadline {
return Err(format!("transition of {bucket}/{KEY}@{version} did not reach every seed drive: {current:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
fn event_time(event: &Value) -> TestResult<OffsetDateTime> {
Ok(OffsetDateTime::parse(
event["timestamp"].as_str().ok_or("lock event omitted its timestamp")?,
&Rfc3339,
)?)
}
async fn pending_publication(events: &mut PublicationEvents, started: OffsetDateTime) -> TestResult<Value> {
loop {
let observed = events.poll()?;
for event in observed {
if event_time(event)? < started {
continue;
}
assert_ne!(
event["state"], "acquired",
"mover acquired its publication lock while remote DELETE was held"
);
if event["state"] == "acquiring"
&& !observed
.iter()
.any(|later| later["lock_attempt_id"] == event["lock_attempt_id"] && later["state"] != "acquiring")
{
assert!(event["lock_attempt_id"].as_str().is_some_and(|id| !id.is_empty()));
return Ok(event.clone());
}
}
sleep(Duration::from_millis(50)).await;
}
}
async fn acquired_publication(events: &mut PublicationEvents, pending: &Value) -> TestResult<Value> {
loop {
for event in events.poll()? {
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
continue;
}
match event["state"].as_str() {
Some("acquired") => return Ok(event.clone()),
Some("failed" | "cancelled") => return Err(format!("held publication attempt did not acquire: {event}").into()),
_ => {}
}
}
sleep(Duration::from_millis(50)).await;
}
}
fn deletes(target: &FakeS3Target, remote: &RemoteTuple) -> Vec<RequestRecord> {
target
.requests()
.into_iter()
.filter(|request| {
request.operation == Operation::DeleteObject
&& request.bucket == remote.bucket
&& request.key.as_deref() == Some(remote.key.as_str())
})
.collect()
}
async fn verify_live(run: &TopologyRun, bucket: &str, live_version: &str, old_version: &str, body: &[u8]) -> TestResult {
for client in run.dist.clients()? {
let got = client
.get_object()
.bucket(bucket)
.key(KEY)
.version_id(live_version)
.send()
.await?;
assert_eq!(got.body.collect().await?.into_bytes().as_ref(), body);
let latest = client.head_object().bucket(bucket).key(KEY).send().await?;
assert_eq!(latest.version_id(), Some(live_version));
assert_eq!(latest.storage_class().map(|class| class.as_str()), Some(TIER));
let deleted = client
.get_object()
.bucket(bucket)
.key(KEY)
.version_id(old_version)
.send()
.await;
let error = deleted.expect_err("deleted source version must not be visible");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
let listed = client.list_object_versions().bucket(bucket).prefix(KEY).send().await?;
assert_eq!(listed.versions().len(), 1, "free owners must not appear as object versions");
assert_eq!(listed.versions()[0].version_id(), Some(live_version));
assert!(
listed.delete_markers().is_empty(),
"moving the free owner must not create a delete marker"
);
}
Ok(())
}
async fn tier_delete_during_movement(decommission: bool) -> TestResult {
init_logging();
require_test_hooks();
let operation = if decommission { "decommission" } else { "rebalance" };
let target = FakeS3Target::start().await?;
let remote_bucket = unique_bucket("sht-cold");
target.create_bucket(&remote_bucket);
let mut run = TopologyRun::start_with_env(
&format!("tier-{operation}"),
&[
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true"),
("RUST_LOG", OBJECT_LOCK_TRACE_FILTER),
],
)
.await?;
let tier = json!({
"type": "rustfs",
"rustfs": {
"name": TIER, "endpoint": target.endpoint(), "accessKey": FAKE_ACCESS_KEY,
"secretKey": FAKE_SECRET_KEY, "bucket": remote_bucket, "prefix": "",
"region": "us-east-1", "storageClass": ""
}
});
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match cluster_admin_ok(&run.dist.cluster, Method::PUT, "/rustfs/admin/v3/tier", Some(tier.to_string())).await {
Ok(_) => break,
Err(_) if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
Err(error) => return Err(error),
}
}
let bucket = unique_bucket("sht-tier");
run.dist.create_bucket(&bucket).await?;
let client = run.dist.client(0)?;
enable_versioning(&client, &bucket).await?;
let rule = LifecycleRule::builder()
.id("tier-owner")
.status(ExpirationStatus::Enabled)
.filter(LifecycleRuleFilter::builder().prefix(KEY).build())
.transitions(
Transition::builder()
.days(0)
.storage_class(TransitionStorageClass::from(TIER))
.build(),
)
.build()?;
client
.put_bucket_lifecycle_configuration()
.bucket(&bucket)
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(rule).build()?)
.send()
.await?;
let old_body = vec![0x31; 4096];
let live_body = vec![0x92; 8192];
let (old_version, old_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &old_body).await?;
let (live_version, live_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &live_body).await?;
assert_ne!(
old_remote.key, live_remote.key,
"the obsolete and live versions need distinct remote tuples"
);
// Keep the seed pool above the rebalance goal until the first key is visited.
// These local objects are outside the transition rule's prefix.
for index in 0..16 {
client
.put_object()
.bucket(&bucket)
.key(format!("zz-load/{index:02}.bin"))
.body(ByteStream::from(vec![index as u8; 1024 * 1024]))
.send()
.await?;
}
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
run.dist.expand_to_four_pools().await?;
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
let seeded = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(seeded.len(), 2 * DRIVES_PER_NODE);
assert!(seeded.iter().all(|owner| owner.pool == SEED_POOL && !owner.free));
let mut decommission_start = Box::pin(start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID));
let mut startup = Value::Null;
if decommission {
run.movement_barrier.arm_movement(
"decommission_start",
".rustfs.sys",
"pool.bin",
STAGE_AFTER_RESPONSIBILITY_PERSISTED,
)?;
let requested_at = OffsetDateTime::now_utc();
let preparation_started = Instant::now();
let reached = tokio::select! {
reached = run.movement_barrier.wait_reached(MOVEMENT_COMPLETE_TIMEOUT) => {
reached.map_err(|error| format!(
"G-TIER decommission start preparation failed before DELETE: {error}"
))?
}
result = &mut decommission_start => {
return Err(format!(
"G-TIER decommission start preparation returned before durable-start barrier {}: {result:?}",
run.movement_barrier.nonce()
).into());
}
};
startup = json!({
"requested_at": requested_at.format(&Rfc3339)?,
"parked_at": OffsetDateTime::now_utc().format(&Rfc3339)?,
"preparation_elapsed_ms": preparation_started.elapsed().as_millis(),
"barrier": reached,
});
}
// The durable-start barrier has released the movement writer but has not
// started a worker. Install a separate release nonce before holding DELETE.
let cleanup_barrier = run.movement_barrier.next_phase();
cleanup_barrier.arm_movement(operation, &bucket, KEY, STAGE_BEFORE_SOURCE_CLEANUP)?;
let mut publication_events = PublicationEvents::new(run.node_log_paths(), &bucket, KEY)?;
let mut delete_gate = target.hold_delete_object(&remote_bucket, &old_remote.key);
// The public DELETE persists a free owner and enqueues its remote cleanup.
let client = run.dist.client(0)?;
client
.delete_object()
.bucket(&bucket)
.key(KEY)
.version_id(&old_version)
.send()
.await
.map_err(|error| format!("G-TIER {operation} public DELETE before remote gate failed: {error}; startup={startup}"))?;
timeout(TRANSITION_TIMEOUT, delete_gate.wait_until_entered())
.await
.map_err(|error| format!("G-TIER {operation} DELETE gate entry timed out: {error}; startup={startup}"))?
.map_err(|error| format!("G-TIER {operation} DELETE gate entry failed: {error}; startup={startup}"))?;
let delete_gate_entered_at = OffsetDateTime::now_utc();
let overlap_started = Instant::now();
let overlap_deadline = overlap_started + DELETE_OVERLAP_TIMEOUT;
let held = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(held.len(), 2 * DRIVES_PER_NODE);
let old_owners: Vec<_> = held.iter().filter(|owner| owner.remote == old_remote).collect();
assert_eq!(old_owners.len(), DRIVES_PER_NODE);
assert!(
old_owners
.iter()
.all(|owner| owner.pool == SEED_POOL && owner.free && owner.version != old_version)
);
assert_eq!(old_owners.iter().map(|owner| &owner.version).collect::<BTreeSet<_>>().len(), 1);
assert_eq!(
held.iter()
.filter(|owner| {
owner.pool == SEED_POOL && !owner.free && owner.version == live_version && owner.remote == live_remote
})
.count(),
DRIVES_PER_NODE
);
assert_remote(&target, &old_remote, &old_body)?;
assert_remote(&target, &live_remote, &live_body)?;
let held_deletes = deletes(&target, &old_remote);
assert!(!held_deletes.is_empty());
assert!(held_deletes.iter().all(|request| {
request.prepared_response.is_none() && request.version_id.as_deref() == Some(old_remote.version.as_str())
}));
assert!(deletes(&target, &live_remote).is_empty());
let movement_started = OffsetDateTime::now_utc();
if decommission {
startup["released_at"] = json!(movement_started.format(&Rfc3339)?);
run.movement_barrier.release_movement()?;
}
let rebalance_id = timeout_at(overlap_deadline, async {
if decommission {
decommission_start.as_mut().await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(None)
} else {
Ok(Some(start_rebalance(&run.dist.cluster).await?))
}
})
.await
.map_err(|error| {
format!("G-TIER {operation} start acknowledgment timed out within DELETE overlap: {error}; startup={startup}")
})?
.map_err(|error| {
format!("G-TIER {operation} start acknowledgment failed within DELETE overlap: {error}; startup={startup}")
})?;
drop(decommission_start);
let movement_acknowledged_at = OffsetDateTime::now_utc();
let pending = timeout_at(overlap_deadline, pending_publication(&mut publication_events, movement_started))
.await
.map_err(|error| format!(
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
publication_events.cached(), cleanup_barrier.reached_observations()
))?
.map_err(|error| format!("G-TIER {operation} publication pending failed within DELETE overlap: {error}"))?;
// Positive gate entry, exact physical ownership and the pending lock attempt
// establish the overlap; an absent log alone is not an ordering assertion.
assert_eq!(owners(&run, &bucket, &remote_bucket)?, held);
assert_remote(&target, &old_remote, &old_body)?;
let released_at = OffsetDateTime::now_utc();
let delete_overlap_elapsed_ms = overlap_started.elapsed().as_millis();
drop(delete_gate);
let acquired = timeout(DELETE_OVERLAP_TIMEOUT, acquired_publication(&mut publication_events, &pending))
.await
.map_err(|error| format!(
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
publication_events.cached(), cleanup_barrier.reached_observations()
))?
.map_err(|error| format!("G-TIER {operation} publication acquired after DELETE release failed: {error}"))?;
assert!(event_time(&pending)? >= movement_started && event_time(&pending)? < released_at);
assert!(
event_time(&acquired)? >= released_at,
"the same publication attempt must acquire after DELETE is released"
);
drop(publication_events);
let reached = cleanup_barrier
.wait_reached_during(
&run.dist.cluster,
match rebalance_id.as_deref() {
Some(id) => MovementOperation::Rebalance(id),
None => MovementOperation::Decommission(DECOMMISSION_POOL_ID),
},
MOVEMENT_COMPLETE_TIMEOUT,
)
.await
.map_err(|error| format!("G-TIER {operation} before-source-cleanup barrier failed: {error}"))?;
let published = owners(&run, &bucket, &remote_bucket)?;
assert!(
published
.iter()
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
);
let publication_pools: BTreeSet<_> = published.iter().map(|owner| owner.pool).collect();
assert!(publication_pools.contains(&SEED_POOL) && publication_pools.iter().any(|pool| *pool != SEED_POOL));
assert_eq!(published.len(), publication_pools.len() * DRIVES_PER_NODE);
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
assert_remote(&target, &live_remote, &live_body)?;
cleanup_barrier.release_movement()?;
cleanup_barrier.disarm()?;
let completion = match rebalance_id.as_deref() {
Some(id) => {
wait_for_rebalance_complete(&run.dist.cluster, id, MOVEMENT_COMPLETE_TIMEOUT).await?;
rebalance_status_json(&run.dist.cluster).await?
}
None => {
wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await?;
decommission_status_json(&run.dist.cluster).await?
}
};
let settled = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(settled.len(), DRIVES_PER_NODE);
assert_eq!(settled.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
assert!(
settled
.iter()
.all(|owner| owner.pool != SEED_POOL && !owner.free && owner.remote == live_remote)
);
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
if decommission {
run.dist.remove_retired_pools_and_restart(&[SEED_POOL]).await?;
} else {
run.dist.restart_current_binary_gracefully().await?;
}
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
let restarted = owners(&run, &bucket, &remote_bucket)?;
assert_eq!(restarted.len(), DRIVES_PER_NODE);
assert_eq!(restarted.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
assert!(
restarted
.iter()
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
);
assert_remote(&target, &live_remote, &live_body)?;
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
assert!(
deletes(&target, &live_remote).is_empty(),
"the live transitioned tuple must never receive DELETE"
);
let remote_deletes = deletes(&target, &old_remote);
assert!(!remote_deletes.is_empty());
assert!(
remote_deletes
.iter()
.all(|request| request.version_id.as_deref() == Some(old_remote.version.as_str()))
);
let successful = remote_deletes
.iter()
.filter_map(|request| request.prepared_response.as_ref())
.find(|response| response.status == 204)
.ok_or("old remote tuple never completed DELETE")?;
let deleted_at_ms = i128::from(
successful
.prepared_at_unix_millis
.ok_or("DELETE response omitted its timestamp")?,
);
assert!(deleted_at_ms >= released_at.unix_timestamp_nanos() / 1_000_000);
assert!(event_time(&acquired)?.unix_timestamp_nanos() / 1_000_000 >= deleted_at_ms);
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
&format!("g-tier-{operation}"),
&json!({
"gate": "G-TIER", "operation": operation, "rebalance_id": rebalance_id,
"startup": startup,
"delete_gate_entered_at": delete_gate_entered_at.format(&Rfc3339)?,
"movement_overlap_started_at": movement_started.format(&Rfc3339)?,
"movement_acknowledged_at": movement_acknowledged_at.format(&Rfc3339)?,
"delete_overlap_timeout_ms": DELETE_OVERLAP_TIMEOUT.as_millis(),
"delete_overlap_elapsed_ms": delete_overlap_elapsed_ms,
"remote_backend": "HTTP fake_s3_target with versioning enabled",
"ordering": "remote DELETE held, mover publication waiting, DELETE released, same publication acquired",
"bucket": bucket, "key": KEY, "bucket_durability": bucket_durability,
"process_durability_strict_nodes": strict_nodes,
"old_version": old_version, "old_remote": old_remote, "old_sha256": sha256_hex(&old_body),
"live_version": live_version, "live_remote": live_remote, "live_sha256": sha256_hex(&live_body),
"owners_while_delete_held": held, "publication_waiting": pending, "publication_acquired": acquired,
"held_delete_sequences": held_deletes.iter().map(|request| request.sequence).collect::<Vec<_>>(),
"delete_released_at": released_at.format(&Rfc3339)?, "remote_delete_completed_at_unix_millis": deleted_at_ms,
"barrier": reached, "owners_before_source_cleanup": published, "owners_after_movement": settled,
"owners_after_restart": restarted, "completion": completion,
"old_remote_delete_attempts": remote_deletes.len(), "live_remote_delete_attempts": 0,
"source_readback_nodes": run.dist.cluster.nodes.len(),
}),
)?;
Ok(())
}
#[tokio::test]
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_rebalance() -> TestResult {
tier_delete_during_movement(false).await
}
#[tokio::test]
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_decommission() -> TestResult {
tier_delete_during_movement(true).await
}
@@ -60,7 +60,7 @@ struct UpgradeSeed {
iam_secret: &'static str,
}
pub(super) fn source_binary() -> TestResult<PathBuf> {
fn source_binary() -> TestResult<PathBuf> {
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
format!(
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
@@ -17,14 +17,6 @@ use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
#[tokio::test]
async fn four_node_explicit_delete_marker_batch_is_immediately_visible() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("explicit-marker-batch");
crate::delete_objects_versioning_test::assert_explicit_marker_batch_cleanup(&dist.clients()?, &bucket).await
}
#[tokio::test]
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
init_logging();
@@ -18,7 +18,7 @@
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via,
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -41,6 +41,21 @@ fn sts_session_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
))
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let ak = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let sk = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((ak, sk, token))
}
async fn assume_role_with_session_policy(
env: &RustFSTestEnvironment,
parent_ak: &str,
+1 -1
View File
@@ -6,7 +6,7 @@ This module is the shared failure-injection boundary for replication end-to-end
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Four switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). `ignore_source_etags(true)` ignores the `x-rustfs-source-etag` / `x-minio-source-etag` replication headers and reports the ETag of the received bytes (AWS S3 and other generic S3 services; the default adopts the source ETag like RustFS/MinIO). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
+17 -126
View File
@@ -487,43 +487,36 @@ impl JournaledHeaders {
struct ControlState {
scripts: HashMap<Operation, VecDeque<FaultAction>>,
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
held_get: Option<HeldObject>,
held_delete: Option<HeldObject>,
held_get: Option<HeldGetObject>,
requests: VecDeque<RequestRecord>,
next_sequence: u64,
}
#[derive(Clone)]
struct HeldObject {
struct HeldGetObject {
bucket: String,
key: String,
entered: watch::Sender<usize>,
released: watch::Receiver<bool>,
}
/// Holds every matching operation, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held requests"]
pub struct ObjectGate {
/// Holds every GET of one object, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held GET requests"]
pub struct GetObjectGate {
control: Arc<Mutex<ControlState>>,
operation: Operation,
entered: watch::Receiver<usize>,
released: watch::Sender<bool>,
}
impl ObjectGate {
impl GetObjectGate {
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
}
}
impl Drop for ObjectGate {
impl Drop for GetObjectGate {
fn drop(&mut self) {
let mut state = lock(&self.control);
match self.operation {
Operation::GetObject => state.held_get = None,
Operation::DeleteObject => state.held_delete = None,
_ => unreachable!("only GET and DELETE have object gates"),
}
lock(&self.control).held_get = None;
self.released.send_replace(true);
}
}
@@ -544,10 +537,6 @@ struct StoreState {
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
/// `Content-MD5` or an `x-amz-checksum-*` header.
require_checksum_for_object_lock: bool,
/// Models AWS S3 and other generic S3 services: the `x-rustfs-source-etag`
/// / `x-minio-source-etag` replication headers are ignored and every
/// stored object reports the ETag computed from the bytes it received.
ignore_source_etags: bool,
/// Models Wasabi (rustfs/backlog#2340): a version-addressed DELETE of a
/// version id the target never had answers 404 `NoSuchVersion` instead of
/// the idempotent 204 RustFS/MinIO give.
@@ -1063,13 +1052,6 @@ impl FakeS3Target {
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
}
/// AWS-like mode: ignore the replication source-ETag headers and report
/// the ETag of the received bytes, as AWS S3 does for SSE-S3 and
/// unencrypted objects. The default adopts the source ETag (RustFS/MinIO).
pub fn ignore_source_etags(&self, enabled: bool) {
lock(&self.backend.store).ignore_source_etags = enabled;
}
pub fn assign_own_multipart_version_ids(&self, enabled: bool) {
lock(&self.backend.store).assign_own_multipart_version_ids = enabled;
}
@@ -1115,40 +1097,24 @@ impl FakeS3Target {
}
/// Hold one exact bucket/key before any GET response can reach the client.
/// The fixture supports one live GET gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> ObjectGate {
self.hold_object_operation(Operation::GetObject, bucket, key)
}
/// Hold one exact bucket/key before DELETE changes any stored version.
/// The fixture supports one live DELETE gate; request deadlines still apply.
pub fn hold_delete_object(&self, bucket: &str, key: &str) -> ObjectGate {
self.hold_object_operation(Operation::DeleteObject, bucket, key)
}
fn hold_object_operation(&self, operation: Operation, bucket: &str, key: &str) -> ObjectGate {
/// The fixture supports one live gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
assert!(
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
"held object identifiers exceed the fixture limit"
"held GET identifiers exceed the fixture limit"
);
let mut state = lock(&self.control);
let slot = match operation {
Operation::GetObject => &mut state.held_get,
Operation::DeleteObject => &mut state.held_delete,
_ => unreachable!("only GET and DELETE have object gates"),
};
assert!(slot.is_none(), "fake target already holds a {operation:?} gate");
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
let (entered, entered_rx) = watch::channel(0);
let (released, released_rx) = watch::channel(false);
*slot = Some(HeldObject {
state.held_get = Some(HeldGetObject {
bucket: bucket.to_string(),
key: key.to_string(),
entered,
released: released_rx,
});
ObjectGate {
GetObjectGate {
control: Arc::clone(&self.control),
operation,
entered: entered_rx,
released,
}
@@ -2478,13 +2444,12 @@ impl S3 for FakeBackend {
.map_err(|_| s3s::s3_error!(ServiceUnavailable, "fake target body limiter closed"))?;
let headers = req.headers;
let input = req.input;
let (assign_own, drop_unlisted, require_checksum, ignore_source_etags, limits, versioned) = {
let (assign_own, drop_unlisted, require_checksum, limits, versioned) = {
let state = lock(&self.store);
(
state.assign_own_version_ids,
state.drop_unlisted_replication_headers,
state.require_checksum_for_object_lock,
state.ignore_source_etags,
state.limits,
bucket_versioned(&state, &input.bucket),
)
@@ -2524,7 +2489,7 @@ impl S3 for FakeBackend {
} else {
NULL_VERSION_ID.to_string()
};
let e_tag = match source_etag(&headers)?.filter(|_| !ignore_source_etags) {
let e_tag = match source_etag(&headers)? {
Some(value) => value,
None => {
let (digest, permit) = md5_digest(body.clone(), body_permit).await?;
@@ -2818,15 +2783,6 @@ impl S3 for FakeBackend {
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let headers = req.headers;
let input = req.input;
let held_delete = lock(&self.control)
.held_delete
.as_ref()
.filter(|held| held.bucket == input.bucket && held.key == input.key)
.cloned();
if let Some(mut held) = held_delete {
held.entered.send_modify(|count| *count += 1);
let _ = held.released.wait_for(|released| *released).await;
}
let mut state = lock(&self.store);
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
@@ -3156,10 +3112,7 @@ impl S3 for FakeBackend {
.collect();
let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?;
let part_count = requested.len();
let ignore_source_etags = lock(&self.store).ignore_source_etags;
let e_tag = source_etag(&headers)?
.filter(|_| !ignore_source_etags)
.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let version = ObjectVersion {
version_id: upload.version_id.clone(),
body,
@@ -3468,68 +3421,6 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn delete_object_gate_retains_exact_version_until_release() -> Result<(), BoxError> {
let target = FakeS3Target::start().await?;
let bucket = "delete-gated-target";
target.create_bucket(bucket);
for key in ["held", "unrelated"] {
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
}
let versions = target.stored_versions(bucket, "held");
let mut gate = target.hold_delete_object(bucket, "held");
let client = client(&target);
let held_client = client.clone();
let version = versions[0].0.clone();
let mut requests = tokio::task::JoinSet::new();
requests.spawn(async move {
held_client
.delete_object()
.bucket(bucket)
.key("held")
.version_id(version)
.send()
.await
});
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
assert_eq!(
target.stored_versions(bucket, "held"),
versions,
"the gate must precede the storage mutation"
);
assert!(requests.try_join_next().is_none());
let held = target
.requests()
.into_iter()
.find(|record| record.operation == Operation::DeleteObject && record.key.as_deref() == Some("held"))
.expect("the held DELETE must be journaled");
assert_eq!(held.version_id.as_deref(), Some(versions[0].0.as_str()));
assert_eq!(held.prepared_response, None);
client
.delete_object()
.bucket(bucket)
.key("unrelated")
.version_id(&target.stored_versions(bucket, "unrelated")[0].0)
.send()
.await?;
assert!(target.stored_versions(bucket, "unrelated").is_empty());
assert_eq!(target.stored_versions(bucket, "held"), versions);
drop(gate);
timeout(Duration::from_secs(2), requests.join_next())
.await?
.expect("held DELETE task")??;
assert!(target.stored_versions(bucket, "held").is_empty());
let released = target
.requests()
.into_iter()
.find(|record| record.sequence == held.sequence)
.and_then(|record| record.prepared_response)
.expect("released DELETE response");
assert_eq!(released.status, 204);
assert_eq!(released.version_id.as_deref(), Some(versions[0].0.as_str()));
Ok(())
}
#[tokio::test]
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata;

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