Compare commits

..

3 Commits

Author SHA1 Message Date
overtrue 1ae146aa1f test(decommission): prove the shared gate budget and each bounded wait
Second review of 652c56875..0ef02a485:

- The operator doc and the budget comment claimed a forwarded start that
  outlives the internode RPC timeout still completes on the leader. The
  leader drops it wherever it is, possibly after pool.bin records the
  pool as active but before its worker starts or the peers reload.
  Document that state, the watchdog that resumes the worker, and the
  checks to run; the cancel-safety fix is tracked in rustfs/backlog#2873.
- Prove the budget is shared across waits (paused clock: a 60 ms wait
  leaves 40 ms for the next gate).
- Prove the rebalance-refresh start gate and activation gate waits and
  the decommission() entry are bounded and named.

Refs rustfs/backlog#2873
2026-10-20 12:00:10 +09:00
overtrue 0ef02a4858 fix(decommission): count only gate waiting in the start budget
Review of 652c56875 found that the 30 s start budget was a wall-clock
deadline fixed when the start began, so slow preflight work (bucket heals,
capacity scans) could spend it before the activation gates were reached.
A gate held for a few milliseconds then failed the start at once, and a
retry repeated the preflight.

- Replace the deadline with a budget that only counts time spent actually
  waiting for a gate; a free gate is always taken, and the lock timeout
  reports the time waited.
- Lower the budget to 20 s, below the default 30 s internode RPC timeout,
  so a forwarded start gets the leader's answer instead of an RPC timeout.
- Name each bounded wait (rebalance metadata refresh or decommission
  activation, and which gate), and route every start-gate rebalance load
  through one load_rebalance_meta_within.
- Move the admin pre-check error mapping into a function and test it.
- Add proof tests: a slow preflight followed by a brief gate wait still
  starts; the activation wait and the admin pre-check are bounded and
  named; the rebalance status read does not wait for an in-flight
  rebalance entry.
- Correct the operator doc: preflight bucket heals are outside the budget,
  and forwarded starts report a generic internal error.

Refs rustfs/backlog#2873
2026-10-20 11:29:55 +09:00
overtrue 652c56875b fix(decommission): keep status reads and starts off the movement gate
The data-movement operation gate is a write-preferring RwLock. A scanner
publication lease stores a read guard on the leasing node, and the
coordinator's lease-scoped rename, delete and validation on that node
queued a second read. A movement writer queued in between (a decommission
start, a status refresh, a peer reload) then waited for the lease while
the coordinator's second read waited for the writer, so both stalled
until the 60 s lease TTL. Admin status reads also took the movement
writer, so every status call waited for the slowest in-flight copy or
cleanup and stalled new movement reads while it queued.

- Lease-scoped target admission shares the read admission the lease
  already holds instead of queueing a new read.
- Pool and rebalance status reads publish under the gates only when they
  are free; otherwise they merge the persisted metadata into a private
  copy and publish nothing.
- A decommission start bounds its start, activation and movement gate
  waits with one 30 s budget and fails with a retryable lock timeout
  before persisting anything; the admin handler maps it to 503 with
  Retry-After.

Refs rustfs/backlog#2873
2026-10-20 06:39:50 +09:00
503 changed files with 18402 additions and 122773 deletions
@@ -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 | [Reconcile Security Advisories, 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.
@@ -48,10 +48,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.
@@ -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.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
sha256-darwin=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
sha256-darwin=3a7f52dd884ed269746029e983006ce69b62f260db2ad1acdcffdb2196c65d11
sha256-linux=4af4bb607919b96e63e2a3fb7a78bd18fcb08e265f4ca4ce6afb09a8be798305
+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=ce16185f87c6cd30aa0891ebd22be3352aedc10040d48cc5b00652d72f7be3b5
+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",
+2 -58
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"
@@ -94,29 +83,12 @@ threads-required = "num-test-threads"
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]]
@@ -362,17 +334,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"
@@ -388,29 +349,12 @@ threads-required = "num-test-threads"
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]]
+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
-27
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/**"
@@ -130,7 +127,6 @@ 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: |
@@ -164,7 +160,6 @@ jobs:
)
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
@@ -188,28 +183,6 @@ 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
@@ -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
-4
View File
@@ -103,10 +103,6 @@ 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"
-3
View File
@@ -19,15 +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.
Generated
+673 -533
View File
File diff suppressed because it is too large Load Diff
+31 -50
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"
@@ -147,7 +146,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.51" }
async-compression = { version = "0.4.50" }
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9"
@@ -157,7 +156,7 @@ futures-lite = "2.6.1"
futures-util = "0.3.34"
pollster = "1.0.1"
pulsar = { default-features = false, version = "=6.9.0" }
lapin = { default-features = false, version = "4.12.3" }
lapin = { default-features = false, version = "4.12.2" }
hyper = { version = "1.12.0" }
h2 = { version = "0.4.20" }
hyper-rustls = { default-features = false, version = "0.27.10" }
@@ -170,7 +169,7 @@ minlz = "1.2.3"
reqwest = "0.13.5"
rustfs-kafka-async = { version = "1.4.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.54.1" }
tokio = { version = "1.53.3" }
tokio-rustls = { default-features = false, version = "0.26.6" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.6"
@@ -244,24 +243,24 @@ tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.104"
arc-swap = "1.9.3"
arc-swap = "1.9.2"
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"
atoi = "3.1.1"
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.13.0" }
aws-credential-types = { version = "1.3.1" }
aws-sdk-kms = { default-features = false, version = "1.126.0" }
aws-sdk-s3 = { default-features = false, version = "1.155.0" }
aws-sdk-sts = { default-features = false, version = "1.122.0" }
aws-smithy-async = { version = "1.3.1" }
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" }
aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.123.0" }
aws-sdk-s3 = { default-features = false, version = "1.152.0" }
aws-sdk-sts = { default-features = false, version = "1.119.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
aws-smithy-runtime-api = { version = "1.19.0" }
aws-smithy-types = { version = "1.8.1" }
base64-simd = "0.8.0"
brotli = "9.1.0"
brotli = "9.0.0"
clap = { version = "4.6.7" }
const-str = { version = "1.1.0" }
convert_case = "0.12.0"
@@ -273,12 +272,12 @@ crossbeam-utils = "0.8.23"
datafusion = { default-features = false, version = "55.2.0" }
derive_builder = "0.20.2"
ed25519-dalek = "3.0.0"
enumset = "1.1.15"
enumset = "1.1.14"
faster-hex = "1.0.0"
flate2 = "1.1.10"
glob = "0.3.4"
google-cloud-storage = "1.21.0"
google-cloud-auth = "1.18.0"
google-cloud-storage = "1.20.0"
google-cloud-auth = "1.17.0"
hashbrown = { version = "0.17.1" }
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
# every authenticator app expects). Already in the graph transitively.
@@ -288,11 +287,11 @@ highway = { version = "1.3.0" }
hostname = "0.4.2"
ipnetwork = { version = "0.21.1" }
lazy_static = "1.5.1"
libc = "0.2.191"
libc = "0.2.190"
libsystemd = "0.7.2"
local-ip-address = "0.6.13"
log = "0.4"
memmap2 = "0.9.12"
memmap2 = "0.9.11"
lz4 = "1.28.1"
matchit = "0.9.2"
md-5 = "0.11.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
@@ -319,7 +317,7 @@ rustfs-erasure-codec = { version = "9.0.0" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.8.0" }
redis = { version = "1.7.1" }
rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.5" }
rust-embed = { version = "8.13.0" }
@@ -329,7 +327,7 @@ s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fc
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.4"
smallvec = { version = "1.16.4" }
smallvec = { version = "1.16.3" }
compact_str = "0.10.0"
snap = "1.1.2"
starshard = { version = "2.4.0" }
@@ -348,7 +346,7 @@ tracing-subscriber = { version = "0.3.23" }
transform-stream = "0.3.1"
url = "2.5.8"
urlencoding = "2.1.3"
uuid = { version = "1.29.0" }
uuid = { version = "1.28.0" }
vaultrs = { version = "0.8.0" }
tar = "0.4.46"
walkdir = "2.5.0"
@@ -356,13 +354,13 @@ 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"
zstd = "0.14.3"
zip = "9.0.3"
zstd = "0.14.2"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.5", default-features = false }
dial9 = { version = "0.5.4", default-features = false }
opentelemetry = { version = "0.33.1" }
opentelemetry-appender-tracing = { version = "0.33.1" }
opentelemetry-otlp = { version = "0.33.1" }
@@ -370,23 +368,23 @@ opentelemetry-proto = { version = "0.33.1", default-features = false, features =
opentelemetry_sdk = { version = "0.33.1" }
opentelemetry-semantic-conventions = { version = "0.33.1" }
opentelemetry-stdout = { version = "0.33.1" }
pyroscope = { version = "2.1.5" }
pyroscope = { version = "2.1.2" }
# FTP and SFTP
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.6", 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" }
+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.3
```
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.3 --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.3 # 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.3
```
如果您通过绑定挂载启用 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.3 --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.3 # 构建指定版本
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);
}
}
-7
View File
@@ -116,7 +116,6 @@ 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)
@@ -128,12 +127,6 @@ 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.
-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;
+2 -6
View File
@@ -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.
-12
View File
@@ -53,9 +53,6 @@ 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";
@@ -96,7 +93,6 @@ 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,
@@ -220,14 +216,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`
+1 -3
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,6 @@ 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"] }
+3 -4
View File
@@ -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 -152
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};
@@ -1272,7 +1272,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 +1312,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 +1374,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 +1467,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 +1475,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 +1484,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 +1504,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 +1693,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 +1824,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 +1846,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 +2397,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 +2414,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 +2427,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 +2514,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));
@@ -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
+24 -175
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());
}
@@ -1367,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");
@@ -1543,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();
+14 -108
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);
}
}
@@ -1115,40 +1108,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,
}
@@ -2818,15 +2795,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"));
@@ -3468,68 +3436,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;
@@ -21,7 +21,7 @@ mod tests {
};
use crate::common::{
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
init_logging, local_http_client, rustfs_binary_path,
init_logging, rustfs_binary_path,
};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::{
@@ -53,196 +53,6 @@ mod tests {
expected_pool_metadata: Option<VersionShardCensus>,
}
fn pool_metadata_matches_republished_manifest(actual: &VersionShardCensus, expected: &VersionShardCensus) -> bool {
for data_dir in [&actual.data_dir, &expected.data_dir] {
if data_dir
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.is_none_or(|value| value.is_nil())
{
return false;
}
}
// The owning CAS republishes the same canonical payload with a fresh
// physical identity. Every shard and layout field must still match.
let mut republished = actual.clone();
republished.data_dir = expected.data_dir.clone();
republished.matches_manifest(expected)
}
fn pool_metadata_node_health_is_ready(status: u16, payload: &serde_json::Value) -> bool {
(200..300).contains(&status)
&& payload["ready"] == true
&& payload["details"]["poolMetadata"]["ready"] == true
&& payload["details"]["storage"]["readQuorum"] == true
&& payload["details"]["storage"]["writeQuorum"] == true
}
async fn all_nodes_pool_metadata_readiness(
cluster: &RustFSTestClusterEnvironment,
deadline: Instant,
) -> (bool, serde_json::Value) {
let http = local_http_client();
let mut ready = !cluster.nodes.is_empty();
let mut observations = Vec::with_capacity(cluster.nodes.len());
for (node, environment) in cluster.nodes.iter().enumerate() {
let response = tokio::time::timeout_at(deadline, async {
let response = http
.get(format!("{}/health/ready", environment.url))
.timeout(Duration::from_secs(3))
.send()
.await?;
let status = response.status().as_u16();
let payload = response.json::<serde_json::Value>().await?;
Ok::<_, reqwest::Error>((status, payload))
})
.await;
let observation = match response {
Ok(Ok((status, payload))) => {
let node_ready = pool_metadata_node_health_is_ready(status, &payload);
ready &= node_ready;
serde_json::json!({"node": node, "status": status, "ready": node_ready, "payload": payload})
}
Ok(Err(error)) => {
ready = false;
serde_json::json!({"node": node, "error": error.to_string()})
}
Err(_) => {
ready = false;
serde_json::json!({"node": node, "error": "pool metadata health deadline elapsed"})
}
};
observations.push(observation);
}
let deadline_elapsed = Instant::now() >= deadline;
(
ready && !deadline_elapsed,
serde_json::json!({"deadline_elapsed": deadline_elapsed, "nodes": observations}),
)
}
fn pool_metadata_manifest_fixture() -> VersionShardCensus {
VersionShardCensus {
version_id: None,
has_xl_meta: true,
data_dir: Some(uuid::Uuid::from_u128(1).to_string()),
erasure_index: Some(1),
erasure_distribution: Some(vec![1, 2, 3, 4]),
data_blocks: Some(2),
parity_blocks: Some(2),
expected_part_numbers: Default::default(),
present_part_fingerprints: Default::default(),
inline_data_fingerprint: Some(crate::chaos::PartShardFingerprint {
size: 7,
sha256: sha256_hex(b"payload"),
}),
}
}
#[test]
fn pool_metadata_republication_allows_only_valid_data_dir_change() {
let expected = pool_metadata_manifest_fixture();
let mut actual = expected.clone();
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
assert!(
!actual.matches_manifest(&expected),
"ordinary object manifests must retain their original identity"
);
for invalid in [
None,
Some(String::new()),
Some("invalid".to_string()),
Some(uuid::Uuid::nil().to_string()),
] {
let mut changed = actual.clone();
changed.data_dir = invalid.clone();
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed_expected = expected.clone();
changed_expected.data_dir = invalid;
assert!(!pool_metadata_matches_republished_manifest(&actual, &changed_expected));
}
let mut changed = actual.clone();
changed
.inline_data_fingerprint
.as_mut()
.expect("fixture has inline payload")
.sha256 = sha256_hex(b"corrupt");
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.erasure_index = Some(2);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.erasure_distribution = Some(vec![2, 1, 3, 4]);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.data_blocks = Some(1);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.parity_blocks = Some(3);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual.clone();
changed.version_id = Some(uuid::Uuid::from_u128(3).to_string());
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual;
changed.has_xl_meta = false;
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
}
#[test]
fn pool_metadata_republication_requires_exact_complete_part_fingerprints() {
let mut expected = pool_metadata_manifest_fixture();
expected.inline_data_fingerprint = None;
expected.expected_part_numbers.insert(1);
expected.present_part_fingerprints.insert(
1,
crate::chaos::PartShardFingerprint {
size: 7,
sha256: sha256_hex(b"payload"),
},
);
let mut actual = expected.clone();
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
let mut changed = actual.clone();
changed.present_part_fingerprints.remove(&1);
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
let mut changed = actual;
changed
.present_part_fingerprints
.get_mut(&1)
.expect("fixture has part 1")
.sha256 = sha256_hex(b"corrupt");
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
}
#[test]
fn pool_metadata_node_health_requires_explicit_metadata_and_both_quorums() {
let expected = serde_json::json!({"ready": true, "details": {
"poolMetadata": {"ready": true},
"storage": {"readQuorum": true, "writeQuorum": true}
}});
assert!(pool_metadata_node_health_is_ready(200, &expected));
assert!(!pool_metadata_node_health_is_ready(503, &expected));
assert!(!pool_metadata_node_health_is_ready(200, &serde_json::json!({"ready": true})));
for path in [
"/ready",
"/details/poolMetadata/ready",
"/details/storage/readQuorum",
"/details/storage/writeQuorum",
] {
for value in [
serde_json::Value::Bool(false),
serde_json::Value::Null,
serde_json::json!("true"),
] {
let mut changed = expected.clone();
*changed.pointer_mut(path).expect("fixture contains required health field") = value;
assert!(!pool_metadata_node_health_is_ready(200, &changed), "required health field {path}");
}
}
}
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
@@ -1396,9 +1206,7 @@ mod tests {
let interval = interval
.filter(|interval| *interval != 0)
.ok_or("scanner schedule has no positive interval")?;
// A new leader may already be sleeping before completing its first cycle.
let schedule_start = if cycle_end == 0 { now } else { cycle_end };
let next_cycle = schedule_start
let next_cycle = cycle_end
.checked_add(interval)
.ok_or("scanner schedule timestamp overflows")?;
let budget = next_cycle
@@ -1415,11 +1223,6 @@ mod tests {
Ok(Duration::from_secs(259)),
);
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
// A newly elected leader can advertise a sleep before its first completed cycle.
assert_eq!(
scanner_cycle_completion_budget(0, Some(289), 1_792_913_211, false),
Ok(Duration::from_secs(349)),
);
}
#[test]
@@ -1440,7 +1243,7 @@ mod tests {
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let mut deadline = Instant::now() + Duration::from_secs(60);
let mut accounted_schedules = HashSet::new();
let mut schedule_accounted = false;
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
@@ -1487,27 +1290,15 @@ mod tests {
return Err("scanner cycle timestamp is in the future".into());
}
let schedule = &status["cycle_schedule"];
if schedule["execution_role"].as_str() == Some("leader") {
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
let active = metrics["current_cycle_active"]
.as_bool()
.ok_or("scanner status is missing its active-cycle flag")?;
if active || schedule["effective_interval_available"].as_bool() == Some(true) {
let interval = if active {
None
} else {
Some(
schedule["effective_interval_seconds"]
.as_u64()
.ok_or("scanner schedule is missing its effective interval")?,
)
};
// Reconsider a changed leader or retry plan, but never renew
// the deadline on repeated observations of the same sleep.
if accounted_schedules.insert((node_index, cycle_end, interval)) {
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
let scheduled_deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
deadline = deadline.max(scheduled_deadline);
}
if active || (cycle_end != 0 && schedule["effective_interval_available"].as_bool() == Some(true)) {
let interval = schedule["effective_interval_seconds"].as_u64();
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
schedule_accounted = true;
}
}
// Superseded walks complete all sets while withholding quota authority.
@@ -2422,7 +2213,6 @@ mod tests {
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(180);
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
let mut last_pool_metadata_readiness = serde_json::Value::Null;
loop {
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
let task_state = timeout(
@@ -2464,23 +2254,14 @@ mod tests {
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
let pool_metadata_matches = match &expected_pool_metadata {
Some(expected) => {
let actual =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
pool_metadata_matches_republished_manifest(&actual, expected)
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
.matches_manifest(expected)
}
None => true,
};
let pool_metadata_ready = if expected_pool_metadata.is_some() && pool_metadata_matches {
let (ready, observation) = all_nodes_pool_metadata_readiness(&cluster, heal_deadline).await;
last_pool_metadata_readiness = observation;
ready
} else {
expected_pool_metadata.is_none()
};
if matching == expected_manifests.len()
&& (!outage_target_manifest_required || outage_census.is_complete())
&& pool_metadata_matches
&& pool_metadata_ready
{
break;
}
@@ -2490,15 +2271,6 @@ mod tests {
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
// This post-deadline observation is diagnostic only. It cannot
// make an expired recovery attempt satisfy the original budget.
let pool_metadata_readiness = if expected_pool_metadata.is_some() {
let (_, observation) =
all_nodes_pool_metadata_readiness(&cluster, Instant::now() + Duration::from_secs(3)).await;
serde_json::json!({"last_eligible_check": last_pool_metadata_readiness, "timeout_check": observation})
} else {
serde_json::Value::Null
};
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
.await
.unwrap_or_else(|err| format!("status request failed: {err}"));
@@ -2522,7 +2294,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
};
return Err(format!(
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_expected={expected_pool_metadata:?}, pool_metadata={pool_metadata:?}, pool_metadata_readiness={pool_metadata_readiness}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
-6
View File
@@ -45,9 +45,6 @@ pub mod on_demand_migration;
#[cfg(test)]
pub mod fault_proxy;
#[cfg(test)]
mod rpc_fault_proxy;
// Reliability tests built on the fault-injection harness
#[cfg(test)]
mod reliability_disk_fault_test;
@@ -192,9 +189,6 @@ mod conditional_delete_objects_test;
#[cfg(test)]
mod delete_authorization_test;
#[cfg(test)]
mod table_location_authorization_test;
// Regression test for signed DELETE Object?versionId requests without Content-Length.
#[cfg(test)]
mod delete_object_no_content_length_test;
@@ -1129,14 +1129,12 @@ mod tests {
/// and the marker plus the cursor tag.
///
/// Bug Reference: backlog#1047
/// Earlier releases appended a `[rustfs_cache:...]` cursor tag to the last
/// returned key inside the V2 continuation token. The orchestration layer
/// passed that raw string to `forward_past`, so any key whose byte after the
/// shared stem sorts below `[` (0x5B) — `.`, `/`, `-`, digits, uppercase —
/// was silently dropped on the next page: with keys `a`, `a.txt`, `zz` and
/// max_keys=1, page 2 returned `zz` and `a.txt` was never listed. The token
/// is now base64 of the plain key, and this test keeps guarding the
/// continuation boundary.
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
/// last returned key. The orchestration layer passed that raw string to
/// `forward_past`, so any key whose byte after the shared stem sorts below
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
/// returned `zz` and `a.txt` was never listed.
#[tokio::test]
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
init_logging();
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Multipart completion validation, staging preservation, and CopyObject readiness.
//! Harbor / Docker Distribution multipart staging to CopyObject regressions.
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -39,132 +39,6 @@ fn list_contains_key(output: &aws_sdk_s3::operation::list_objects_v2::ListObject
.any(|object| object.key().is_some_and(|candidate| candidate == key))
}
#[tokio::test]
async fn multipart_complete_invalid_etag_returns_400_and_preserves_retry() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "multipart-invalid-etag";
let key = "object";
env.create_test_bucket(bucket).await?;
let original = b"original object";
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(original))
.send()
.await?;
let upload = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?;
let payload = b"completed multipart payload";
let mut uploaded_etag = None;
// The first case reproduces #8771's Create -> Complete sequence without UploadPart.
for (upload_first, part_number, etag) in [
(false, 1, Some("unknown-etag")),
(true, 1, Some("unknown-etag")),
(true, 1, Some("\"unknown-etag\"")),
(true, 1, Some("00000000000000000000000000000000")),
(true, 1, None),
(true, 2, Some("unknown-etag")),
] {
if upload_first && uploaded_etag.is_none() {
let part = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(payload))
.send()
.await?;
uploaded_etag = Some(part.e_tag().ok_or("missing part ETag")?.to_owned());
}
let error = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(part_number)
.set_e_tag(etag.map(str::to_owned))
.build(),
)
.build(),
)
.send()
.await
.expect_err("missing or mismatched parts must not complete the upload");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400), "{error:?}");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidPart"),
"{error:?}"
);
let staged = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(staged.parts().len(), usize::from(uploaded_etag.is_some()));
if let Some(etag) = uploaded_etag.as_deref() {
assert_eq!(staged.parts()[0].part_number(), Some(1));
assert_eq!(staged.parts()[0].e_tag(), Some(etag));
}
let current = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(current.as_ref(), original, "rejected completion must preserve the original object");
}
let etag = uploaded_etag.ok_or("part was not uploaded")?;
// Accept the unquoted value, then replay with the quoted UploadPart response.
for etag in [etag.trim_matches('"'), etag.as_str()] {
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag(etag).build())
.build(),
)
.send()
.await?;
}
let current = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(current.as_ref(), payload, "a valid retry must publish the exact uploaded bytes");
client.delete_object().bucket(bucket).key(key).send().await?;
env.delete_test_bucket(bucket).await?;
env.stop_server();
Ok(())
}
async fn upload_one_part_mpu(
client: &aws_sdk_s3::Client,
bucket: &str,
+38 -341
View File
@@ -230,302 +230,6 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
encoded
}
/// Sign the data and terminating chunks independently of the server decoder.
fn encode_signed_aws_chunked(signer: &SigV4, headers: &SignedHeaders, decoded: &[u8], trailer: bool) -> Vec<u8> {
let mut previous = headers
.authorization
.rsplit_once("Signature=")
.expect("seed signature")
.1
.to_string();
let mut encoded = Vec::new();
for chunk in [decoded, b"".as_slice()] {
let string_to_sign = format!(
"AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{previous}\n{}\n{}",
headers.amz_date,
get_scope(REGION, signer.time, "s3"),
sha256_hex(b""),
sha256_hex(chunk),
);
previous = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
encoded.extend_from_slice(format!("{:x};chunk-signature={previous}\r\n", chunk.len()).as_bytes());
encoded.extend_from_slice(chunk);
encoded.extend_from_slice(b"\r\n");
}
if trailer {
let checksum = sha256_base64(decoded);
let canonical = format!("x-amz-checksum-sha256:{checksum}\n");
let string_to_sign = format!(
"AWS4-HMAC-SHA256-TRAILER\n{}\n{}\n{previous}\n{}",
headers.amz_date,
get_scope(REGION, signer.time, "s3"),
sha256_hex(canonical.as_bytes()),
);
let signature = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
encoded.extend_from_slice(
format!("x-amz-checksum-sha256:{checksum}\r\nx-amz-trailer-signature:{signature}\r\n\r\n").as_bytes(),
);
}
encoded
}
/// Exercise decoder failures through both storage publication boundaries.
#[tokio::test]
async fn aws_chunked_named_errors_reject_put_and_upload_part() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let client = env.create_s3_client();
let decoded = b"private-chunked-fixture-body";
let mut failures = Vec::new();
for multipart in [false, true] {
for (case, expected_status, expected_code) in [
("signature", 403, "SignatureDoesNotMatch"),
("final-signature", 403, "SignatureDoesNotMatch"),
("format", 400, "IncompleteBody"),
("metadata-at-limit", 400, "IncompleteBody"),
("metadata-over-limit", 400, "EntityTooLarge"),
("chunk-over-limit", 400, "EntityTooLarge"),
("trailers-over-limit", 400, "IncompleteBody"),
("too-many-trailers", 400, "IncompleteBody"),
] {
let key = format!("chunked-{case}-{multipart}.bin");
let upload_id = if multipart {
Some(
client
.create_multipart_upload()
.bucket(BUCKET)
.key(&key)
.send()
.await?
.upload_id()
.ok_or("missing upload ID")?
.to_owned(),
)
} else {
None
};
let query = upload_id
.as_ref()
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
.unwrap_or_default();
let path = format!("/{BUCKET}/{key}");
let target = if query.is_empty() {
path.clone()
} else {
format!("{path}?{query}")
};
let signer = SigV4::new(&env);
let unsigned = matches!(case, "trailers-over-limit" | "too-many-trailers");
let mode = if unsigned {
UNSIGNED_PAYLOAD_TRAILER
} else {
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
};
let length = decoded.len().to_string();
let mut extra = vec![
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", length.as_str()),
];
if unsigned {
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
}
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
let encoded = match case {
"signature" | "final-signature" => {
let mut body = encode_signed_aws_chunked(&signer, &headers, decoded, false);
let offset = if case == "signature" {
body.windows(2).position(|w| w == b"\r\n").expect("chunk header") + 2
} else {
body.len() - 5
};
body[offset] = if body[offset] == b'a' { b'b' } else { b'a' };
body
}
"format" => b"invalid-chunk-size\r\n".to_vec(),
"metadata-at-limit" | "metadata-over-limit" => {
let size = if case == "metadata-at-limit" { 1024 } else { 1025 };
let mut body = vec![b'z'; size - 2];
body.extend_from_slice(b"\r\n");
body
}
"chunk-over-limit" => format!("10000001;chunk-signature={}\r\n", "0".repeat(64)).into_bytes(),
"trailers-over-limit" | "too-many-trailers" => {
let mut body = format!("{:x}\r\n", decoded.len()).into_bytes();
body.extend_from_slice(decoded);
body.extend_from_slice(b"\r\n0\r\n");
if case == "trailers-over-limit" {
body.extend_from_slice(format!("x-private:{}\r\n\r\n", "v".repeat(16385 - 14)).as_bytes());
} else {
body.extend_from_slice("x-private:v\r\n".repeat(101).as_bytes());
body.extend_from_slice(b"\r\n");
}
body
}
_ => unreachable!("all fixtures are enumerated"),
};
let mut request = local_http_client()
.put(format!("{}{target}", env.url))
.header("authorization", &headers.authorization)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date);
for (name, value) in extra {
request = request.header(name, value);
}
let response = request
.body(encoded)
.timeout(std::time::Duration::from_secs(20))
.send()
.await?;
let status = response.status().as_u16();
let body = response.text().await?;
eprintln!("aws-chunked {case} multipart={multipart}: HTTP {status}, {body}");
if status != expected_status || !body.contains(&format!("<Code>{expected_code}</Code>")) {
failures.push(format!(
"{case} multipart={multipart}: expected {expected_status} {expected_code}, got {status} {body}"
));
}
for private in [
"private-chunked-fixture-body",
"chunk-signature=",
"AwsChunkedStreamError",
"x-private",
] {
if body.contains(private) {
failures.push(format!("{case}: response exposed {private}"));
}
}
if let Some(id) = upload_id {
let parts = client.list_parts().bucket(BUCKET).key(&key).upload_id(&id).send().await?;
assert!(parts.parts().is_empty(), "{case}: rejected UploadPart published a part");
client
.abort_multipart_upload()
.bucket(BUCKET)
.key(&key)
.upload_id(id)
.send()
.await?;
}
let absent = client
.get_object()
.bucket(BUCKET)
.key(&key)
.send()
.await
.expect_err("a rejected body must not publish an object");
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
Ok(())
}
#[tokio::test]
async fn aws_chunked_valid_signed_and_trailer_boundaries_succeed() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let client = env.create_s3_client();
let decoded = b"valid-chunked-control";
for (case, multipart) in ["signed", "signed-trailer", "trailer-size-limit", "trailer-count-limit"]
.into_iter()
.flat_map(|case| [false, true].map(|multipart| (case, multipart)))
{
let key = format!("{case}-{multipart}");
let upload_id = if multipart {
Some(
client
.create_multipart_upload()
.bucket(BUCKET)
.key(&key)
.send()
.await?
.upload_id()
.ok_or("missing upload ID")?
.to_owned(),
)
} else {
None
};
let query = upload_id
.as_ref()
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
.unwrap_or_default();
let signer = SigV4::new(&env);
let path = format!("/{BUCKET}/{key}");
let target = if query.is_empty() {
path.clone()
} else {
format!("{path}?{query}")
};
let length = decoded.len().to_string();
let mode = match case {
"signed" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
"signed-trailer" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER",
_ => UNSIGNED_PAYLOAD_TRAILER,
};
let mut extra = vec![
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", length.as_str()),
];
if case != "signed" {
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
}
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
let mut encoded = if case.starts_with("signed") {
encode_signed_aws_chunked(&signer, &headers, decoded, case == "signed-trailer")
} else {
encode_unsigned_aws_chunked_with_sha256_trailer(decoded)
};
if !case.starts_with("signed") {
encoded.truncate(encoded.len() - 2);
if case == "trailer-size-limit" {
let checksum_line_size = format!("x-amz-checksum-sha256:{}\r\n", sha256_base64(decoded)).len();
encoded.extend_from_slice(format!("x-padding:{}\r\n", "v".repeat(16384 - checksum_line_size - 14)).as_bytes());
} else {
encoded.extend_from_slice("x-padding:v\r\n".repeat(99).as_bytes());
}
encoded.extend_from_slice(b"\r\n");
}
let mut request = local_http_client()
.put(format!("{}{target}", env.url))
.header("authorization", &headers.authorization)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date);
for (name, value) in extra {
request = request.header(name, value);
}
let response = request
.body(encoded)
.timeout(std::time::Duration::from_secs(20))
.send()
.await?;
let status = response.status();
let etag = response
.headers()
.get("etag")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = response.text().await?;
assert_eq!(status.as_u16(), 200, "{case}: {body}");
if let Some(id) = upload_id {
let part = aws_sdk_s3::types::CompletedPart::builder()
.part_number(1)
.e_tag(etag.ok_or("successful UploadPart omitted ETag")?)
.build();
client
.complete_multipart_upload()
.bucket(BUCKET)
.key(&key)
.upload_id(id)
.multipart_upload(aws_sdk_s3::types::CompletedMultipartUpload::builder().parts(part).build())
.send()
.await?;
}
let object = client.get_object().bucket(BUCKET).key(&key).send().await?;
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), decoded, "{case}");
}
Ok(())
}
/// Positive control: a correctly hand-signed request must succeed. Without
/// this, every negative assertion below could pass for the wrong reason (a
/// broken signer that never produces a valid signature).
@@ -862,54 +566,47 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
use md5::{Digest, Md5};
let key = "tampered-payload.txt";
let path = format!("/{BUCKET}/{key}");
let claimed_body = b"the-body-i-claim-to-send";
let actual_body = b"the-body-i-really-send!!";
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
let signer = SigV4::new(&env);
let client = env.create_s3_client();
// Empty, small-eager, zero-copy, and streaming PUTs must share the error contract.
for size in [0, 24, 2 * 1024 * 1024, 17 * 1024 * 1024] {
let actual_body = vec![b'a'; size];
let wrong_hash = sha256_hex(b"a different payload");
let content_md5 = base64_simd::STANDARD.encode_to_string(Md5::digest(&actual_body));
for with_md5 in [false, true] {
let key = format!("tampered-payload-{size}-{with_md5}.txt");
let path = format!("/{BUCKET}/{key}");
let extra = if with_md5 {
vec![("content-md5", content_md5.as_str())]
} else {
vec![]
};
let headers = signer.sign_with_extra_headers("PUT", &path, "", &wrong_hash, &extra);
let mut request = local_http_client()
.put(format!("{}{}", env.url, path))
.header("x-amz-date", &headers.amz_date)
.header("x-amz-content-sha256", &headers.content_sha256)
.header("authorization", &headers.authorization);
for (name, value) in extra {
request = request.header(name, value);
}
let response = request.body(actual_body.clone()).send().await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "payload mismatch: {body}");
assert_error_code(&body, "XAmzContentSHA256Mismatch");
assert!(
body.replace("&apos;", "'")
.contains("<Message>The provided 'x-amz-content-sha256' header does not match what was computed.</Message>"),
"wrong message: {body}"
);
// Sign over the hash of the CLAIMED body (single-chunk payload hash), then
// send a different body of equal length.
let headers = signer.sign("PUT", &path, "", &sha256_hex(claimed_body));
let absent = client
.get_object()
.bucket(BUCKET)
.key(&key)
.send()
.await
.expect_err("a tampered payload must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
let result = send_signed(&env, reqwest::Method::PUT, &path, &headers, Some(actual_body.to_vec())).await;
match result {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
assert!(
status.is_client_error(),
"payload mismatch must be rejected with a client error, got {status}, body:\n{body}"
);
info!(%status, "tampered payload rejected with error status");
}
// A mid-stream hash-mismatch abort surfacing as a transport error is
// also a valid rejection (definitely not a 200 success).
Err(err) => {
assert!(!err.is_connect(), "connection failure is not proof of payload rejection: {err}");
assert!(!err.is_timeout(), "request timeout is not proof of payload rejection: {err}");
info!(%err, "tampered payload rejected via mid-stream transport error");
}
}
let absent = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("a tampered payload must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
Ok(())
}
@@ -941,9 +638,9 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"multipart payload mismatch must be rejected as XAmzContentSHA256Mismatch, body:\n{body}"
"multipart payload mismatch must be rejected as BadDigest, body:\n{body}"
);
assert_error_code(&body, "XAmzContentSHA256Mismatch");
assert_error_code(&body, "BadDigest");
let parts = client
.list_parts()
+87 -201
View File
@@ -19,19 +19,6 @@ use http::{Method, StatusCode};
use tokio::time::{Duration, sleep, timeout};
use tracing::{debug, info};
/// A successful reporting response can still have unknown scanner usage.
fn reported_quota_usage(response: &serde_json::Value) -> std::io::Result<Option<u64>> {
let value = response
.get("current_usage")
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "quota response is missing current_usage"))?;
if value.is_null() {
return Ok(None);
}
value.as_u64().map(Some).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "quota current_usage is not an unsigned integer or null")
})
}
/// Test environment setup for quota tests
pub struct QuotaTestEnv {
pub env: RustFSTestEnvironment,
@@ -100,69 +87,35 @@ impl QuotaTestEnv {
}
}
async fn wait_for_usage_response(
&self,
method: Method,
path: &str,
body: Option<String>,
expected_usage: u64,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let convergence = async {
loop {
let (status, response) = admin_request(
&self.env.url,
method.clone(),
path,
body.clone(),
&self.env.access_key,
&self.env.secret_key,
)
.await?;
if status.is_success() {
let response: serde_json::Value = serde_json::from_str(&response)?;
if let Some(usage) = reported_quota_usage(&response)?
&& usage == expected_usage
{
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(response);
}
} else if status != StatusCode::SERVICE_UNAVAILABLE {
return Err(format!("quota usage request failed for {path}: {status} {response}").into());
}
sleep(Duration::from_millis(100)).await;
}
};
match timeout(Duration::from_secs(30), convergence).await {
Ok(result) => result,
Err(_) => Err(format!("quota usage did not converge to {expected_usage} for {path} within 30 seconds").into()),
pub async fn get_bucket_quota_stats(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, self.bucket_name);
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
if response.contains("error") {
Err(format!("Failed to get quota stats: {}", response).into())
} else {
Ok(serde_json::from_str(&response)?)
}
}
async fn wait_for_bucket_quota_stats(
&self,
bucket: &str,
expected_usage: u64,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
self.wait_for_usage_response(Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None, expected_usage)
.await
}
async fn wait_for_bucket_quota_check(
pub async fn check_bucket_quota(
&self,
operation_type: &str,
operation_size: u64,
expected_usage: u64,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/quota-check/{}", self.env.url, self.bucket_name);
let check_request = serde_json::json!({
"operation_type": operation_type,
"operation_size": operation_size
});
self.wait_for_usage_response(
Method::POST,
&format!("/rustfs/admin/v3/quota-check/{}", self.bucket_name),
Some(check_request.to_string()),
expected_usage,
)
.await
let response = awscurl_post(&url, &check_request.to_string(), &self.env.access_key, &self.env.secret_key).await?;
if response.contains("error") {
Err(format!("Failed to check quota: {}", response).into())
} else {
Ok(serde_json::from_str(&response)?)
}
}
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -192,9 +145,25 @@ impl QuotaTestEnv {
}
}
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let stats = self.get_bucket_quota_stats().await?;
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
}
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let stats = self.wait_for_bucket_quota_stats(&self.bucket_name, expected).await?;
reported_quota_usage(&stats)?.ok_or_else(|| "converged quota usage is missing".into())
let convergence = async {
loop {
let usage = self.get_bucket_usage().await?;
if usage == expected {
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
}
sleep(Duration::from_millis(100)).await;
}
};
match timeout(Duration::from_secs(30), convergence).await {
Ok(result) => result,
Err(_) => Err(format!("bucket usage did not converge to {expected} bytes within 30 seconds").into()),
}
}
pub async fn set_bucket_quota_for(
@@ -202,6 +171,8 @@ impl QuotaTestEnv {
bucket: &str,
quota_bytes: u64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.wait_for_quota_usage_for(bucket).await?;
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
let quota_config = serde_json::json!({
"quota": quota_bytes,
@@ -235,6 +206,46 @@ impl QuotaTestEnv {
}
}
pub async fn wait_for_quota_usage_for(&self, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let stats_path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
let readiness = async {
loop {
let (status, response) =
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
.await?;
if status.is_success() {
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
}
if status != StatusCode::SERVICE_UNAVAILABLE {
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
}
sleep(Duration::from_secs(1)).await;
}
};
match timeout(Duration::from_secs(30), readiness).await {
Ok(result) => result,
Err(_) => Err(format!("quota usage did not become authoritative for {bucket} within 30 seconds").into()),
}
}
/// Get bucket quota statistics for specific bucket
pub async fn get_bucket_quota_stats_for(
&self,
bucket: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
debug!("Getting quota stats for bucket: {}", bucket);
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, bucket);
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
if response.contains("error") {
Err(format!("Failed to get quota stats: {}", response).into())
} else {
let stats: serde_json::Value = serde_json::from_str(&response)?;
Ok(stats)
}
}
/// Upload an object to specific bucket
pub async fn upload_object_to_bucket(
&self,
@@ -259,132 +270,6 @@ impl QuotaTestEnv {
}
}
#[cfg(test)]
mod usage_response_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::JoinSet;
#[test]
fn unknown_usage_is_not_zero() {
assert_eq!(
reported_quota_usage(&serde_json::json!({"current_usage": null})).expect("valid unknown usage"),
None
);
assert_eq!(
reported_quota_usage(&serde_json::json!({"current_usage": 0})).expect("known zero usage"),
Some(0)
);
}
#[test]
fn missing_or_invalid_usage_is_not_readiness() {
for response in [
serde_json::json!({}),
serde_json::json!({"current_usage": -1}),
serde_json::json!({"current_usage": "0"}),
serde_json::json!({"current_usage": 1.5}),
serde_json::json!({"current_usage": false}),
] {
let error = reported_quota_usage(&response).expect_err("malformed usage must fail");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
}
async fn mock_environment(
responses: Vec<(StatusCode, &'static str)>,
) -> Result<(QuotaTestEnv, JoinSet<()>), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
let address = listener.local_addr()?;
let mut tasks = JoinSet::new();
tasks.spawn(async move {
for (status, body) in responses {
let (mut stream, _) = listener.accept().await.expect("accept quota reporting request");
let mut request = Vec::new();
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let mut buffer = [0_u8; 1024];
let read = stream.read(&mut buffer).await.expect("read reporting request");
assert!(read > 0 && request.len() + read <= 8192, "bounded HTTP request headers");
request.extend_from_slice(&buffer[..read]);
}
let response = format!(
"HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status.as_u16(),
body.len(),
body
);
stream
.write_all(response.as_bytes())
.await
.expect("write quota reporting response");
stream.shutdown().await.expect("close reporting response");
}
});
let mut env = RustFSTestEnvironment::new().await?;
env.url = format!("http://{address}");
let client = env.create_s3_client();
Ok((
QuotaTestEnv {
env,
client,
bucket_name: "quota-reporting-fixture".to_owned(),
},
tasks,
))
}
#[tokio::test]
async fn reporting_waits_for_known_matching_usage_without_selecting_allowed()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (env, mut tasks) = mock_environment(vec![
(StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
(StatusCode::OK, r#"{"current_usage":null}"#),
(StatusCode::OK, r#"{"current_usage":1}"#),
(StatusCode::OK, r#"{"current_usage":0,"allowed":false}"#),
])
.await?;
let response = env.wait_for_usage_response(Method::GET, "/reporting", None, 0).await?;
assert_eq!(response["current_usage"], 0);
assert_eq!(response["allowed"], false, "readiness must not select a desired admission outcome");
timeout(Duration::from_secs(1), tasks.join_next())
.await?
.expect("reporting mock task")
.expect("all responses consumed");
Ok(())
}
#[tokio::test]
async fn reporting_rejects_malformed_success_instead_of_retrying() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (env, mut tasks) = mock_environment(vec![(StatusCode::OK, r#"{"current_usage":"unknown"}"#)]).await?;
let error = env
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
.await
.expect_err("malformed usage must fail");
assert!(error.to_string().contains("not an unsigned integer or null"));
timeout(Duration::from_secs(1), tasks.join_next())
.await?
.expect("reporting mock task")
.expect("response consumed");
Ok(())
}
#[tokio::test]
async fn reporting_does_not_retry_authorization_failure() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let (env, mut tasks) = mock_environment(vec![(StatusCode::FORBIDDEN, "denied")]).await?;
let error = env
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
.await
.expect_err("authorization error must fail");
assert!(error.to_string().contains("403"));
timeout(Duration::from_secs(1), tasks.join_next())
.await?
.expect("reporting mock task")
.expect("response consumed");
Ok(())
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
@@ -597,7 +482,7 @@ mod integration_tests {
env.upload_object("file2.txt", 256 * 1024).await?;
// Check usage
let usage = env.wait_for_bucket_usage((512 + 256) * 1024).await?;
let usage = env.get_bucket_usage().await?;
assert_eq!(usage, (512 + 256) * 1024);
// Delete a file
@@ -632,7 +517,7 @@ mod integration_tests {
env.upload_object("file2.txt", 512 * 1024).await?;
// Get detailed quota statistics
let stats = env.wait_for_bucket_quota_stats(&env.bucket_name, (1024 + 512) * 1024).await?;
let stats = env.get_bucket_quota_stats().await?;
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
@@ -661,22 +546,22 @@ mod integration_tests {
env.upload_object("existing_file.txt", 512 * 1024).await?;
// Check if we can upload another 512KB (should succeed, exactly fill quota)
let check_result = env.wait_for_bucket_quota_check("PUT", 512 * 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("PUT", 512 * 1024).await?;
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
let check_result = env.wait_for_bucket_quota_check("PUT", 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("PUT", 1024).await?;
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
// Check if we can upload 600KB (should fail - would exceed quota)
let check_result = env.wait_for_bucket_quota_check("PUT", 600 * 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("PUT", 600 * 1024).await?;
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
// Check delete operation (should always be allowed)
let check_result = env.wait_for_bucket_quota_check("DELETE", 512 * 1024, 512 * 1024).await?;
let check_result = env.check_bucket_quota("DELETE", 512 * 1024).await?;
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
env.cleanup_bucket().await?;
@@ -708,8 +593,8 @@ mod integration_tests {
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
// Verify statistics are independent
let stats1 = env.wait_for_bucket_quota_stats(&bucket1, 1024 * 1024).await?;
let stats2 = env.wait_for_bucket_quota_stats(&bucket2, (1024 + 512) * 1024).await?;
let stats1 = env.get_bucket_quota_stats_for(&bucket1).await?;
let stats2 = env.get_bucket_quota_stats_for(&bucket2).await?;
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
@@ -770,6 +655,7 @@ mod integration_tests {
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
env.wait_for_quota_usage_for(&env.bucket_name).await?;
// Test 1: GET quota for bucket without quota config
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
@@ -548,20 +548,6 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_multipart_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn recover_multipart_source(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_at_incarnation(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
@@ -576,25 +562,6 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_with_pool_meta_cas(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn undo_pool_meta_write(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn pool_meta_native_recovery(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn make_volumes(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
@@ -10085,9 +10085,10 @@ async fn build_scanner_compensation_pair(
/// P1-20: objects that already exist when a rule with
/// ExistingObjectReplication=Enabled arrives are compensated by the scanner's
/// existing-object resync pass, whatever wrote them — plain PUT, CopyObject,
/// or Snowball auto-extract. That includes a null-version object written
/// before the bucket became versioned (rustfs/rustfs#8747): it is compensated
/// like any other existing object and lands as the target's null version.
/// or Snowball auto-extract. The pinned exception is a null-version object
/// (written before the bucket became versioned): the scanner heal gate skips
/// nil-version objects entirely (`scanner_folder.rs` heal_replication), so it
/// must NEVER be compensated.
#[tokio::test]
async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestResult {
init_logging();
@@ -10097,54 +10098,14 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
// Null-version cell: PUT before versioning; the object keeps the null
// version forever.
// Null-version cell: PUT before versioning; the object keeps the nil
// version id forever.
let null_key = "pre-versioning-null.txt";
let null_payload = "null version payload";
source_client
.put_object()
.bucket(source_bucket)
.key(null_key)
.body(ByteStream::from_static(null_payload.as_bytes()))
.send()
.await?;
// Multipart null-version cell: a one-part upload already carries a
// multipart ETag, so its replica travels the target's multipart path,
// whose completion must keep the null version too.
let multipart_null_key = "pre-versioning-null-multipart.txt";
let multipart_null_payload = "multipart null version payload";
let upload = source_client
.create_multipart_upload()
.bucket(source_bucket)
.key(multipart_null_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("multipart upload id missing")?;
let part = source_client
.upload_part()
.bucket(source_bucket)
.key(multipart_null_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(multipart_null_payload.as_bytes()))
.send()
.await?;
source_client
.complete_multipart_upload()
.bucket(source_bucket)
.key(multipart_null_key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(part.e_tag().map(str::to_string))
.build(),
)
.build(),
)
.body(ByteStream::from_static(b"null version payload"))
.send()
.await?;
@@ -10232,22 +10193,9 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
wait_for_replicated_object(&target_client, target_bucket, copy_key, plain_payload).await?;
wait_for_replicated_object(&target_client, target_bucket, member_key, std::str::from_utf8(member_payload)?).await?;
// The null versions converge too, and keep their identity whichever path
// carried them: each replica is the target's null version, not a freshly
// minted one.
for (key, payload) in [(null_key, null_payload), (multipart_null_key, multipart_null_payload)] {
wait_for_replicated_object(&target_client, target_bucket, key, payload).await?;
let null_replica = target_client
.get_object()
.bucket(target_bucket)
.key(key)
.version_id("null")
.send()
.await?;
assert_eq!(null_replica.version_id(), Some("null"), "{key} must replicate as the null version");
let null_replica_body = null_replica.body.collect().await?.into_bytes();
assert_eq!(null_replica_body.as_ref(), payload.as_bytes());
}
// Null-version contract: with every sibling compensated (scanner proven
// live), the nil-version object must stay absent across further cycles.
assert_replication_key_absent(&target_client, target_bucket, null_key, Duration::from_secs(6)).await?;
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -245,11 +245,11 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
Ok(())
}
/// Loopback and metadata endpoints must be rejected as remote tier backends (SSRF)
/// Internal/private endpoints must be rejected as remote tier backends (SSRF)
/// before RustFS opens a connection to them.
///
/// This issues a real admin AddTier call (`PUT /rustfs/admin/v3/tier`) for each
/// restricted endpoint and requires the exact validation response. A
/// internal/private endpoint and requires the exact validation response. A
/// loopback listener additionally proves the literal loopback case is rejected
/// without an outbound connection; a generic connectivity failure is not
/// sufficient evidence of SSRF protection.
@@ -267,9 +267,6 @@ async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync
("http://localhost:8080", false),
("http://169.254.169.254", false), // cloud instance metadata endpoint
("http://[::1]:8080", false),
("http://[fd00:ec2::254]", false),
("http://[fd00:ec2::23]", false),
("http://[fd20:ce::254]", false),
];
for (endpoint, checks_connection_attempt) in internal_endpoints {
+1 -29
View File
@@ -15,11 +15,7 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) const OBJECT_LOCK_TRACE_FILTER: &str = "info,rustfs_ecstore::object_lock_diag=trace";
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{
DiskOption, Endpoint, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
};
pub(crate) use rustfs_ecstore::api::disk::{Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
@@ -56,27 +52,3 @@ pub(crate) mod internode_rpc_signature {
pub(crate) mod replication_extension {
pub(crate) use super::BucketTargetSys;
}
#[cfg(test)]
pub(crate) mod upgrade_compatibility {
pub(crate) use super::{DiskOption, Endpoint, RUSTFS_META_MULTIPART_BUCKET, new_disk};
}
#[cfg(test)]
pub(crate) mod multipart_rpc_delay {
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
}
#[cfg(test)]
pub(crate) mod multipart_fence_acceptance {
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
}
#[cfg(test)]
pub(crate) mod multipart_source_rpc {
pub(crate) use rustfs_ecstore::api::disk::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceSnapshot,
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
};
}
@@ -1,417 +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 crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
init_logging, signed_request,
};
use aws_sdk_s3::primitives::ByteStream;
use http::{Method, StatusCode};
use serde_json::{Value, json};
use uuid::Uuid;
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
async fn set_policy(env: &RustFSTestEnvironment, user: &str, policy: Value) -> TestResult {
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
}
async fn catalog_request(
env: &RustFSTestEnvironment,
credentials: &(String, String),
method: Method,
path: &str,
body: Option<Value>,
expected: StatusCode,
) -> TestResult<Value> {
let response = signed_request(
method.clone(),
&format!("{}{path}", env.url),
&credentials.0,
&credentials.1,
body.map(|value| serde_json::to_vec(&value)).transpose()?,
Some("application/json"),
)
.await?;
let status = response.status();
let text = response.text().await?;
assert_eq!(status, expected, "{method} {path}: {text}");
if text.is_empty() || !status.is_success() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&text)?)
}
}
async fn get_object(
env: &RustFSTestEnvironment,
credentials: &(String, String),
bucket: &str,
key: &str,
expected: StatusCode,
) -> TestResult {
let response = signed_request(
Method::GET,
&format!("{}/{bucket}/{key}", env.url),
&credentials.0,
&credentials.1,
None,
None,
)
.await?;
assert_eq!(response.status(), expected, "GET {bucket}/{key}");
if expected == StatusCode::OK {
assert_eq!(response.bytes().await?.as_ref(), b"original object");
}
Ok(())
}
fn create_table(name: &str) -> Value {
json!({
"name": name,
"schema": {
"type": "struct",
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
},
"stage-create": false
})
}
fn staged_commit(metadata: &Value, location: &str) -> Value {
json!({
"requirements": [{"type": "assert-create"}],
"updates": [
{"action": "assign-uuid", "uuid": metadata["table-uuid"]},
{"action": "upgrade-format-version", "format-version": metadata["format-version"]},
{"action": "add-schema", "schema": metadata["schemas"][0]},
{"action": "set-current-schema", "schema-id": -1},
{"action": "add-spec", "spec": metadata["partition-specs"][0]},
{"action": "set-default-spec", "spec-id": -1},
{"action": "add-sort-order", "sort-order": metadata["sort-orders"][0]},
{"action": "set-default-sort-order", "sort-order-id": -1},
{"action": "set-location", "location": location}
]
})
}
async fn location_claim_preserves_other_users_objects_after_drop_and_restart(backing: &str) -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let server_env = [("RUSTFS_TABLE_CATALOG_BACKING", backing)];
env.start_rustfs_server_without_cleanup_with_env(&[("RUSTFS_TABLE_CATALOG_BACKING", "object")])
.await?;
env.wait_for_server_ready().await?;
let root = (env.access_key.clone(), env.secret_key.clone());
let creator = (format!("creator-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
let reader = (format!("reader-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
let bucket = "table-location-authorization";
let root_s3 = env.create_s3_client();
root_s3.create_bucket().bucket(bucket).send().await?;
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
root_s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"original object"))
.send()
.await?;
}
let catalog = format!("/iceberg/v1/{bucket}/namespaces/lab/tables");
catalog_request(&env, &root, Method::PUT, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/namespaces"),
Some(json!({"namespace": ["lab"]})),
StatusCode::OK,
)
.await?;
if backing == "durable-strong" {
catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/catalog/warehouse-index/backfill"),
None,
StatusCode::NO_CONTENT,
)
.await?;
let preflight = catalog_request(
&env,
&root,
Method::GET,
&format!("/iceberg/v1/{bucket}/catalog/migration"),
None,
StatusCode::OK,
)
.await?;
assert_eq!(preflight["blockers"], json!([]), "{preflight}");
let migration = catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/catalog/migration"),
None,
StatusCode::OK,
)
.await?;
assert_eq!(migration["ready_to_enable_durable_strong"], json!(true), "{migration}");
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
}
admin_create_user(&env, &creator.0, &creator.1).await?;
admin_create_user(&env, &reader.0, &reader.1).await?;
let create_statement = json!({
"Effect": "Allow",
"Action": "admin:CreateTable",
"Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")
});
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [create_statement.clone()]}),
)
.await?;
set_policy(
&env,
&reader.0,
json!({"Version": "2012-10-17", "Statement": [{
"Effect": "Allow", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/*")
}]}),
)
.await?;
for key in ["shared/known.txt", "shared2/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
get_object(&env, &creator, bucket, key, StatusCode::FORBIDDEN).await?;
}
for (method, path) in [
(Method::PUT, format!("/{bucket}/shared/new.txt")),
(Method::GET, format!("/{bucket}?list-type=2")),
] {
let response = signed_request(method, &format!("{}{path}", env.url), &creator.0, &creator.1, None, None).await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// Both direct and staged requests must reject a caller-selected foreign prefix.
for staged in [false, true] {
for location in [format!("s3://{bucket}/shared"), format!("s3://{bucket}/shared/")] {
let mut request = create_table("claim");
request["location"] = json!(location);
request["stage-create"] = json!(staged);
catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::FORBIDDEN).await?;
}
}
catalog_request(&env, &root, Method::GET, &format!("{catalog}/claim"), None, StatusCode::NOT_FOUND).await?;
// Server-assigned locations still need only CreateTable, and retain protection after drop.
let created = catalog_request(&env, &creator, Method::POST, &catalog, Some(create_table("managed")), StatusCode::OK).await?;
let location = created["metadata"]["location"].as_str().ok_or("missing table location")?;
let managed_key = format!(
"{}/known.txt",
location
.strip_prefix(&format!("s3://{bucket}/"))
.ok_or("table location has wrong bucket")?
);
root_s3
.put_object()
.bucket(bucket)
.key(&managed_key)
.body(ByteStream::from_static(b"original object"))
.send()
.await?;
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/managed"), None, StatusCode::NO_CONTENT).await?;
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
for table in ["claim", "managed"] {
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
}
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
}
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
get_object(&env, &root, bucket, &managed_key, StatusCode::OK).await?;
// Explicit adoption is scoped to a physical prefix and does not grant S3 access.
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
create_statement.clone(),
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/authorized")}
]}),
)
.await?;
let mut request = create_table("authorized");
request["location"] = json!(format!("s3://{bucket}/shared"));
catalog_request(&env, &creator, Method::POST, &catalog, Some(request.clone()), StatusCode::FORBIDDEN).await?;
request["location"] = json!(format!("s3://{bucket}/authorized"));
let adopted = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
get_object(&env, &creator, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
// Existing updates retain their permissions; relocation and re-registration
// require a prefix grant even when the caller can access every S3 object.
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
create_statement,
{"Effect": "Allow", "Action": "admin:RegisterTable", "Resource": [
format!("arn:aws:s3:::{bucket}/namespaces/lab"),
format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")
]},
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")},
{"Effect": "Allow", "Action": "admin:SetTableMetadataLocation", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/authorized")},
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
]}),
)
.await?;
let requirements = json!([{"type": "assert-table-uuid", "uuid": adopted["metadata"]["table-uuid"]}]);
let committed = catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized"),
Some(json!({
"requirements": requirements,
"updates": [{"action": "set-properties", "updates": {"test.location-authorization": "retained"}}]
})),
StatusCode::OK,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized"),
Some(json!({
"requirements": requirements,
"updates": [{"action": "set-location", "location": format!("s3://{bucket}/shared")}]
})),
StatusCode::FORBIDDEN,
)
.await?;
let current = catalog_request(&env, &root, Method::GET, &format!("{catalog}/authorized"), None, StatusCode::OK).await?;
assert_eq!(current["metadata"]["location"], json!(format!("s3://{bucket}/authorized")));
assert_eq!(current["metadata-location"], committed["metadata-location"]);
catalog_request(
&env,
&root,
Method::DELETE,
&format!("{catalog}/authorized"),
None,
StatusCode::NO_CONTENT,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("/iceberg/v1/{bucket}/namespaces/lab/register"),
Some(json!({"name": "authorized", "metadata-location": committed["metadata-location"]})),
StatusCode::FORBIDDEN,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized/catalog/import"),
Some(json!({"metadata-location": committed["metadata-location"]})),
StatusCode::FORBIDDEN,
)
.await?;
if backing == "object" {
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized/catalog/external/sync"),
Some(json!({
"catalog": "polaris",
"external-namespace": "lab",
"external-table": "authorized",
"metadata-location": committed["metadata-location"]
})),
StatusCode::FORBIDDEN,
)
.await?;
}
// Staged metadata is client-controlled and must be authorized at publication.
let mut request = create_table("staged");
request["stage-create"] = json!(true);
let staged = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/staged"),
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/shared"))),
StatusCode::FORBIDDEN,
)
.await?;
catalog_request(&env, &root, Method::GET, &format!("{catalog}/staged"), None, StatusCode::NOT_FOUND).await?;
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Action": "admin:CreateTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")},
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/staged")},
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/stage-owned")},
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
]}),
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/staged"),
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/stage-owned"))),
StatusCode::OK,
)
.await?;
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/staged"), None, StatusCode::NO_CONTENT).await?;
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
for table in ["authorized", "staged"] {
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
}
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
get_object(&env, &root, bucket, "authorized/known.txt", StatusCode::OK).await?;
for key in ["shared/known.txt", "shared2/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
}
Ok(())
}
#[tokio::test]
async fn object_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
location_claim_preserves_other_users_objects_after_drop_and_restart("object").await
}
#[tokio::test]
async fn durable_strong_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
location_claim_preserves_other_users_objects_after_drop_and_restart("durable-strong").await
}
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -33,6 +33,11 @@ workspace = true
default = []
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
# Compiles the controlled list-objects namespace-journal chaos injector into a
# production binary (it is always available to tests). Off by default so the
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
# state in a stock build (backlog#1832).
list-chaos = []
rio-v2 = ["dep:rustfs-rio-v2"]
hotpath = [
"hotpath/hotpath",
@@ -273,6 +278,10 @@ harness = false
name = "comparison_benchmark"
harness = false
[[bench]]
name = "rename_data_meta_benchmark"
harness = false
[[bench]]
name = "single_block_non_inline_benchmark"
harness = false
@@ -281,9 +290,5 @@ harness = false
name = "multipart_read_parts_benchmark"
harness = false
[[bench]]
name = "local_walk_dir_benchmark"
harness = false
[lib]
doctest = false
@@ -1,85 +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.
use criterion::{Criterion, Throughput, criterion_group, criterion_main};
use rustfs_filemeta::{MetaCacheEntry, MetacacheReader, test_data::create_real_xlmeta};
use std::hint::black_box;
mod storage_api;
use storage_api::local_walk_dir::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
const OBJECTS: usize = 1000;
async fn walk_page(disk: &Disk, recursive: bool) -> Vec<MetaCacheEntry> {
let opts = WalkDirOptions {
bucket: "bench-bucket".to_string(),
base_dir: "prefix".to_string(),
recursive,
limit: i32::try_from(OBJECTS).expect("page limit fits"),
..Default::default()
};
let (reader, mut writer) = tokio::io::duplex(4096);
let produce = async move { disk.walk_dir(opts, &mut writer).await };
let consume = async { MetacacheReader::new(reader).read_all().await.expect("decode listing stream") };
let (result, entries) = tokio::join!(produce, consume);
result.expect("production walk_dir must succeed");
entries
}
fn bench_local_walk_dir(c: &mut Criterion) {
let root = tempfile::tempdir().expect("create benchmark disk");
let metadata = create_real_xlmeta().expect("create object metadata");
let decoded = rustfs_filemeta::FileMeta::load(&metadata).expect("decode fixture");
let data_dirs = decoded.get_data_dirs().expect("fixture data directories");
for i in 0..OBJECTS {
let object_dir = root.path().join("bench-bucket/prefix").join(format!("object-{i:04}"));
std::fs::create_dir_all(&object_dir).expect("create object directory");
for data_dir in data_dirs.iter().flatten() {
std::fs::create_dir_all(object_dir.join(data_dir.to_string())).expect("create data directory");
}
std::fs::write(object_dir.join("xl.meta"), &metadata).expect("write object metadata");
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("build benchmark runtime");
let endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path")).expect("disk endpoint");
let disk = runtime
.block_on(new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
))
.expect("open local disk");
let mut group = c.benchmark_group("local_walk_dir");
group.throughput(Throughput::Elements(u64::try_from(OBJECTS).expect("object count fits")));
for (name, recursive) in [("recursive_1000_objects", true), ("non_recursive_1000_objects", false)] {
let entries = runtime.block_on(walk_page(&disk, recursive));
let objects: Vec<_> = entries.iter().filter(|entry| !entry.metadata.is_empty()).collect();
assert_eq!(objects.len(), OBJECTS);
for (i, entry) in objects.iter().enumerate() {
assert_eq!(entry.name, format!("prefix/object-{i:04}"));
}
group.bench_function(name, |b| {
b.iter(|| black_box(runtime.block_on(walk_page(&disk, recursive))));
});
}
group.finish();
}
criterion_group!(benches, bench_local_walk_dir);
criterion_main!(benches);
@@ -12,9 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use bytes::Bytes;
use criterion::{BatchSize, BenchmarkId, Criterion, SamplingMode, criterion_group, criterion_main};
use rustfs_filemeta::{ErasureAlgo, FileInfo, FileMeta, ObjectPartInfo};
use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main};
use rustfs_filemeta::{ErasureAlgo, FileInfo, FileMeta};
use std::hint::black_box;
use std::time::Duration;
use time::OffsetDateTime;
@@ -55,6 +54,8 @@ fn build_meta_with_versions(version_count: usize) -> FileMeta {
fn bench_rename_data_meta_path(c: &mut Criterion) {
let mut group = c.benchmark_group("rename_data_meta");
group.sample_size(20);
group.measurement_time(Duration::from_secs(10));
for &version_count in VERSION_COUNT_CASES {
let seeded = build_meta_with_versions(version_count);
@@ -66,10 +67,6 @@ fn bench_rename_data_meta_path(c: &mut Criterion) {
.and_then(|v| v.header.version_id)
.unwrap_or(Uuid::nil());
group.bench_with_input(BenchmarkId::new("find_unshared_data_dir", version_count), &version_count, |b, _| {
b.iter(|| black_box(seeded.find_unshared_data_dir_for_version(black_box(Some(replace_version_id)))));
});
group.bench_with_input(BenchmarkId::new("read_modify_write", version_count), &version_count, |b, _| {
b.iter(|| {
let mut xlmeta = FileMeta::load(black_box(&dst_buf)).expect("load dst meta");
@@ -117,84 +114,5 @@ fn bench_rename_data_meta_path(c: &mut Criterion) {
}
}
fn bench_delete_version_batches(c: &mut Criterion) {
let mut group = c.benchmark_group("delete_version_batch");
// Keep iteration counts and fixture batches independent of sample position.
group.sampling_mode(SamplingMode::Flat);
for (kind, inline_size) in [
("physical", None),
("inline-64", Some(64_usize)),
("inline-9k", Some(9 * 1024)),
("markers", None),
("mixed-markers-physical", None),
("mixed-markers-inline", Some(64)),
] {
for (versions, requested) in [(1_usize, 1), (8, 8), (32, 32), (128, 128), (1000, 1000), (1000, 32)] {
let mut meta = FileMeta::new();
let base_time = OffsetDateTime::from_unix_timestamp(BENCH_BASE_TIME_UNIX_SECS).expect("fixture timestamp");
for id in 1..=versions {
let size = inline_size.unwrap_or(64 * 1024);
let mut info = make_file_info(
Uuid::from_u128(u128::try_from(id).expect("version id")),
Uuid::from_u128(u128::try_from(versions + id).expect("directory id")),
i64::try_from(size).expect("object size"),
base_time + time::Duration::seconds(i64::try_from(id).expect("version time")),
);
if kind == "markers" || (kind.starts_with("mixed-markers-") && id % 2 == 0) {
info = FileInfo {
deleted: true,
version_id: info.version_id,
mod_time: info.mod_time,
..Default::default()
};
} else {
info.parts.push(ObjectPartInfo {
number: 1,
size,
actual_size: info.size,
..Default::default()
});
if inline_size.is_some() {
info.set_inline_data();
info.data = Some(Bytes::from(vec![42; size]));
}
}
meta.add_version(info).expect("seed batch metadata");
}
let requests: Vec<_> = meta
.versions
.iter()
.take(requested)
.map(|version| FileInfo {
version_id: version.header.version_id,
..Default::default()
})
.collect();
// Large histories use one fixture per timing interval in both implementations.
// Tiny histories amortize the timer over a fixed batch instead.
let batch_size = if versions < 128 {
BatchSize::NumIterations(32)
} else {
BatchSize::PerIteration
};
group.bench_with_input(BenchmarkId::new(format!("{kind}-r{requested}"), versions), &versions, |b, _| {
b.iter_batched(
|| meta.clone(),
|mut metadata| black_box(metadata.delete_versions(black_box(&requests)).expect("delete batch")),
batch_size,
)
});
}
}
group.finish();
}
criterion_group! {
name = benches;
config = Criterion::default()
.sample_size(20)
.warm_up_time(Duration::from_millis(300))
.measurement_time(Duration::from_secs(1));
targets = bench_rename_data_meta_path, bench_delete_version_batches
}
criterion_group!(benches, bench_rename_data_meta_path);
criterion_main!(benches);
@@ -33,7 +33,3 @@ pub(crate) mod single_block_non_inline {
pub(crate) mod multipart {
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, Endpoint, new_disk};
}
pub(crate) mod local_walk_dir {
pub(crate) use rustfs_ecstore::api::disk::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
}
+18 -38
View File
@@ -17,8 +17,8 @@
#[cfg(feature = "diagnostic-read-version-caller")]
pub mod diagnostic {
pub use crate::cluster::rpc::runtime_sources::{
current_read_version_entry_source, observe_read_version_context_boundary, with_captured_read_version_context,
with_read_version_entry, with_read_version_spawn_context,
current_read_version_entry_source, observe_read_version_context_boundary, with_read_version_entry,
with_read_version_spawn_context,
};
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
}
@@ -429,16 +429,6 @@ pub mod data_usage {
pub mod disk {
pub use crate::disk::disk_store::get_object_disk_read_timeout;
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
#[cfg(feature = "test-util")]
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
pub use crate::disk::multipart_source::{
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
MultipartSourceSnapshot, PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage, capture_source_condition,
require_source_condition, strip_native_controls,
};
#[cfg(all(feature = "test-util", unix))]
pub use crate::disk::os::set_local_dir_fsync_failure;
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{
@@ -448,12 +438,9 @@ pub mod disk {
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
validate_batch_read_version_item_count,
PartTransactionAction, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
is_quota_mutation_fence_path, new_disk, validate_batch_read_version_item_count,
};
pub use bytes::Bytes;
pub use endpoint::Endpoint;
@@ -504,8 +491,6 @@ pub mod global {
}
pub mod runtime {
#[cfg(any(test, feature = "test-util"))]
pub use crate::runtime::global::set_object_store_resolver;
pub use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
pub use crate::runtime::sources::{
boot_time, bucket_monitor, deployment_id, endpoint_pools, expiry_state_handle, first_cluster_node_is_local,
@@ -526,16 +511,13 @@ pub mod metrics {
pub mod notification {
pub use crate::services::notification_sys::{
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
LegacyTransitionStateReconcileFleetProofToken, MrfLifecycleWriterFleetProofToken, NotificationPeerErr, NotificationSys,
ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, acquire_mrf_lifecycle_writer_fleet_proof,
configured_fleet_topology_fingerprint, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
legacy_transition_state_reconcile_fleet_proof_matches, mrf_lifecycle_reader_local_process_epoch,
mrf_lifecycle_writer_fleet_proof_current, mrf_lifecycle_writer_fleet_proof_matches, new_global_notification_sys,
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
with_mrf_lifecycle_writer_fleet_proof,
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
};
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::{
@@ -605,14 +587,12 @@ pub mod rpc {
decode_heal_bucket_rpc_options, encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers,
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability,
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
}
+1 -29
View File
@@ -2012,7 +2012,7 @@ impl TargetClient {
.head_object()
.bucket(bucket)
.key(object)
.set_version_id(resolve_read_api_version_id(version_id))
.set_version_id(version_id)
.customize()
.map_request(move |mut req| {
for (k, v) in headers.clone().into_iter() {
@@ -4170,34 +4170,6 @@ mod tests {
);
}
/// The worker's convergence HEAD addresses a source null version (nil
/// UUID) as `versionId=null`, the spelling every S3 target understands,
/// like the proxy and audit reads do.
#[tokio::test]
async fn head_object_addresses_nil_source_version_as_null() {
let (client, request_uris) = recording_target_client();
let versioned = Uuid::new_v4().to_string();
// Only the request URI matters here; the canned empty response may or
// may not parse as a HEAD result.
let _ = client
.head_object("target-bucket", "object", Some(Uuid::nil().to_string()))
.await;
let _ = client.head_object("target-bucket", "object", Some(versioned.clone())).await;
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
assert_eq!(request_uris.len(), 2);
assert!(
request_uris[0].contains("versionId=null") && !request_uris[0].contains(&Uuid::nil().to_string()),
"a nil source version must be addressed as the null version: {}",
request_uris[0]
);
assert!(
request_uris[1].contains(&format!("versionId={versioned}")),
"a concrete source version must pass through unchanged: {}",
request_uris[1]
);
}
#[tokio::test]
async fn put_object_sends_source_version_id_query_to_target() {
// MinIO reads the replicated version only from the `versionId` query
@@ -879,11 +879,7 @@ fn free_version_cleanup_fences_current(
&& free_version_physical_topology_generation(api) == topology_generation
}
pub(crate) async fn cleanup_free_version_exact(
api: Arc<ECStore>,
oi: &ObjectInfo,
cancel: &CancellationToken,
) -> std::io::Result<bool> {
async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel: &CancellationToken) -> std::io::Result<bool> {
const FREE_VERSION_REMOTE_DEADLINE: StdDuration = StdDuration::from_secs(30);
let topology_generation = free_version_physical_topology_generation(&api);
@@ -1042,32 +1038,6 @@ pub struct ExpiryState {
recovery_notify: Arc<Notify>,
}
/// Keeps every lifecycle expiry task the process enqueues in its queue until
/// released, as if the expiry workers were busy.
#[cfg(all(test, feature = "test-util"))]
pub(crate) struct HeldExpiryQueue {
state: Arc<RwLock<ExpiryState>>,
previous: ExpiryState,
}
#[cfg(all(test, feature = "test-util"))]
impl HeldExpiryQueue {
pub(crate) async fn hold() -> Self {
let held = Arc::try_unwrap(ExpiryState::new_with_unconsumed_worker_channel(16))
.ok()
.expect("a new expiry state has a single owner")
.into_inner();
let state = runtime_sources::expiry_state_handle();
let previous = std::mem::replace(&mut *state.write().await, held);
Self { state, previous }
}
/// Drops the held tasks unrun and restores the previous expiry state.
pub(crate) async fn release(self) {
*self.state.write().await = self.previous;
}
}
impl ExpiryState {
#[allow(clippy::new_ret_no_self)]
pub fn new() -> Arc<RwLock<Self>> {
@@ -3042,7 +3012,14 @@ fn spawn_tier_free_version_recovery_once_with_options(
move |bucket_marker, object_marker, recovery_cancel| {
let api = Arc::clone(&api);
async move {
recover_configured_tier_free_versions(api, bucket_marker, object_marker, recovery_cancel).await
recover_tier_free_versions_with_cancel(
api,
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
bucket_marker,
object_marker,
recovery_cancel,
)
.await
}
},
)
@@ -3054,39 +3031,6 @@ fn spawn_tier_free_version_recovery_once_with_options(
}))
}
async fn recover_configured_tier_free_versions(
api: Arc<ECStore>,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> crate::error::Result<FreeVersionRecoveryStats> {
let tier_config_mgr = api.tier_config_mgr();
let has_tiers = select! {
biased;
_ = cancel_token.cancelled() => {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into());
}
manager = tier_config_mgr.read() => !manager.empty(),
};
// Startup can launch recovery before tier configuration is loaded. Keep
// polling, but release the manager lock before any namespace I/O.
if !has_tiers {
return Ok(FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: 0,
buckets_scanned: 0,
truncated: false,
});
}
recover_tier_free_versions_with_cancel(api, DEFAULT_FREE_VERSION_RECOVERY_LIMIT, bucket_marker, object_marker, cancel_token)
.await
}
fn tier_free_version_recovery_leader_retry_delay() -> StdDuration {
StdDuration::from_secs(rand::rng().random_range(15..=30))
}
@@ -11314,7 +11258,13 @@ mod tests {
let marker = marker
.as_deref()
.expect("production checkpoint should persist an object marker");
assert_eq!(marker, expected_marker, "production checkpoint should stop at the first page boundary");
let marker_suffix = marker
.strip_prefix(&expected_marker)
.expect("production checkpoint should stop at the first page boundary");
assert!(
marker_suffix.is_empty() || marker_suffix.starts_with("[rustfs_cache:v2,"),
"unexpected production checkpoint marker suffix: {marker_suffix}"
);
assert_eq!(version_marker.as_deref(), Some("null"));
}
@@ -14270,14 +14220,9 @@ mod tests {
crate::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
// Tests start the recovery loop they observe. An automatic startup loop
// would race that loop for the same unconsumed worker queue.
let ecstore = temp_env::async_with_vars(
[(super::ENV_TIER_FREE_VERSION_RECOVERY_ENABLED, Some("false"))],
ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new()),
)
.await
.unwrap();
let ecstore = ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new())
.await
.unwrap();
let buckets = ecstore
.list_bucket(&BucketOptions {
@@ -14753,232 +14698,10 @@ mod tests {
StdDuration::from_millis(10)
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_without_tiers_skips_listing_and_resets_cursor() {
let (disk_paths, ecstore) = setup_test_env().await;
let manager = ecstore.tier_config_mgr();
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
let bucket = format!("recovery-no-tiers-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
let metadata_path = disk_paths[0].join(&bucket).join(object).join(STORAGE_FORMAT_FILE);
let metadata_before = fs::read(&metadata_path).await.expect("seeded metadata should be readable");
let list_started = Arc::new(tokio::sync::Notify::new());
let list_wait = set_recovery_bucket_list_wait_hook(Arc::clone(&list_started));
let walk_calls = Arc::new(AtomicUsize::new(0));
let counted_walks = Arc::clone(&walk_calls);
let walk_hook = set_recovery_walk_test_hook(move |_| {
counted_walks.fetch_add(1, Ordering::SeqCst);
None
});
let mut schedule = super::TierFreeVersionRecoverySchedule::default();
schedule.record_success(&free_version_recovery_stats(1, 0, true));
let stats = tokio::select! {
biased;
_ = list_started.notified() => panic!("empty tier configuration must not list buckets"),
result = super::recover_configured_tier_free_versions(
Arc::clone(&ecstore),
schedule.bucket_marker.clone(),
schedule.object_marker.clone(),
CancellationToken::new(),
) => result.expect("empty tier configuration should be an idle recovery result"),
};
assert_eq!(
stats,
FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: 0,
buckets_scanned: 0,
truncated: false,
}
);
schedule.record_success(&stats);
assert!(schedule.bucket_marker.is_none());
assert!(schedule.object_marker.is_none());
assert!(!schedule.follow_up_sweep);
assert_eq!(walk_calls.load(Ordering::SeqCst), 0);
assert!(matches!(
recovery_rx.lock().await.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
assert_eq!(
fs::read(&metadata_path).await.expect("paused recovery must retain metadata"),
metadata_before
);
drop(list_wait);
drop(walk_hook);
remove_seeded_free_version(&disk_paths, &bucket, object).await;
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("test bucket should be removed");
manager.write().await.tiers = saved_tiers;
reset_runtime_expiry_state(&ecstore).await;
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_tier_snapshot_wait_is_cancellable() {
let (_disk_paths, ecstore) = setup_test_env().await;
let manager = ecstore.tier_config_mgr();
let _write_guard = manager.write().await;
let cancel = CancellationToken::new();
let recovery = super::recover_configured_tier_free_versions(ecstore, None, None, cancel.clone());
tokio::pin!(recovery);
assert!(
futures::poll!(&mut recovery).is_pending(),
"recovery should wait for the tier manager lock"
);
cancel.cancel();
let err = recovery
.await
.expect_err("cancellation must not wait for the tier manager writer");
assert!(matches!(err, crate::error::Error::Io(ref err) if err.kind() == std::io::ErrorKind::Interrupted));
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_loop_observes_tier_configuration_changes() {
let automatic_recovery_started = super::TIER_FREE_VERSION_RECOVERY_STARTED.get().copied();
let (disk_paths, ecstore) = setup_test_env().await;
assert_eq!(
super::TIER_FREE_VERSION_RECOVERY_STARTED.get().copied(),
automatic_recovery_started,
"fixture must not start an unowned automatic recovery loop"
);
let manager = ecstore.tier_config_mgr();
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
let bucket = format!("recovery-dynamic-tier-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
let page_permits = Arc::new(tokio::sync::Semaphore::new(1));
let worker_permits = Arc::clone(&page_permits);
let (page_tx, mut page_rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = CancellationToken::new();
let loop_cancel = cancel.clone();
let recovery_store = Arc::clone(&ecstore);
let worker = tokio::spawn(async move {
run_tier_free_version_recovery_loop(
loop_cancel,
runtime_state,
std::convert::identity,
move |bucket, object, cancel| {
let api = Arc::clone(&recovery_store);
let page_tx = page_tx.clone();
let page_permits = Arc::clone(&worker_permits);
async move {
// Advance the scheduler clock only while real disk I/O is
// parked. Otherwise its timeout can win while the blocking
// filesystem operation is still running on wall-clock time.
let permit = tokio::select! {
_ = cancel.cancelled() => return Err(crate::error::Error::other("test recovery canceled")),
permit = page_permits.acquire() => permit.expect("test controls page admission"),
};
permit.forget();
let stats =
super::recover_configured_tier_free_versions(api, bucket.clone(), object.clone(), cancel).await?;
page_tx
.send((bucket, object, stats.clone()))
.expect("test should observe each recovery result");
Ok(stats)
}
},
)
.await;
});
let (_, _, idle) = page_rx
.recv()
.await
.expect("worker should check the initially empty configuration");
assert_eq!(idle.buckets_scanned, 0);
assert!(matches!(
recovery_rx.lock().await.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
crate::services::tier::test_util::register_mock_tier(&manager, &tier).await;
tokio::time::pause();
tokio::task::yield_now().await;
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
tokio::time::resume();
page_permits.add_permits(1);
let (bucket_marker, object_marker, active) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
.await
.expect("new tier should enable the next recovery page")
.expect("worker should remain running");
assert!(bucket_marker.is_none());
assert!(object_marker.is_none());
assert_eq!(active.enqueued, 1, "recovery page: {active:?}");
let task = recovery_rx
.lock()
.await
.try_recv()
.expect("new tier should enable discovery")
.expect("queue item should exist");
let recovered = task
.as_any()
.downcast_ref::<FreeVersionTask>()
.expect("seeded free version should be queued");
assert_eq!(recovered.0.bucket, bucket);
assert_eq!(recovered.0.name, object);
// Simulate a historical or temporarily missing configuration without
// bypassing the admin removal path's persisted-reference proof.
let configured_tiers = std::mem::take(&mut manager.write().await.tiers);
tokio::time::pause();
tokio::task::yield_now().await;
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
tokio::time::resume();
page_permits.add_permits(1);
let (_, _, idle_again) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
.await
.expect("empty configuration should still produce an idle check")
.expect("worker should remain running");
assert_eq!(idle_again.buckets_scanned, 0);
assert_eq!(idle_again.enqueued, 0);
assert!(matches!(
recovery_rx.lock().await.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
cancel.cancel();
worker.await.expect("worker should exit on cancellation");
manager.write().await.tiers = configured_tiers;
manager
.write()
.await
.remove(&tier, true)
.await
.expect("mock tier should be removed");
manager.write().await.tiers = saved_tiers;
remove_seeded_free_version(&disk_paths, &bucket, object).await;
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("test bucket should be removed");
reset_runtime_expiry_state(&ecstore).await;
}
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
let (disk_paths, ecstore) = setup_test_env().await;
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
crate::services::tier::test_util::register_mock_tier(&ecstore.tier_config_mgr(), &tier).await;
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
let recovery_rx = {
let state = runtime_state.read().await;
@@ -15014,13 +14737,6 @@ mod tests {
.await
.expect_err("aborted production recovery task should report cancellation");
assert!(join_error.is_cancelled());
ecstore
.tier_config_mgr()
.write()
.await
.remove(&tier, true)
.await
.expect("mock tier should be removed");
remove_seeded_free_version(&disk_paths, &bucket, object).await;
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
@@ -63,14 +63,7 @@ pub(crate) async fn get_expiry_configs_for_options(
bucket: &str,
opts: &crate::object_api::ObjectOptions,
) -> Result<LifecycleExpiryConfigs> {
let bucket_incarnation_id = match opts
.object_lock_config_snapshot
.as_ref()
.and_then(|snapshot| snapshot.validated_bucket_incarnation_for(api.id, bucket, opts.expected_bucket_incarnation_id))
{
Some(incarnation_id) => incarnation_id,
None => metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?,
};
let bucket_incarnation_id = metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?;
let metadata = get_authoritative_metadata(api, bucket, bucket_incarnation_id).await?;
let table_bucket_enabled = metadata.table_bucket_enabled();
@@ -181,36 +174,5 @@ mod tests {
.unwrap()
);
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
let snapshot = store_a.object_lock_config_snapshot(bucket).await.unwrap();
assert_eq!(
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(configs.bucket_incarnation_id)),
Some(configs.bucket_incarnation_id)
);
assert_eq!(
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(uuid::Uuid::new_v4())),
None
);
assert_eq!(snapshot.validated_bucket_incarnation_for(store_b.id, bucket, None), None);
let opts = crate::object_api::ObjectOptions {
object_lock_config_snapshot: Some(snapshot.clone()),
expected_bucket_incarnation_id: Some(configs.bucket_incarnation_id),
..Default::default()
};
assert_eq!(
get_expiry_configs_for_options(&store_a, bucket, &opts)
.await
.unwrap()
.bucket_incarnation_id,
configs.bucket_incarnation_id
);
let unguarded = crate::object_api::ObjectLockConfigSnapshot::for_store_bucket(
store_a.id,
bucket,
configs.bucket_incarnation_id,
time::OffsetDateTime::UNIX_EPOCH,
metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
);
assert_eq!(unguarded.validated_bucket_incarnation_for(store_a.id, bucket, None), None);
}
}
@@ -23,7 +23,6 @@ use std::{
};
use futures::StreamExt;
use rand::RngExt as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
@@ -71,7 +70,6 @@ pub(crate) const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_del
// successful pages while its wall-clock budget remains.
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
const TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
@@ -6106,114 +6104,57 @@ pub(crate) async fn recover_test_tier_delete_journal_pass_with_budget(
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
}
fn tier_delete_journal_recovery_pass_is_idle(stats: &TierDeleteJournalRecoveryPassStats) -> bool {
stats.manifest_pages > 0
&& stats.journal_pages > 0
&& stats.manifest_errors == 0
&& stats.journal_errors == 0
&& !stats.deadline_exhausted
&& !stats.canceled
&& stats.manifests == TierDeleteDispatchManifestRecoveryStats::default()
&& stats.journals == TierDeleteJournalRecoveryStats::default()
}
fn bounded_tier_delete_journal_recovery_delay(delay: Duration, percent: u32) -> Duration {
(delay.saturating_mul(percent) / 100)
.clamp(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL)
}
fn jitter_tier_delete_journal_recovery_delay(delay: Duration) -> Duration {
bounded_tier_delete_journal_recovery_delay(delay, rand::rng().random_range(90..=110))
}
async fn drive_tier_delete_journal_recovery(
cancel_token: &CancellationToken,
wait_for_wakeup: impl AsyncFn(),
mut recover: impl AsyncFnMut() -> TierDeleteJournalRecoveryPassStats,
jitter_delay: fn(Duration) -> Duration,
) {
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
let mut marker: Option<String> = None;
let mut manifest_marker: Option<String> = None;
loop {
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = wait_for_wakeup() => {
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
interval.reset();
},
_ = interval.tick() => {},
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
}
let stats = recover().await;
let stats = recover_tier_delete_journal_pass(
api.clone(),
&cancel_token,
&mut manifest_marker,
&mut marker,
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
)
.await;
if stats.canceled {
return;
}
if tier_delete_journal_recovery_pass_is_idle(&stats) {
// Only a completed empty sweep may defer discovery. Retained,
// failed, or paginated work keeps the original retry tick budget.
interval.reset_after(jitter_delay(idle_interval));
idle_interval = idle_interval
.saturating_mul(2)
.min(TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
} else {
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
}
debug!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
manifest_pages = stats.manifest_pages,
manifest_scanned = stats.manifests.scanned,
manifest_advanced = stats.manifests.advanced,
manifest_deleted = stats.manifests.deleted,
manifest_retained = stats.manifests.retained,
manifest_failed = stats.manifests.failed,
manifest_errors = stats.manifest_errors,
manifest_truncated = stats.manifests.truncated,
manifest_next_marker = ?manifest_marker,
journal_pages = stats.journal_pages,
journal_scanned = stats.journals.scanned,
journal_deleted = stats.journals.deleted,
journal_failed = stats.journals.failed,
journal_errors = stats.journal_errors,
journal_truncated = stats.journals.truncated,
journal_next_marker = ?marker,
deadline_exhausted = stats.deadline_exhausted,
"Recovered tier delete journal pass"
);
}
}
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut marker: Option<String> = None;
let mut manifest_marker: Option<String> = None;
let wake_context = api.ctx.clone();
let recovery_cancel_token = cancel_token.clone();
drive_tier_delete_journal_recovery(
&cancel_token,
async move || wake_context.wait_for_tier_delete_journal_recovery().await,
async move || {
let stats = recover_tier_delete_journal_pass(
api.clone(),
&recovery_cancel_token,
&mut manifest_marker,
&mut marker,
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
)
.await;
if stats.canceled {
return stats;
}
debug!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
manifest_pages = stats.manifest_pages,
manifest_scanned = stats.manifests.scanned,
manifest_advanced = stats.manifests.advanced,
manifest_deleted = stats.manifests.deleted,
manifest_retained = stats.manifests.retained,
manifest_failed = stats.manifests.failed,
manifest_errors = stats.manifest_errors,
manifest_truncated = stats.manifests.truncated,
manifest_next_marker = ?manifest_marker,
journal_pages = stats.journal_pages,
journal_scanned = stats.journals.scanned,
journal_deleted = stats.journals.deleted,
journal_failed = stats.journals.failed,
journal_errors = stats.journal_errors,
journal_truncated = stats.journals.truncated,
journal_next_marker = ?marker,
deadline_exhausted = stats.deadline_exhausted,
"Recovered tier delete journal pass"
);
stats
},
jitter_tier_delete_journal_recovery_delay,
)
.await;
}
async fn await_tier_delete_journal_recovery<T, F>(
cancel_token: &CancellationToken,
timeout: Duration,
@@ -6239,20 +6180,16 @@ mod tests {
use super::{
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL,
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest,
TierDeleteDispatchManifestRecoveryStats, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
TierDeleteDispatchParentState, TierDeleteDispatchRecord, TierDeleteJournalRecoveryPassStats,
await_tier_delete_journal_recovery, bounded_tier_delete_journal_recovery_delay, decode_tier_delete_dispatch_record,
decode_tier_delete_journal_entry, drive_tier_delete_journal_recovery, encode_tier_delete_dispatch_manifest,
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
validate_legacy_tier_delete_recovery_source,
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
};
use crate::bucket::lifecycle::tier_sweeper::{
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
@@ -6896,213 +6833,6 @@ mod tests {
assert!(err.to_string().contains("decode tier delete journal failed"));
}
fn empty_recovery_pass() -> TierDeleteJournalRecoveryPassStats {
TierDeleteJournalRecoveryPassStats {
manifest_pages: 1,
journal_pages: 1,
..Default::default()
}
}
#[test]
fn tier_delete_journal_recovery_requires_both_completed_empty_queues_for_idle() {
assert!(tier_delete_journal_recovery_pass_is_idle(&empty_recovery_pass()));
let mut cases = Vec::new();
cases.push(TierDeleteJournalRecoveryPassStats::default());
for change in [
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_pages = 0,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_pages = 0,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_errors = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_errors = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.deadline_exhausted = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.canceled = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.scanned = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.retained = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.failed = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.truncated = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.next_marker = Some("next".to_owned()),
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.scanned = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.failed = 1,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.truncated = true,
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.next_marker = Some("next".to_owned()),
] {
let mut stats = empty_recovery_pass();
change(&mut stats);
cases.push(stats);
}
for stats in cases {
assert!(
!tier_delete_journal_recovery_pass_is_idle(&stats),
"unfinished recovery was considered idle: {stats:?}"
);
}
}
#[test]
fn tier_delete_journal_recovery_jitter_preserves_discovery_bounds() {
for delay in [
Duration::ZERO,
Duration::from_secs(60),
Duration::from_secs(120),
Duration::from_secs(300),
Duration::MAX,
] {
for percent in [90, 100, 110] {
let jittered = bounded_tier_delete_journal_recovery_delay(delay, percent);
assert!(jittered >= TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
assert!(jittered <= TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
if delay == Duration::from_secs(120) {
assert!((Duration::from_secs(108)..=Duration::from_secs(132)).contains(&jittered));
}
}
}
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_starts_immediately_backs_off_and_wakes() {
let cancel = CancellationToken::new();
let wake = tokio::sync::Notify::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || wake.notified().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
empty_recovery_pass()
},
|delay| bounded_tier_delete_journal_recovery_delay(delay, 110),
);
let exercise = async {
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
let mut elapsed = Duration::ZERO;
for delay in [66, 132, 264, 300, 300] {
tokio::time::advance(Duration::from_secs(delay - 1)).await;
tokio::task::yield_now().await;
assert!(rx.try_recv().is_err(), "idle pass ran before its backoff");
tokio::time::advance(Duration::from_secs(1)).await;
elapsed += Duration::from_secs(delay);
assert_eq!(rx.recv().await.expect("bounded idle recovery must run"), started + elapsed);
}
wake.notify_one();
assert_eq!(rx.recv().await.expect("notification must bypass idle delay"), started + elapsed);
let first_idle_delay = Duration::from_secs(66);
tokio::time::advance(first_idle_delay).await;
assert_eq!(
rx.recv().await.expect("wakeup must reset idle backoff"),
started + elapsed + first_idle_delay
);
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_preserves_known_work_tick_deadline() {
let cancel = CancellationToken::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let mut pass = 0;
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || std::future::pending::<()>().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
pass += 1;
if pass <= 3 {
return empty_recovery_pass();
}
if pass == 4 {
tokio::time::sleep(Duration::from_secs(40)).await;
}
let mut stats = empty_recovery_pass();
match pass {
4 => {
stats.manifests = TierDeleteDispatchManifestRecoveryStats {
retained: 1,
scanned: 1,
..Default::default()
}
}
5 => stats.manifest_errors = 1,
6 => stats.journal_errors = 1,
7 => stats.manifests.truncated = true,
8 => stats.journals.failed = 1,
9 => stats.deadline_exhausted = true,
_ => stats.journals.next_marker = Some("next-page".to_owned()),
}
stats
},
std::convert::identity,
);
let exercise = async {
for expected in [0, 60, 180, 420] {
assert_eq!(
rx.recv().await.expect("idle and work passes must run"),
started + Duration::from_secs(expected)
);
}
tokio::time::advance(Duration::from_secs(59)).await;
tokio::task::yield_now().await;
assert!(rx.try_recv().is_err(), "known work ran before the next tick");
tokio::time::advance(Duration::from_secs(1)).await;
for expected in [480, 540, 600, 660, 720, 780, 840] {
assert_eq!(
rx.recv().await.expect("unfinished work must retain the base tick deadline"),
started + Duration::from_secs(expected)
);
}
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_keeps_wakeup_received_during_a_pass() {
let cancel = CancellationToken::new();
let wake = tokio::sync::Notify::new();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let started = tokio::time::Instant::now();
let mut first = true;
let recovery = drive_tier_delete_journal_recovery(
&cancel,
async || wake.notified().await,
async || {
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
if first {
first = false;
tokio::time::sleep(Duration::from_secs(40)).await;
}
empty_recovery_pass()
},
std::convert::identity,
);
let exercise = async {
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
wake.notify_one();
tokio::time::advance(Duration::from_secs(40)).await;
assert_eq!(
rx.recv().await.expect("a notification during recovery must remain pending"),
started + Duration::from_secs(40)
);
cancel.cancel();
};
tokio::join!(recovery, exercise);
}
#[tokio::test(start_paused = true)]
async fn tier_delete_journal_recovery_loop_cancellation_wins_over_ready_wakeup() {
let cancel = CancellationToken::new();
cancel.cancel();
drive_tier_delete_journal_recovery(
&cancel,
async || {},
async || panic!("shutdown must prevent admission even when a wakeup and startup tick are ready"),
std::convert::identity,
)
.await;
}
#[tokio::test]
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
let result = await_tier_delete_journal_recovery(
+26 -57
View File
@@ -19,7 +19,7 @@ use super::{
use crate::bucket::metadata_sys::{
BucketMetadataSys, update, update_if_incarnation, update_quota_with_sys_if_incarnation_and_revision,
};
use crate::data_usage::{get_bucket_usage_memory, reload_unknown_bucket_usage};
use crate::data_usage::get_bucket_usage_memory;
use crate::error::StorageError;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_metrics::metrics::Metric;
@@ -235,8 +235,6 @@ impl QuotaChecker {
Ok(updated_at)
}
/// Return quota configuration with optional scanner usage, independently
/// of the usage requirements enforced by write admission.
pub async fn get_quota_stats(&self, bucket: &str) -> Result<(BucketQuota, Option<u64>), QuotaError> {
// If bucket doesn't exist, return ConfigNotFound error
if !self.bucket_exists(bucket).await {
@@ -246,28 +244,35 @@ impl QuotaChecker {
}
let quota = self.get_quota_config(bucket).await?;
let current_usage = self.report_only_usage(bucket).await;
let current_usage = if quota.uses_durable_reservations() {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
Ok((quota, current_usage))
}
/// Best-effort usage for statistics and durable admission reports.
/// Missing scanner coverage is unknown, not an error or a zero-sized bucket.
/// Scanner usage for a bucket the reservation ledger admits is reporting
/// input only, so a failed lookup degrades to "unknown" and is counted
/// instead of becoming an error the write path would fail on.
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
// The plain lookup: a report must not wait for an early snapshot reload.
let usage = get_bucket_usage_memory(bucket).await;
if usage.is_none() {
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
debug!(
event = EVENT_QUOTA_USAGE_REPORT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
"quota usage report degraded"
);
match self.get_real_time_usage(bucket).await {
Ok(usage) => Some(usage),
Err(err) => {
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
debug!(
event = EVENT_QUOTA_USAGE_REPORT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
error = %err,
"durable quota usage report degraded"
);
None
}
}
usage
}
/// Re-save `bucket`'s quota under the sharded durable reservation
@@ -385,22 +390,8 @@ impl QuotaChecker {
self.metadata_sys.read().await.get(bucket).await.is_ok()
}
/// Usage for a caller that fails closed without it. In a distributed
/// deployment a bucket the cached usage snapshot does not cover yet may
/// reload that snapshot before the degraded fallback is consulted.
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
let usage = match get_bucket_usage_memory(bucket).await {
Some(usage) => Some(usage),
// Boxed like the degraded fallback below: the snapshot reload
// would otherwise grow every write's state machine, and it only
// runs for a bucket the cached snapshot does not cover.
None => Box::pin(reload_unknown_bucket_usage(bucket)).await,
};
self.usage_or_degraded_baseline(bucket, usage).await
}
async fn usage_or_degraded_baseline(&self, bucket: &str, usage: Option<u64>) -> Result<u64, QuotaError> {
if let Some(usage) = usage {
if let Some(usage) = get_bucket_usage_memory(bucket).await {
return Ok(usage);
}
@@ -537,8 +528,6 @@ mod tests {
use serial_test::serial;
use uuid::Uuid;
mod reporting_retirement;
/// Regression (PR #5307 / s3-tests `test_100_continue_error_retry`): a
/// bucket with no persisted metadata has no quota, so the admission check
/// must pass and let the request reach its NoSuchBucket answer. With the
@@ -706,11 +695,6 @@ mod tests {
.await
.expect("stats for a ledger-admitted bucket degrade to an absent usage");
assert_eq!(usage, None);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
"reporting a ledger-admitted bucket's usage must not wait for an early snapshot reload"
);
// Negative control: the ledger, not the scanner, still refuses an
// over-limit write with the same error.
@@ -734,22 +718,7 @@ mod tests {
checker.check_quota(&legacy, QuotaOperation::PutObject, 512).await,
Err(QuotaError::UsageUnavailable { .. })
));
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy),
1,
"fail-closed admission takes the early-reload lookup"
);
let (_, usage) = checker
.get_quota_stats(&legacy)
.await
.expect("legacy quota statistics are reporting only");
assert_eq!(usage, None);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 1);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
"ledger-admitted writes never take the early-reload lookup"
);
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
@@ -1,330 +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.
use super::*;
use crate::disk::DiskAPI as _;
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::storage_api_contracts::object::ObjectIO as _;
use crate::test_metrics::CapturingRecorder;
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
use std::time::SystemTime;
async fn stop_reporting_fixture(store: &crate::store::ECStore) {
if let Some(cancel) = store.ctx.background_cancel_token() {
cancel.cancel();
}
for pool in &store.pools {
for set in &pool.disk_set {
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
disk.close().await.expect("stop fixture disk monitoring");
}
}
}
}
async fn install_reporting_snapshot(
store: Arc<crate::store::ECStore>,
bucket: &str,
source: &str,
size: u64,
) -> Option<(String, Vec<u8>)> {
if source == "missing" {
return None;
}
let mut info = DataUsageInfo {
last_update: Some(SystemTime::now()),
scanner_epoch: Some(1),
scanner_cycle: Some(1),
buckets_count: 1,
..Default::default()
};
info.buckets_usage.insert(
bucket.to_owned(),
BucketUsageInfo {
size,
objects_count: 1,
versions_count: 1,
..Default::default()
},
);
info.bucket_sizes.insert(bucket.to_owned(), size);
info.calculate_totals();
let object = match source {
"legacy" => rustfs_data_usage::LEGACY_DATA_USAGE_OBJECT_NAME,
"incomplete" => rustfs_data_usage::DATA_USAGE_OBJECT_NAME,
"complete" => {
info.usage_snapshot_complete = true;
rustfs_data_usage::DATA_USAGE_OBJECT_NAME
}
"observed" => {
info.usage_snapshot_complete = true;
info.usage_snapshot_converged = Some(false);
info.usage_snapshot_authoritative_baseline = Some(DataUsageInfo::default().snapshot_identity());
rustfs_data_usage::DATA_USAGE_OBSERVED_OBJECT_NAME
}
_ => panic!("unknown scanner fixture source"),
};
let path = format!("{}/{object}", crate::disk::BUCKET_META_PREFIX);
let bytes = serde_json::to_vec(&info).expect("scanner fixture encoding");
crate::config::com::save_config(store, &path, bytes.clone())
.await
.expect("persist scanner fixture");
if source == "complete" {
crate::data_usage::replace_bucket_usage_memory_from_info(&info).await;
}
Some((path, bytes))
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn durable_quota_reporting_retires_degraded_floors_without_changing_ledger_admission() {
let recorder = CapturingRecorder::default();
let _recorder = metrics::set_default_local_recorder(&recorder);
for protocol in [1, 2] {
for source in ["missing", "legacy", "incomplete", "observed", "complete"] {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
let checker = QuotaChecker::new(sys);
let bucket = format!("quota-report-{protocol}-{}", Uuid::new_v4().simple());
let quota = if protocol == 1 {
BucketQuota::new(Some(4096))
} else {
BucketQuota::new_sharded(4096)
};
create_bucket_with_quota_json(&store, &bucket, serde_json::to_vec(&quota).expect("quota fixture encoding")).await;
let snapshot = install_reporting_snapshot(store.clone(), &bucket, source, 1_000_000).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let unavailable_before = recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]);
let check = checker
.check_quota(&bucket, QuotaOperation::PutObject, 512)
.await
.expect("reporting cannot fail ledger admission");
let (_, usage) = checker.get_quota_stats(&bucket).await.expect("best-effort quota statistics");
let preview = checker
.check_quota_with_usage_reporting(&bucket, QuotaOperation::PutObject, 512, true)
.await
.expect("admin preview uses the same optional report");
// Exercise the real storage entry point before testing the new
// reporting semantics: the same ledger must admit and refuse
// these writes even when a scanner floor claims a million bytes.
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
assert!(
matches!(
store
.put_object(&bucket, "too-large", &mut too_large, &ObjectOptions::default())
.await,
Err(StorageError::QuotaExceeded { .. })
),
"protocol={protocol}, source={source}"
);
let mut fits = PutObjReader::from_vec(vec![0x5a; 512]);
store
.put_object(&bucket, "fits", &mut fits, &ObjectOptions::default())
.await
.expect("ledger admits 512 bytes");
let mut fills = PutObjReader::from_vec(vec![0x5a; 3584]);
store
.put_object(&bucket, "fills", &mut fills, &ObjectOptions::default())
.await
.expect("ledger admits exactly the remaining quota");
let mut exceeds = PutObjReader::from_vec(vec![0x5a; 1]);
assert!(
matches!(
store
.put_object(&bucket, "exceeds", &mut exceeds, &ObjectOptions::default())
.await,
Err(StorageError::QuotaExceeded { .. })
),
"ledger cannot undercount protocol={protocol}, source={source}"
);
assert!(check.allowed && check.uses_durable_reservations);
let expected_usage = (source == "complete").then_some(1_000_000);
assert_eq!(
check.current_usage, expected_usage,
"protocol={protocol}, source={source}: a floor is not a current report"
);
assert_eq!(check.remaining, expected_usage.map(|_| 0));
assert_eq!(usage, expected_usage, "statistics use the same best-effort reporting contract");
assert_eq!(preview.current_usage, expected_usage);
assert_eq!(preview.remaining, expected_usage.map(|_| 0));
assert_eq!(preview.allowed, source != "complete", "unknown preview is advisory, not a reservation");
assert_eq!(
recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]) - unavailable_before,
if source == "complete" { 0 } else { 3 },
"each unknown check/statistics/preview report is counted"
);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, Some(protocol));
if let Some((path, original)) = snapshot {
assert_eq!(
crate::config::com::read_config(store.clone(), &path)
.await
.expect("retained scanner evidence"),
original
);
}
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
}
}
}
#[tokio::test]
#[serial]
async fn snapshot_quota_admission_retains_degraded_floors_until_migration() {
for source in ["missing", "legacy", "incomplete", "observed"] {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-compat-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
// An unregistered checker models a snapshot-protocol reader without
// starting the optional background migration during this assertion.
let checker = QuotaChecker::new(Arc::new(RwLock::new(BucketMetadataSys::new(store.clone()))));
install_reporting_snapshot(store.clone(), &bucket, source, 1000).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let check = checker.check_quota(&bucket, QuotaOperation::PutObject, 512).await;
if source == "missing" {
assert!(matches!(check, Err(QuotaError::UsageUnavailable { .. })));
} else {
let check = check.expect("legacy admission retains its grounded compatibility fallback");
assert_eq!(check.current_usage, Some(1000));
assert!(check.allowed && !check.uses_durable_reservations);
assert!(
!checker
.check_quota(&bucket, QuotaOperation::PutObject, 4096)
.await
.expect("legacy quota decision")
.allowed
);
}
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, None);
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
}
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
let bucket = format!("quota-unlimited-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
)
.await;
let recorder = CapturingRecorder::default();
let _recorder = metrics::set_default_local_recorder(&recorder);
let check = QuotaChecker::new(sys)
.check_quota(&bucket, QuotaOperation::PutObject, u64::MAX)
.await
.expect("unlimited quota fast path");
assert!(check.allowed);
assert_eq!(check.current_usage, None);
assert_eq!(check.quota_limit, None);
assert_eq!(recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]), 0);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
stop_reporting_fixture(&store).await;
}
#[tokio::test]
#[serial]
async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
for configuration in ["unconfigured", "unlimited", "snapshot"] {
for source in ["missing", "legacy", "incomplete", "observed", "complete", "empty"] {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-statistics-{}", Uuid::new_v4().simple());
match configuration {
"unconfigured" => store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket without a quota configuration"),
"unlimited" => {
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
)
.await;
}
"snapshot" => create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await,
_ => unreachable!(),
}
// An unregistered checker keeps legacy migration out of the
// assertion: reporting must work before a protocol upgrade.
let metadata_sys = BucketMetadataSys::new(store.clone());
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
metadata_sys.set(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let statistics = checker.get_quota_stats(&bucket).await;
let early_reloads = crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket);
let missing_bucket = format!("missing-{}", Uuid::new_v4().simple());
let missing_statistics = checker.get_quota_stats(&missing_bucket).await;
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
let (quota, usage) = statistics.unwrap_or_else(|error| {
panic!("configuration={configuration}, source={source}: quota statistics must remain available: {error}")
});
assert_eq!(quota.quota, (configuration == "snapshot").then_some(4096));
assert_eq!(
usage,
(snapshot_source == "complete").then_some(size),
"configuration={configuration}, source={source}: only known usage is numeric"
);
assert_eq!(early_reloads, 0, "read-only statistics must not force an early snapshot reload");
assert!(
matches!(missing_statistics, Err(QuotaError::ConfigNotFound { bucket }) if bucket == missing_bucket),
"reporting must still reject a missing bucket"
);
}
}
}
#[tokio::test]
async fn quota_statistics_preserve_configuration_errors() {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-invalid-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let metadata_sys = BucketMetadataSys::new(store.clone());
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
metadata_sys.set(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let statistics = checker.get_quota_stats(&bucket).await;
stop_reporting_fixture(&store).await;
assert!(
matches!(statistics, Err(QuotaError::InvalidConfig { .. })),
"unavailable reporting must not hide malformed quota configuration"
);
}
+20 -82
View File
@@ -595,7 +595,7 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
let allow_create = shard_may_be_created(&allocator, data.shard_index, bootstrap);
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let (mut shard, shard_created) = load_current_shard_locked(
let mut shard = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
bucket_incarnation,
@@ -640,7 +640,7 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
});
}
let shard_was_empty = shard.grants.is_empty() && shard.reservations.is_empty() && shard.accounted_usage == 0;
if shard_created || shard_was_empty {
if shard_was_empty {
fence_namespace_mutations(&store, RUSTFS_META_BUCKET, &data.shard_object, None).await?;
save_shard_locked(Arc::clone(&store), &data.shard_object, &shard, &shard_guard).await?;
}
@@ -707,6 +707,13 @@ struct ShardedReservationData {
}
impl ReservationLedgerData {
fn store(&self) -> &Arc<ECStore> {
match self {
Self::Single(data) => &data.store,
Self::Sharded(data) => &data.store,
}
}
fn bucket(&self) -> &str {
match self {
Self::Single(data) => &data.bucket,
@@ -758,7 +765,7 @@ impl QuotaReservation {
let Some(ledger) = self.ledger.as_ref() else {
return;
};
crate::store::list_objects::observe_scanner_namespace_mutations(ledger.bucket(), 1);
crate::store::list_objects::observe_list_objects_mutation(ledger.store(), ledger.bucket()).await;
match settle(ledger, true).await {
Ok(()) => self.ledger = None,
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
@@ -1283,7 +1290,7 @@ async fn reconcile_exact(store: &Arc<ECStore>, bucket: &str, ledger: &mut QuotaL
}
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
crate::store::list_objects::observe_list_objects_mutation(store, bucket).await;
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
}
@@ -1503,18 +1510,18 @@ async fn load_current_shard_locked(
shard_index: u16,
allow_create: bool,
allocator: &QuotaAllocatorLedger,
) -> Result<(QuotaShardLedger, bool)> {
let (mut shard, created) = match load_shard_locked(Arc::clone(&store), object).await {
Ok(shard) if shard.matches(bucket_incarnation, quota_revision, shard_index) => (shard, false),
) -> Result<QuotaShardLedger> {
let mut shard = match load_shard_locked(Arc::clone(&store), object).await {
Ok(shard) if shard.matches(bucket_incarnation, quota_revision, shard_index) => shard,
Ok(_) => return Err(StorageError::PartMissingOrCorrupt),
Err(StorageError::ConfigNotFound) if allow_create => {
(QuotaShardLedger::new(bucket_incarnation, quota_revision, shard_index), true)
QuotaShardLedger::new(bucket_incarnation, quota_revision, shard_index)
}
Err(StorageError::ConfigNotFound) => return Err(StorageError::PartMissingOrCorrupt),
Err(err) => return Err(err),
};
shard.adopt_grants(allocator)?;
Ok((shard, created))
Ok(shard)
}
async fn save_allocator_locked(
@@ -1639,7 +1646,7 @@ async fn reap_sharded_reservations(
.await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = shard_lock.get_write_lock(get_lock_acquire_timeout()).await?;
let (shard, _) = load_current_shard_locked(
let shard = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
bucket_incarnation,
@@ -1702,7 +1709,7 @@ async fn reap_sharded_reservations(
.await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let (mut shard, _) = load_current_shard_locked(
let mut shard = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
bucket_incarnation,
@@ -1767,7 +1774,7 @@ async fn mark_commit_started_sharded(data: &ShardedReservationData) -> Result<()
let allocator = load_allocator_locked(Arc::clone(&store), &data.allocator_object).await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let (mut shard, _) = load_current_shard_locked(
let mut shard = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
data.bucket_incarnation,
@@ -1812,7 +1819,7 @@ async fn settle_sharded(data: &ShardedReservationData, committed: bool) -> Resul
let allocator = load_allocator_locked(Arc::clone(&store), &data.allocator_object).await?;
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
let (mut shard, _) = load_current_shard_locked(
let mut shard = load_current_shard_locked(
Arc::clone(&store),
&data.shard_object,
data.bucket_incarnation,
@@ -2452,10 +2459,7 @@ mod tests {
}
}
// Its writes read the process-wide usage snapshot cache through the
// degraded-baseline fallback, which the data-usage cache tests rewrite.
#[tokio::test]
#[serial]
async fn sharded_reservation_reloads_allocator_after_concurrent_refill() {
use crate::object_api::PutObjReader;
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
@@ -2528,70 +2532,4 @@ mod tests {
1
);
}
#[tokio::test]
#[serial]
async fn sharded_reservation_persists_a_populated_lazy_shard_before_refill() {
use crate::object_api::PutObjReader;
use crate::storage_api_contracts::object::ObjectIO;
let (_directories, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let bucket = format!("quota-populated-bootstrap-{}", Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create quota bucket");
let mut seed_shard = None;
let mut seed_name = None;
let mut candidate_name = None;
let mut bootstrap_name = None;
for index in 0..10_000 {
let object = format!("quota-populated-object-{index}");
let object_shard = shard_index(&object);
if seed_shard.is_none() {
seed_shard = Some(object_shard);
seed_name = Some(object);
} else if Some(object_shard) == seed_shard && candidate_name.is_none() {
candidate_name = Some(object);
} else if Some(object_shard) != seed_shard && bootstrap_name.is_none() {
bootstrap_name = Some(object);
}
if candidate_name.is_some() && bootstrap_name.is_some() {
break;
}
}
let seed_name = seed_name.expect("find a seeded shard");
let candidate_name = candidate_name.expect("find a second object in the seeded shard");
let bootstrap_name = bootstrap_name.expect("find an empty bootstrap shard");
let mut seed = PutObjReader::from_vec(vec![1]);
store
.put_object(&bucket, &seed_name, &mut seed, &ObjectOptions::default())
.await
.expect("seed existing usage before enabling v2");
store
.update_bucket_metadata_config(
&bucket,
"quota.json",
serde_json::to_vec(&super::super::BucketQuota::new_sharded(100_000)).expect("serialize v2 quota"),
)
.await
.expect("enable v2 quota");
let mut bootstrap = PutObjReader::from_vec(vec![2]);
store
.put_object(&bucket, &bootstrap_name, &mut bootstrap, &ObjectOptions::default())
.await
.expect("bootstrap an empty shard");
let mut candidate = PutObjReader::from_vec(vec![3]);
let result = store
.put_object(&bucket, &candidate_name, &mut candidate, &ObjectOptions::default())
.await
.expect("a populated shard must persist before refill and accept its first growth");
assert_eq!(result.size, 1);
}
}
@@ -84,8 +84,6 @@ pub use replication_queue_boundary::{
};
pub use replication_resync_boundary::ResyncStatusType;
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
#[cfg(test)]
pub(crate) use replication_resyncer::read_source_delete_marker;
pub use replication_scanner_bridge::ReplicationScannerBridge;
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
@@ -2475,8 +2475,19 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
if dobj.delete_object.delete_marker
&& let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id
{
let source_marker_state =
read_source_delete_marker(&*storage, &bucket, &dobj.delete_object.object_name, delete_marker_version_id).await;
let source_marker_state = storage
.get_object_info(
&bucket,
&dobj.delete_object.object_name,
&ObjectOptions {
version_id: Some(delete_marker_version_id.to_string()),
versioned: ReplicationVersioningStore::prefix_enabled(&bucket, &dobj.delete_object.object_name).await,
version_suspended: ReplicationVersioningStore::prefix_suspended(&bucket, &dobj.delete_object.object_name)
.await,
..Default::default()
},
)
.await;
match source_marker_state {
Ok(info) if info.delete_marker && info.version_id == Some(delete_marker_version_id) => {}
@@ -2847,18 +2858,14 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
)
}
/// Reads the source version addressed by `delete_marker_version_id` so a
/// delete-marker replication can confirm the marker still exists. Plain
/// `get_object_info` rejects explicit marker version reads on multi-pool
/// stores with `MethodNotAllowed`, which would fail every marker (rustfs#8746).
pub(crate) async fn read_source_delete_marker<S: EcstoreObjectOperations>(
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
storage: &S,
bucket: &str,
object_name: &str,
delete_marker_version_id: Uuid,
) -> Result<ObjectInfo> {
storage
.get_object_info_for_delete(
) -> bool {
match storage
.get_object_info(
bucket,
object_name,
&ObjectOptions {
@@ -2869,15 +2876,7 @@ pub(crate) async fn read_source_delete_marker<S: EcstoreObjectOperations>(
},
)
.await
}
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
storage: &S,
bucket: &str,
object_name: &str,
delete_marker_version_id: Uuid,
) -> bool {
match read_source_delete_marker(storage, bucket, object_name, delete_marker_version_id).await {
{
Ok(info) => !info.delete_marker || info.version_id != Some(delete_marker_version_id),
Err(err) => is_err_object_not_found(&err) || is_err_version_not_found(&err),
}
+114 -448
View File
@@ -25,7 +25,7 @@ use std::{
future::Future,
io::ErrorKind,
pin::Pin,
sync::{Arc, OnceLock},
sync::{Arc, OnceLock, atomic::AtomicBool},
task::{Context, Poll},
time::Duration,
};
@@ -40,9 +40,14 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_METACACHE: &str = "metacache";
const EVENT_METACACHE_LISTING: &str = "metacache_listing";
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
pub type PartialFn =
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
pub(crate) type PartialResultFn = Box<
dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = disk::error::Result<()>> + Send>>
+ Send
+ 'static,
>;
type FinishedFn = Box<dyn Fn(&[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
#[derive(Clone, Default)]
@@ -55,7 +60,6 @@ impl FallbackClaimTracker {
self.claimed.lock().await.insert(disk.endpoint().to_string());
}
#[cfg(test)]
pub(crate) async fn claimed_keys(&self) -> HashSet<String> {
self.claimed.lock().await.clone()
}
@@ -194,8 +198,6 @@ pub(crate) enum TestReaderBehavior {
stopped: Arc<tokio::sync::Notify>,
},
IgnoreCancel,
/// Entries followed by a terminator trailer saying the producer stopped at its limit.
LimitReached(Vec<MetaCacheEntry>),
ProducerError(DiskError),
PrimaryErrorThenFallback(DiskError),
PartialThenTimeout(Vec<MetaCacheEntry>),
@@ -234,6 +236,8 @@ pub struct ListPathRawOptions {
pub skip_walkdir_total_timeout: bool,
pub walkdir_timeout: Option<Duration>,
pub walkdir_stall_timeout: Option<Duration>,
/// Shared terminal state for bounded producer walks.
pub producer_limit_reached: Option<Arc<AtomicBool>>,
pub agreed: Option<AgreedFn>,
pub partial: Option<PartialFn>,
pub finished: Option<FinishedFn>,
@@ -244,7 +248,7 @@ pub struct ListPathRawOptions {
#[cfg(test)]
pub(crate) peek_timeout: Option<Duration>,
#[cfg(test)]
pub(crate) test_disk_io_context_observed: Option<Arc<std::sync::atomic::AtomicBool>>,
pub(crate) test_disk_io_context_observed: Option<Arc<AtomicBool>>,
// pub agreed: Option<Arc<dyn Fn(MetaCacheEntry) + Send + Sync>>,
// pub partial: Option<Arc<dyn Fn(MetaCacheEntries, &[Option<Error>]) + Send + Sync>>,
// pub finished: Option<Arc<dyn Fn(&[Option<Error>]) + Send + Sync>>,
@@ -269,6 +273,7 @@ impl Clone for ListPathRawOptions {
skip_walkdir_total_timeout: self.skip_walkdir_total_timeout,
walkdir_timeout: self.walkdir_timeout,
walkdir_stall_timeout: self.walkdir_stall_timeout,
producer_limit_reached: self.producer_limit_reached.clone(),
#[cfg(test)]
test_reader_behaviors: self.test_reader_behaviors.clone(),
#[cfg(test)]
@@ -296,51 +301,44 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
skip_total_timeout: opts.skip_walkdir_total_timeout,
timeout_ms: opts.walkdir_timeout.map(duration_millis),
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
producer_limit_reached: opts.producer_limit_reached.clone(),
..Default::default()
}
}
/// How a merged raw listing stream ended.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ListPathRawOutcome {
/// At least one producer stopped at its per-disk limit instead of at the
/// end of its namespace, so the closed stream is not proof of exhaustion.
/// Carried in-band by the metacache stream trailer, so remote producers
/// report it as well.
pub limit_reached: bool,
}
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, None).await.map(|_| ())
}
/// Like [`list_path_raw`], but reports how the merged stream ended.
pub(crate) async fn list_path_raw_with_outcome(
rx: CancellationToken,
opts: ListPathRawOptions,
) -> disk::error::Result<ListPathRawOutcome> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, None).await
list_path_raw_inner(rx, opts, None, None).await
}
pub(crate) async fn list_path_raw_with_claim_tracker(
rx: CancellationToken,
opts: ListPathRawOptions,
claim_tracker: FallbackClaimTracker,
) -> disk::error::Result<ListPathRawOutcome> {
) -> disk::error::Result<()> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, Some(claim_tracker)).await
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
}
pub(crate) async fn list_path_raw_with_partial_result(
rx: CancellationToken,
opts: ListPathRawOptions,
claim_tracker: FallbackClaimTracker,
partial_result: PartialResultFn,
) -> disk::error::Result<()> {
let rx = rx.child_token();
let _cancel_guard = rx.clone().drop_guard();
list_path_raw_inner(rx, opts, Some(claim_tracker), Some(partial_result)).await
}
async fn list_path_raw_inner(
rx: CancellationToken,
opts: ListPathRawOptions,
fallback_claim_tracker: Option<FallbackClaimTracker>,
) -> disk::error::Result<ListPathRawOutcome> {
partial_result: Option<PartialResultFn>,
) -> disk::error::Result<()> {
if opts.disks.is_empty() {
return Err(DiskError::ErasureReadQuorum);
}
@@ -374,7 +372,7 @@ async fn list_path_raw_inner(
let cancel_rx_clone = cancel_rx.clone();
let producer_errs_clone = producer_errs.clone();
let disk_io_context_clone = disk_io_context.clone();
let (rd, wr) = tokio::io::duplex(64 * 1024);
let (rd, wr) = tokio::io::duplex(64);
readers.push(MetacacheReader::new(rd));
jobs.spawn(with_optional_disk_io_context(disk_io_context_clone, async move {
let mut wr = PublishedBytesWriter::new(wr);
@@ -395,14 +393,6 @@ async fn list_path_raw_inner(
out.close().await.expect("test entries should close");
return Ok(());
}
TestReaderBehavior::LimitReached(entries) => {
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
out.write(&entries).await.expect("test entries should be written");
out.close_with_limit_reached(true)
.await
.expect("test entries should close at the limit");
return Ok(());
}
TestReaderBehavior::Stall => {
let _held_writer = wr;
cancel_rx_clone.cancelled().await;
@@ -541,16 +531,6 @@ async fn list_path_raw_inner(
last_err = None;
continue;
}
TestReaderBehavior::LimitReached(entries) => {
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
out.write(&entries).await.expect("test fallback entries should be written");
out.close_with_limit_reached(true)
.await
.expect("test fallback entries should close at the limit");
need_fallback = false;
last_err = None;
continue;
}
TestReaderBehavior::ProducerError(err) | TestReaderBehavior::PrimaryErrorThenFallback(err) => {
last_err = Some(err);
continue;
@@ -719,13 +699,9 @@ async fn list_path_raw_inner(
}
let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
let mut peek_outcomes: Vec<Option<PeekOutcome>> = std::iter::repeat_with(|| None).take(readers.len()).collect();
// Sticky across rounds: once any reader's trailer says its producer
// stopped at the per-disk limit, the merged stream may hide more keys.
let mut limit_reached = false;
loop {
let mut current = MetaCacheEntry::default();
let mut current_idx: Option<usize> = None;
// warn!(
// "list_path_raw: loop start, bucket: {}, path: {}, current: {:?}",
@@ -733,7 +709,7 @@ async fn list_path_raw_inner(
// );
if revjob_rx.is_cancelled() {
return Ok(ListPathRawOutcome { limit_reached });
return Ok(());
}
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
@@ -783,7 +759,6 @@ async fn list_path_raw_inner(
continue;
}
// eof
limit_reached |= r.limit_reached() == Some(true);
at_eof += 1;
// warn!("list_path_raw: peek eof, disk: {}", i);
continue;
@@ -857,7 +832,7 @@ async fn list_path_raw_inner(
*r = MetacacheReader::new(detached_rd);
continue;
}
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
PeekOutcome::Cancelled => return Ok(()),
}
};
@@ -865,32 +840,30 @@ async fn list_path_raw_inner(
// If no current, add it.
if current.name.is_empty() {
current_idx = Some(i);
top_entries[i] = Some(entry.clone());
current = entry;
agree += 1;
continue;
}
// If exact match, we agree.
if let (_, true) = current.matches(Some(&entry), true) {
top_entries[i] = Some(entry);
agree += 1;
continue;
}
// If only the name matches we didn't agree, but add it for resolution.
if entry.name == current.name {
top_entries[i] = Some(entry);
continue;
}
// We got different entries
if entry.name > current.name {
pending_entries[i] = Some(entry);
continue;
}
if entry.name == current.name {
// If exact match, we agree.
if current.matches_strict(&entry) {
top_entries[i] = Some(entry);
agree += 1;
} else {
// If only the name matches we didn't agree, but add it for resolution.
top_entries[i] = Some(entry);
}
continue;
}
if let Some(idx) = current_idx.take() {
pending_entries[idx] = Some(std::mem::take(&mut current));
}
for (idx, item) in top_entries.iter_mut().enumerate().take(i) {
if let Some(entry) = item.take() {
pending_entries[idx] = Some(entry);
@@ -898,7 +871,7 @@ async fn list_path_raw_inner(
}
agree = 1;
current_idx = Some(i);
top_entries[i] = Some(entry.clone());
current = entry;
}
@@ -916,7 +889,7 @@ async fn list_path_raw_inner(
if let Some(finished_fn) = opts.finished.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(()),
_ = finished_fn(&errs) => {}
}
}
@@ -973,7 +946,7 @@ async fn list_path_raw_inner(
{
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(()),
_ = finished_fn(&errs) => {}
}
}
@@ -993,8 +966,8 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: agreed_fn start, current: {:?}", &current.name);
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
result = agreed_fn(current) => result?
_ = revjob_rx.cancelled() => return Ok(()),
_ = agreed_fn(current) => {}
}
// warn!("list_path_raw: agreed_fn done");
}
@@ -1004,68 +977,67 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: skip start, current: {:?}", &current.name);
if let Some(idx) = current_idx.take() {
top_entries[idx] = Some(current);
}
for (i, r) in readers.iter_mut().enumerate() {
if top_entries[i].is_some() {
let _ = r.skip(1).await;
}
}
if let Some(partial_fn) = opts.partial.as_ref() {
if let Some(partial_fn) = partial_result.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
_ = revjob_rx.cancelled() => return Ok(()),
result = partial_fn(MetaCacheEntries(top_entries), &errs) => result?,
}
} else if let Some(partial_fn) = opts.partial.as_ref() {
tokio::select! {
biased;
_ = revjob_rx.cancelled() => return Ok(()),
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
}
}
}
Ok(ListPathRawOutcome { limit_reached })
Ok(())
});
let merge_started = std::time::Instant::now();
let Some(revjob) = revjobs.join_next().await else {
return Err(DiskError::Unexpected);
};
let outcome = match revjob.map_err(std::io::Error::other)? {
Ok(outcome) => outcome,
Err(err) => {
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
if is_missing_path_error(&err) {
debug!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_missing_path",
error = ?err,
"Metacache merge job missing path skipped"
);
} else {
error!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_failed",
error = ?err,
"Metacache merge job failed"
);
}
cancel_rx.cancel();
jobs.abort_all();
return Err(err);
if let Err(err) = revjob.map_err(std::io::Error::other)? {
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
if is_missing_path_error(&err) {
debug!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_missing_path",
error = ?err,
"Metacache merge job missing path skipped"
);
} else {
error!(
event = EVENT_METACACHE_LISTING,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_METACACHE,
bucket = %log_bucket,
path = %log_path,
state = "merge_job_failed",
error = ?err,
"Metacache merge job failed"
);
}
};
cancel_rx.cancel();
jobs.abort_all();
return Err(err);
}
if rx.is_cancelled() {
cancel_rx.cancel();
jobs.abort_all();
return Ok(outcome);
return Ok(());
}
rustfs_io_metrics::record_stage_duration("metacache_merge", merge_started.elapsed().as_secs_f64() * 1000.0);
@@ -1134,7 +1106,7 @@ async fn list_path_raw_inner(
}
// warn!("list_path_raw: done");
Ok(outcome)
Ok(())
}
#[inline]
@@ -1153,7 +1125,6 @@ mod tests {
use rustfs_filemeta::MetacacheWriter;
use rustfs_filemeta::{FileInfo, FileMeta, MetadataResolutionParams};
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use time::OffsetDateTime;
use uuid::Uuid;
@@ -1274,59 +1245,6 @@ mod tests {
);
}
#[tokio::test]
async fn list_path_raw_reports_limit_reached_from_any_reader_trailer() {
let first = fallback_test_entry();
let second = MetaCacheEntry {
name: "bucket/object-2".to_string(),
..fallback_test_entry()
};
// One bounded producer, one that ends naturally, one legacy empty
// stream without any trailer.
let outcome = list_path_raw_inner(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::LimitReached(vec![first.clone()]),
TestReaderBehavior::Entries(vec![first, second]),
TestReaderBehavior::Eof,
],
..Default::default()
},
None,
)
.await
.expect("listing should complete");
assert!(
outcome.limit_reached,
"a reader whose trailer reports a limit stop must mark the merged stream as possibly incomplete"
);
}
#[tokio::test]
async fn list_path_raw_reports_natural_end_when_no_reader_trailer_says_limit() {
let outcome = list_path_raw_inner(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(vec![fallback_test_entry()]),
TestReaderBehavior::Eof,
],
..Default::default()
},
None,
)
.await
.expect("listing should complete");
assert!(!outcome.limit_reached, "natural and legacy stream ends must not claim a limit stop");
}
#[tokio::test]
async fn list_path_raw_returns_typed_quorum_error_for_multiple_drive_failures() {
let err = list_path_raw(
@@ -1786,31 +1704,36 @@ mod tests {
}
#[tokio::test]
async fn list_path_raw_propagates_agreed_callback_failure() {
let entry = MetaCacheEntry {
name: "bucket/object".to_owned(),
async fn list_path_raw_propagates_partial_callback_failure() {
let first = MetaCacheEntry {
name: "bucket/object-a".to_string(),
metadata: vec![1],
..Default::default()
};
let result = timeout(
Duration::from_secs(1),
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None],
min_disks: 1,
test_reader_behaviors: vec![TestReaderBehavior::Entries(vec![entry])],
agreed: Some(Box::new(|_| Box::pin(async { Err(DiskError::ErasureReadQuorum) }))),
..Default::default()
},
),
let second = MetaCacheEntry {
name: "bucket/object-b".to_string(),
metadata: vec![2],
..Default::default()
};
let err = list_path_raw_with_partial_result(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None],
min_disks: 2,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(vec![first]),
TestReaderBehavior::Entries(vec![second]),
],
..Default::default()
},
FallbackClaimTracker::default(),
Box::new(move |_, _| Box::pin(async { Err(DiskError::ErasureReadQuorum) })),
)
.await
.expect("callback failure must terminate the walk");
assert_eq!(
result.expect_err("agreed resolution must not silently omit the key"),
DiskError::ErasureReadQuorum
);
.expect_err("a resolution failure after reader advancement must fail the listing");
assert_eq!(err, DiskError::ErasureReadQuorum);
}
#[tokio::test]
@@ -1881,7 +1804,6 @@ mod tests {
Box::pin(async move {
callback_started.notify_one();
std::future::pending::<()>().await;
Ok(())
})
})),
..Default::default()
@@ -2033,7 +1955,6 @@ mod tests {
let seen = seen_clone.clone();
Box::pin(async move {
seen.lock().expect("seen mutex poisoned").push(entry.name);
Ok(())
})
})),
..Default::default()
@@ -2076,7 +1997,6 @@ mod tests {
let seen = seen_clone.clone();
Box::pin(async move {
seen.lock().expect("seen mutex poisoned").push(entry.name);
Ok(())
})
})),
..Default::default()
@@ -2149,7 +2069,6 @@ mod tests {
let seen = agreed_seen.clone();
Box::pin(async move {
seen.lock().expect("seen mutex poisoned").push(entry.name);
Ok(())
})
})),
partial: Some(Box::new(move |entries, _| {
@@ -2261,257 +2180,4 @@ mod tests {
assert_eq!(err, DiskError::FileAccessDenied);
}
#[tokio::test]
async fn list_path_raw_demotes_current_and_agreed_peers_across_cascading_smaller_keys() {
fn make_entry(name: &str, vid: u128, etag: &str) -> MetaCacheEntry {
let mut meta = FileMeta::default();
let mut fi = FileInfo::new(name, 2, 2);
fi.version_id = Some(Uuid::from_u128(vid));
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
fi.metadata.insert("etag".to_string(), etag.to_string());
meta.add_version(fi).expect("add version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("marshal metadata"),
cached: None,
reusable: false,
}
}
// Round 1: Disks 0 and 1 BOTH start at "key-d" and strictly agree, populating `current_idx = Some(0)`
// and `top_entries[1] = Some(key-d)`. Disk 2 then yields "key-b" (< "key-d"), forcing demotion of
// BOTH `current_idx` (`pending_entries[0]`) AND `top_entries[1]` (`pending_entries[1]`).
// Disk 3 then yields "key-a" (< "key-b"), forcing a second cascading demotion of Disk 2 to `pending_entries[2]`.
// Later at "key-e", all 4 disks present the same name, version_id, and mod_time, but Disk 2 has a different
// ETag ("etag-e2" vs "etag-e1"), proving `matches_strict` routes all 4 replicas to `partial` instead of `agreed`.
let k_a = make_entry("key-a", 1, "etag-a");
let k_b = make_entry("key-b", 2, "etag-b");
let k_d = make_entry("key-d", 4, "etag-d");
let k_e_v1 = make_entry("key-e", 5, "etag-e1");
let k_e_v2 = make_entry("key-e", 5, "etag-e2");
let disk0 = vec![k_d.clone(), k_e_v1.clone()];
let disk1 = vec![k_d.clone(), k_e_v1.clone()];
let disk2 = vec![k_b.clone(), k_d.clone(), k_e_v2];
let disk3 = vec![k_a, k_b, k_d, k_e_v1];
let events = Arc::new(Mutex::new(Vec::new()));
let agreed_events = events.clone();
let partial_events = events.clone();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(disk0),
TestReaderBehavior::Entries(disk1),
TestReaderBehavior::Entries(disk2),
TestReaderBehavior::Entries(disk3),
],
agreed: Some(Box::new(move |entry| {
let agreed_events = agreed_events.clone();
Box::pin(async move {
agreed_events.lock().expect("events lock").push((entry.name, 4usize, true));
Ok(())
})
})),
partial: Some(Box::new(move |entries, _| {
let partial_events = partial_events.clone();
Box::pin(async move {
let present = entries.0.iter().flatten().count();
let name = entries
.0
.iter()
.flatten()
.next()
.expect("at least one entry in partial")
.name
.clone();
partial_events.lock().expect("events lock").push((name, present, false));
})
})),
..Default::default()
},
)
.await
.expect("cascading demotion listing should succeed");
assert_eq!(
events.lock().expect("events lock").as_slice(),
&[
("key-a".to_string(), 1, false),
("key-b".to_string(), 2, false),
("key-d".to_string(), 4, true),
("key-e".to_string(), 4, false),
],
"cascading demotions must preserve pending_entries from both current_idx and top_entries, and 4-disk ETag divergence must take partial"
);
}
#[tokio::test]
async fn list_path_raw_seeded_multi_disk_streams_match_reference_oracle() {
type Step = (String, Vec<Option<String>>, bool);
fn make_entry(name: &str, variant: u64) -> MetaCacheEntry {
if name.ends_with('/') {
return MetaCacheEntry {
name: name.to_string(),
metadata: Vec::new(),
cached: None,
reusable: false,
};
}
let mut meta = FileMeta::default();
let mut fi = FileInfo::new(name, 2, 2);
fi.version_id = Some(Uuid::from_u128(1));
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
fi.metadata.insert("etag".to_string(), format!("etag-{variant}"));
meta.add_version(fi).expect("add version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("marshal metadata"),
cached: None,
reusable: false,
}
}
fn describe_entry(entry: &MetaCacheEntry) -> String {
if entry.is_dir() {
return format!("{}:0", entry.name);
}
let fi = entry.to_fileinfo("bucket").expect("decode test entry");
let etag = fi.metadata.get("etag").expect("etag present");
let v = etag.strip_prefix("etag-").expect("etag prefix");
format!("{}:{v}", entry.name)
}
let universe = ["a", "a/", "a/b", "a/b/", "a/c", "a0", "b", "b/", "b/1", "c", "d/", "e"];
// Deterministic LCG seed using high bits across 24 random 4-disk stream schedules.
let mut seed = 0x5eed_cafe_1234_5678_u64;
let mut next_rand = || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
seed >> 33
};
let mut total_agreed = 0usize;
let mut total_partial = 0usize;
let mut total_preserved_all_four = 0usize;
for round in 0..24 {
let preserve_replica_metadata = round % 3 == 0;
let mut disk_streams: Vec<Vec<MetaCacheEntry>> = vec![Vec::new(); 4];
let mut expected_steps: Vec<Step> = Vec::new();
for &key in &universe {
// High-bit mask in 1..=15; force mask 15 on every 4th key so 4-disk agreement/divergence is heavily exercised.
let mask = if next_rand() % 4 == 0 {
0b1111_u64
} else {
(next_rand() % 15) + 1
};
let mut slot_variants: Vec<Option<u64>> = Vec::with_capacity(4);
let mut first_variant = None;
let mut all_present_and_equal = true;
for (disk_idx, stream) in disk_streams.iter_mut().enumerate() {
if (mask & (1_u64 << disk_idx)) != 0 {
// Directory entries carry no metadata, so their variant is always 0.
let v = if key.ends_with('/') {
first_variant = Some(0);
0
} else if first_variant.is_none() || (next_rand() % 4 != 0) {
let v = first_variant.unwrap_or_else(|| next_rand() % 3);
first_variant = Some(v);
v
} else {
all_present_and_equal = false;
(first_variant.unwrap_or(0) + 1) % 3
};
stream.push(make_entry(key, v));
slot_variants.push(Some(v));
} else {
all_present_and_equal = false;
slot_variants.push(None);
}
}
let expect_agreed = all_present_and_equal && !preserve_replica_metadata;
if expect_agreed {
total_agreed += 1;
} else {
total_partial += 1;
if all_present_and_equal && preserve_replica_metadata {
total_preserved_all_four += 1;
}
}
let expected_slots = slot_variants
.into_iter()
.map(|opt| opt.map(|v| format!("{key}:{v}")))
.collect();
expected_steps.push((key.to_string(), expected_slots, expect_agreed));
}
let observed: Arc<Mutex<Vec<Step>>> = Arc::new(Mutex::new(Vec::new()));
let agreed_obs = observed.clone();
let partial_obs = observed.clone();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None; 4],
min_disks: 1,
preserve_replica_metadata,
test_reader_behaviors: disk_streams.into_iter().map(TestReaderBehavior::Entries).collect(),
agreed: Some(Box::new(move |entry| {
let agreed_obs = agreed_obs.clone();
let desc = describe_entry(&entry);
let name = entry.name;
Box::pin(async move {
agreed_obs.lock().expect("obs lock").push((name, vec![Some(desc); 4], true));
Ok(())
})
})),
partial: Some(Box::new(move |entries, _| {
let partial_obs = partial_obs.clone();
let name = entries
.0
.iter()
.flatten()
.next()
.expect("partial must contain at least one entry")
.name
.clone();
let slots = entries
.0
.iter()
.map(|opt| opt.as_ref().map(describe_entry))
.collect::<Vec<_>>();
Box::pin(async move {
partial_obs.lock().expect("obs lock").push((name, slots, false));
})
})),
..Default::default()
},
)
.await
.expect("seeded list_path_raw must succeed");
assert_eq!(
*observed.lock().expect("obs lock"),
expected_steps,
"round={round}, preserve_replica_metadata={preserve_replica_metadata}"
);
}
assert!(total_agreed > 0, "seeded schedule must exercise agreed fast-path");
assert!(total_partial > 0, "seeded schedule must exercise partial path");
assert!(
total_preserved_all_four > 0,
"seeded schedule must exercise preserve_replica_metadata on 4-disk agreement"
);
}
}
+2 -12
View File
@@ -105,7 +105,7 @@ pub async fn tier_mutation_control_time_out_client(
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
///
/// Bulk `bytes`-carrying RPCs, including native pool metadata recovery inventories, pass
/// Bulk `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion) pass
/// [`ChannelClass::Bulk`] so, when channel isolation is enabled, they are physically isolated
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
@@ -357,7 +357,6 @@ fn rename_data_grpc_stage(path: &str) -> bool {
matches!(
path,
"/node_service.NodeService/RenameData"
| "/node_service.NodeService/RenameMultipartData"
| "/node_service.NodeService/RenameDataAtIncarnation"
| "/node_service.NodeService/RenameDataWithQuotaFence"
)
@@ -380,7 +379,6 @@ where
}
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
let rpc_method = request.extensions().get::<tonic::GrpcMethod<'_>>().cloned();
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
let request_scope_started = if observe_rename_data {
rustfs_io_metrics::put_stage_timer()
@@ -449,21 +447,13 @@ where
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
if let Err(error) = &response_state
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)
|| matches!(sent_state.cache_capability, Some(PeerReplayCapability::Capable { .. })))
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER))
{
debug!(
event = "internode_rpc_capability_proof_rejected",
component = "ecstore",
subsystem = "rpc_client",
result = "rejected",
audience = audience.rsplit('@').next().unwrap_or("unknown"),
rpc_service = rpc_method.as_ref().map(|method| method.service()).unwrap_or("unknown"),
rpc_method = rpc_method.as_ref().map(|method| method.method()).unwrap_or("unknown"),
http_status = response.status().as_u16(),
grpc_status = response.headers().get("grpc-status").and_then(|value| value.to_str().ok()).unwrap_or("trailers"),
capability_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER),
capability_proof_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER),
error = %error,
"internode RPC capability proof rejected"
)
+14 -88
View File
@@ -94,7 +94,6 @@ const REPLAY_CACHE_AUTO_MEMORY_PERCENT: u64 = 13;
const REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU: usize = 4096;
const REPLAY_CACHE_AUTO_MAX_CAPACITY: usize = 33_554_432;
const NS_SCANNER_CAPABILITY_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-capability-v3";
const NS_SCANNER_CHECKPOINT_COVERAGE_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-checkpoint-coverage-v1";
const NS_SCANNER_TIER_REGISTRY_GENERATION_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-tier-registry-generation-v1";
pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService";
static INTERNODE_RPC_SIGNATURE_STRICT: LazyLock<bool> = LazyLock::new(|| {
@@ -643,7 +642,6 @@ fn update_ns_scanner_capability_mac(
challenge: Uuid,
server_epoch: Uuid,
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) {
mac.update(NS_SCANNER_CAPABILITY_AUTH_DOMAIN);
mac.update(&NS_SCANNER_PROTOCOL_VERSION.to_be_bytes());
@@ -655,9 +653,6 @@ fn update_ns_scanner_capability_mac(
// that receives generation-fenced scanner work.
mac.update(NS_SCANNER_TIER_REGISTRY_GENERATION_AUTH_DOMAIN);
}
if supports_checkpoint_coverage {
mac.update(NS_SCANNER_CHECKPOINT_COVERAGE_AUTH_DOMAIN);
}
}
fn generate_ns_scanner_capability_proof(
@@ -665,20 +660,13 @@ fn generate_ns_scanner_capability_proof(
challenge: Uuid,
server_epoch: Uuid,
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<Vec<u8>> {
if challenge.is_nil() || server_epoch.is_nil() {
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
}
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_ns_scanner_capability_mac(
&mut mac,
challenge,
server_epoch,
supports_tier_registry_generation,
supports_checkpoint_coverage,
);
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch, supports_tier_registry_generation);
Ok(mac.finalize().into_bytes().to_vec())
}
@@ -688,20 +676,13 @@ fn verify_ns_scanner_capability_proof(
server_epoch: Uuid,
proof: &[u8],
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<()> {
if challenge.is_nil() || server_epoch.is_nil() {
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
}
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
update_ns_scanner_capability_mac(
&mut mac,
challenge,
server_epoch,
supports_tier_registry_generation,
supports_checkpoint_coverage,
);
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch, supports_tier_registry_generation);
mac.verify_slice(proof)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid namespace scanner capability proof"))
}
@@ -719,22 +700,7 @@ pub fn sign_ns_scanner_capability_with_tier_registry_generation(
server_epoch: Uuid,
supports_tier_registry_generation: bool,
) -> std::io::Result<Vec<u8>> {
sign_ns_scanner_capability_with_checkpoint_coverage(challenge, server_epoch, supports_tier_registry_generation, false)
}
pub fn sign_ns_scanner_capability_with_checkpoint_coverage(
challenge: Uuid,
server_epoch: Uuid,
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<Vec<u8>> {
generate_ns_scanner_capability_proof(
&get_shared_secret()?,
challenge,
server_epoch,
supports_tier_registry_generation,
supports_checkpoint_coverage,
)
generate_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, supports_tier_registry_generation)
}
pub fn verify_ns_scanner_capability_with_tier_registry_generation(
@@ -743,30 +709,7 @@ pub fn verify_ns_scanner_capability_with_tier_registry_generation(
proof: &[u8],
supports_tier_registry_generation: bool,
) -> std::io::Result<()> {
verify_ns_scanner_capability_with_checkpoint_coverage(
challenge,
server_epoch,
proof,
supports_tier_registry_generation,
false,
)
}
pub fn verify_ns_scanner_capability_with_checkpoint_coverage(
challenge: Uuid,
server_epoch: Uuid,
proof: &[u8],
supports_tier_registry_generation: bool,
supports_checkpoint_coverage: bool,
) -> std::io::Result<()> {
verify_ns_scanner_capability_proof(
&get_shared_secret()?,
challenge,
server_epoch,
proof,
supports_tier_registry_generation,
supports_checkpoint_coverage,
)
verify_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, proof, supports_tier_registry_generation)
}
#[derive(Clone, Copy)]
@@ -1833,30 +1776,13 @@ mod tests {
let secret = "test-scanner-capability-secret";
let challenge = Uuid::new_v4();
let server_epoch = Uuid::new_v4();
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false, false)
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false)
.expect("capability proof should be generated");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, false).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, false, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof, false, false).is_err());
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof, false, false).is_err());
}
#[test]
fn namespace_scanner_capability_proof_binds_checkpoint_coverage_support() {
let secret = "checkpoint-capability-test-secret";
let challenge = Uuid::new_v4();
let server_epoch = Uuid::new_v4();
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, true)
.expect("checkpoint capability proof");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, true).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, true).is_err());
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, false)
.expect("legacy generation capability proof");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true, true).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &[], true, true).is_err());
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, true, true).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof, false).is_err());
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof, false).is_err());
}
#[test]
@@ -1864,14 +1790,14 @@ mod tests {
let secret = "test-scanner-capability-secret";
let challenge = Uuid::new_v4();
let server_epoch = Uuid::new_v4();
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, false)
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true)
.expect("generation capability proof should be generated");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, false).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, false).is_err());
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false, false)
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true).is_ok());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false).is_err());
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false)
.expect("legacy capability proof should be generated");
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true, false).is_err());
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true).is_err());
}
/// Security regression for GHSA-r5qv-rc46-hv8q (internode RPC fail-closed,
@@ -13,19 +13,18 @@
// limitations under the License.
use crate::cluster::rpc::{
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability_with_checkpoint_coverage,
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_capability,
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_capability,
};
use crate::disk::error::{Error, Result};
use crate::disk::{FileReader, FileWriter};
use crate::storage_api_contracts::internode::{
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CHECKPOINT_COVERAGE_QUERY,
NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY, NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY,
NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY, NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY,
NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY, NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, PUT_FILE_AUTH_V1,
PUT_FILE_CAPABILITY_CHALLENGE_QUERY, PUT_FILE_CAPABILITY_QUERY, PUT_FILE_CAPABILITY_VERSION, PUT_FILE_NONCE_QUERY,
PUT_FILE_SERVER_EPOCH_QUERY, PutFileCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY,
WALK_DIR_STREAM_COMPLETION_V1,
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY,
NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY, NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY,
NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY, NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY,
NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, PUT_FILE_AUTH_V1, PUT_FILE_CAPABILITY_CHALLENGE_QUERY,
PUT_FILE_CAPABILITY_QUERY, PUT_FILE_CAPABILITY_VERSION, PUT_FILE_NONCE_QUERY, PUT_FILE_SERVER_EPOCH_QUERY,
PutFileCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
};
use async_trait::async_trait;
use http::{HeaderMap, HeaderValue, Method, header::CONTENT_TYPE};
@@ -251,7 +250,6 @@ pub struct NsScannerStreamRequest {
pub struct NsScannerCapabilityRequest {
pub endpoint: String,
pub supports_tier_registry_generation: bool,
pub supports_checkpoint_coverage: bool,
}
/// Data-plane stream opener used by `RemoteDisk`.
@@ -291,7 +289,6 @@ pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
server_epoch,
proof: Vec::new(),
supports_tier_registry_generation: None,
supports_checkpoint_coverage: None,
})
}
// Interface facet nobody calls yet: every transport implements both, but no
@@ -382,18 +379,6 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
}
async fn probe_ns_scanner_capability(&self, request: NsScannerCapabilityRequest) -> Result<NsScannerCapabilityResponse> {
let request = if request.supports_checkpoint_coverage {
match self.probe_ns_scanner_capability_once(&request).await {
Ok(response) => return Ok(response),
Err(error) if ns_scanner_capability_error_allows_legacy(&error) => NsScannerCapabilityRequest {
supports_checkpoint_coverage: false,
..request
},
Err(error) => return Err(error),
}
} else {
request
};
if request.supports_tier_registry_generation {
return match self.probe_ns_scanner_capability_once(&request).await {
Ok(response) => Ok(response),
@@ -405,7 +390,6 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
let legacy_request = NsScannerCapabilityRequest {
endpoint: request.endpoint.clone(),
supports_tier_registry_generation: false,
supports_checkpoint_coverage: false,
};
match self.probe_ns_scanner_capability_once(&legacy_request).await {
Ok(mut response) => {
@@ -463,32 +447,18 @@ impl TcpHttpInternodeDataTransport {
if response.version != NS_SCANNER_PROTOCOL_VERSION || response.server_epoch.is_nil() {
return Err(Error::other("incompatible remote namespace scanner capability response"));
}
if let Err(err) = verify_ns_scanner_capability_with_checkpoint_coverage(
if let Err(err) = verify_ns_scanner_capability_with_tier_registry_generation(
challenge,
response.server_epoch,
&response.proof,
request.supports_tier_registry_generation,
request.supports_checkpoint_coverage,
) {
if request.supports_checkpoint_coverage
&& verify_ns_scanner_capability_with_tier_registry_generation(
challenge,
response.server_epoch,
&response.proof,
request.supports_tier_registry_generation,
)
.is_ok()
{
normalize_ns_scanner_capability_response(&mut response, request.supports_tier_registry_generation, false);
return Ok(response);
}
// A permissive older peer can ignore the additive marker and
// return a valid legacy-scope proof with HTTP 200. Accept that
// response only after independently authenticating the legacy
// scope; all other verification failures remain fail-closed.
if request.supports_tier_registry_generation && ns_scanner_capability_legacy_proof_is_valid(challenge, &response) {
response.supports_tier_registry_generation = None;
response.supports_checkpoint_coverage = None;
return Ok(response);
}
return Err(Error::other(format!("remote namespace scanner capability authentication failed: {err}")));
@@ -498,11 +468,7 @@ impl TcpHttpInternodeDataTransport {
// verified scope so an intermediary cannot strip or rewrite the field
// and force a silent downgrade after a successful generation-bound
// handshake.
normalize_ns_scanner_capability_response(
&mut response,
request.supports_tier_registry_generation,
request.supports_checkpoint_coverage,
);
normalize_ns_scanner_capability_response(&mut response, request.supports_tier_registry_generation);
Ok(response)
}
@@ -832,13 +798,8 @@ fn build_walk_dir_url(request: &WalkDirStreamRequest) -> String {
)
}
fn normalize_ns_scanner_capability_response(
response: &mut NsScannerCapabilityResponse,
requested_generation_support: bool,
requested_checkpoint_coverage: bool,
) {
fn normalize_ns_scanner_capability_response(response: &mut NsScannerCapabilityResponse, requested_generation_support: bool) {
response.supports_tier_registry_generation = requested_generation_support.then_some(true);
response.supports_checkpoint_coverage = requested_checkpoint_coverage.then_some(true);
}
fn ns_scanner_capability_legacy_proof_is_valid(challenge: Uuid, response: &NsScannerCapabilityResponse) -> bool {
@@ -871,7 +832,7 @@ fn build_ns_scanner_url(request: &NsScannerStreamRequest) -> String {
fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challenge: Uuid) -> String {
format!(
"{}{}?{}={}&{}={}{}{}",
"{}{}?{}={}&{}={}{}",
request.endpoint,
NS_SCANNER_PATH,
NS_SCANNER_PROTOCOL_VERSION_QUERY,
@@ -882,11 +843,6 @@ fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challen
format!("&{}=true", NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY)
} else {
String::new()
},
if request.supports_checkpoint_coverage {
format!("&{}=true", NS_SCANNER_CHECKPOINT_COVERAGE_QUERY)
} else {
String::new()
}
)
}
@@ -1045,7 +1001,6 @@ mod tests {
.probe_ns_scanner(NsScannerCapabilityRequest {
endpoint: "http://node1:9000".to_string(),
supports_tier_registry_generation: false,
supports_checkpoint_coverage: false,
})
.await
.expect_err("legacy transport should report namespace scanner as unsupported");
@@ -1072,97 +1027,6 @@ mod tests {
assert!(matches!(open_err, Error::MethodNotAllowed));
}
#[tokio::test]
async fn namespace_scanner_checkpoint_capability_authenticates_new_and_legacy_peers() {
crate::runtime::sources::ensure_test_rpc_secret();
for peer in ["current", "strict-old", "permissive-old", "invalid-proof", "missing-proof"] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("capability listener");
let address = listener.local_addr().expect("capability listener address");
let server_epoch = Uuid::new_v4();
let server = tokio::spawn(async move {
let requests = if peer == "strict-old" { 2 } else { 1 };
for index in 0..requests {
let (mut socket, _) = listener.accept().await.expect("capability request");
let mut bytes = Vec::new();
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let mut chunk = [0; 1024];
let read = socket.read(&mut chunk).await.expect("capability headers");
assert!(read > 0 && bytes.len() < 16 * 1024, "bounded complete capability headers");
bytes.extend_from_slice(&chunk[..read]);
}
let request = std::str::from_utf8(&bytes).expect("HTTP headers");
let target = request.split_whitespace().nth(1).expect("HTTP request target");
let url = reqwest::Url::parse(&format!("http://{address}{target}")).expect("capability URL");
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(query.get(NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY).map(String::as_str), Some("true"));
assert_eq!(query.contains_key(NS_SCANNER_CHECKPOINT_COVERAGE_QUERY), index == 0);
if peer == "strict-old" && index == 0 {
socket
.write_all(b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await
.expect("legacy parser rejection");
continue;
}
let challenge = query[NS_SCANNER_CAPABILITY_CHALLENGE_QUERY].parse().expect("challenge UUID");
let proof = match peer {
"invalid-proof" => vec![0; 32],
"missing-proof" => Vec::new(),
_ => crate::cluster::rpc::sign_ns_scanner_capability_with_checkpoint_coverage(
challenge,
server_epoch,
true,
peer == "current",
)
.expect("server capability proof"),
};
let body = rmp_serde::to_vec_named(&NsScannerCapabilityResponse {
version: NS_SCANNER_PROTOCOL_VERSION,
server_epoch,
proof,
supports_tier_registry_generation: Some(true),
// Strip a supported bit, or forge it for an older peer.
supports_checkpoint_coverage: (peer != "current").then_some(true),
})
.expect("capability response");
socket
.write_all(
format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", body.len()).as_bytes(),
)
.await
.expect("capability response headers");
socket.write_all(&body).await.expect("capability response body");
}
requests
});
let response = tokio::time::timeout(
Duration::from_secs(10),
TcpHttpInternodeDataTransport.probe_ns_scanner_capability(NsScannerCapabilityRequest {
endpoint: format!("http://{address}"),
supports_tier_registry_generation: true,
supports_checkpoint_coverage: true,
}),
)
.await
.expect("bounded capability probe");
if peer.ends_with("proof") {
assert!(
response
.expect_err("unverified capability must fail closed")
.to_string()
.contains("authentication failed")
);
} else {
let response = response.expect("authenticated capability");
assert_eq!(response.server_epoch, server_epoch);
assert_eq!(response.supports_tier_registry_generation, Some(true));
assert_eq!(response.supports_checkpoint_coverage, (peer == "current").then_some(true));
}
assert_eq!(server.await.expect("capability server"), if peer == "strict-old" { 2 } else { 1 });
}
}
#[tokio::test]
async fn walk_dir_restores_explicit_remote_missing_error() {
let _ = rustfs_credentials::set_global_rpc_secret("walk-dir-error-test-secret".to_string());
@@ -1905,7 +1769,6 @@ mod tests {
&NsScannerCapabilityRequest {
endpoint: "http://node1:9000".to_string(),
supports_tier_registry_generation: false,
supports_checkpoint_coverage: false,
},
challenge,
);
@@ -1925,7 +1788,6 @@ mod tests {
&NsScannerCapabilityRequest {
endpoint: "http://node1:9000".to_string(),
supports_tier_registry_generation: true,
supports_checkpoint_coverage: false,
},
challenge,
);
@@ -1964,14 +1826,13 @@ mod tests {
server_epoch: Uuid::new_v4(),
proof: Vec::new(),
supports_tier_registry_generation: None,
supports_checkpoint_coverage: None,
};
normalize_ns_scanner_capability_response(&mut response, true, true);
normalize_ns_scanner_capability_response(&mut response, true);
assert_eq!(response.supports_tier_registry_generation, Some(true));
response.supports_tier_registry_generation = Some(false);
normalize_ns_scanner_capability_response(&mut response, false, false);
normalize_ns_scanner_capability_response(&mut response, false);
assert_eq!(response.supports_tier_registry_generation, None);
}
@@ -1985,7 +1846,6 @@ mod tests {
proof: crate::cluster::rpc::sign_ns_scanner_capability(challenge, Uuid::new_v4())
.expect("placeholder proof should be generated"),
supports_tier_registry_generation: None,
supports_checkpoint_coverage: None,
};
// A proof bound to a different challenge cannot authorize the legacy
// fallback, even though the response has the expected shape.
+6 -7
View File
@@ -36,13 +36,12 @@ pub use http_auth::{
TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, gen_signature_headers,
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
set_tonic_mutation_body_digest, set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability,
verify_ns_scanner_capability_with_checkpoint_coverage, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability,
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer,
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
#[cfg(test)]
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
@@ -631,17 +631,6 @@ fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> R
Ok((version, epoch))
}
fn decode_mrf_lifecycle_reader_capability(expected_member: &str, result: &[u8]) -> Result<(u8, Uuid)> {
let (&maximum_format_version, identity) = result
.split_first()
.ok_or_else(|| Error::other("peer returned a truncated MRF lifecycle reader capability"))?;
if maximum_format_version == 0 || expected_member.trim().is_empty() {
return Err(Error::other("peer returned an invalid MRF lifecycle reader capability"));
}
let epoch = decode_remote_version_state_capability(expected_member, identity)?;
Ok((maximum_format_version, epoch))
}
#[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch {
pub events: Vec<u8>,
@@ -1975,16 +1964,6 @@ impl PeerRestClient {
Ok((self.topology_member.clone(), supported_version, epoch))
}
/// Reports one peer's reader support without authorizing a lifecycle writer or rollback.
pub async fn probe_mrf_lifecycle_reader(&self, topology_fingerprint: String) -> Result<(String, u8, Uuid)> {
let probe = rustfs_protos::mrf_lifecycle_reader_capability_probe(Uuid::new_v4().as_bytes());
let result = self
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
.await?;
let (maximum_format_version, epoch) = decode_mrf_lifecycle_reader_capability(&self.topology_member, &result)?;
Ok((self.topology_member.clone(), maximum_format_version, epoch))
}
pub async fn probe_ilm_recovery_export(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
let probe = rustfs_protos::ilm_recovery_export_capability_probe(Uuid::new_v4().as_bytes());
let result = self
@@ -4354,49 +4333,6 @@ mod tests {
}
}
#[test]
fn mrf_lifecycle_reader_capability_decoder_preserves_the_advertised_version() {
let epoch = Uuid::new_v4();
for version in [1, 2, u8::MAX] {
let result = rustfs_protos::encode_mrf_lifecycle_reader_capability(version, "node-a:9000", epoch.as_bytes())
.expect("capability frame");
assert_eq!(
decode_mrf_lifecycle_reader_capability("node-a:9000", &result).expect("reported capability"),
(version, epoch)
);
}
}
#[test]
fn mrf_lifecycle_reader_capability_decoder_rejects_unbound_or_malformed_identity() {
let epoch = Uuid::new_v4();
let valid =
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", epoch.as_bytes()).expect("capability frame");
let mut invalid = vec![Vec::new(), vec![2], valid[..valid.len() - 1].to_vec()];
let mut zero = valid.clone();
zero[0] = 0;
invalid.push(zero);
let mut trailing = valid.clone();
trailing.push(0);
invalid.push(trailing);
let mut length_overflow = valid.clone();
length_overflow[1..9].copy_from_slice(&u64::MAX.to_be_bytes());
invalid.push(length_overflow);
let mut invalid_utf8 = valid.clone();
invalid_utf8[9] = 0xff;
invalid.push(invalid_utf8);
invalid.push(
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", Uuid::nil().as_bytes())
.expect("nil epoch fixture"),
);
for result in invalid {
assert!(decode_mrf_lifecycle_reader_capability("node-a:9000", &result).is_err());
}
assert!(decode_mrf_lifecycle_reader_capability("node-b:9000", &valid).is_err());
let empty = rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "", epoch.as_bytes()).expect("empty member fixture");
assert!(decode_mrf_lifecycle_reader_capability("", &empty).is_err());
}
#[test]
fn remote_version_state_capability_decoder_fails_closed() {
let epoch = Uuid::new_v4();
@@ -417,10 +417,8 @@ impl S3PeerSys {
let errs = join_all(futures).await;
let mut pool_errs = Vec::new();
let mut all_pools_have_participants = true;
for pool_idx in 0..self.pools_count {
let per_pool_errs = pool_participant_errors(&self.clients, &errs, pool_idx);
all_pools_have_participants &= !per_pool_errs.is_empty();
pool_errs.push(reduce_pool_write_quorum_errs(&per_pool_errs));
}
@@ -453,17 +451,6 @@ impl S3PeerSys {
for pool_idx in 0..self.pools_count {
let per_pool_errs = pool_participant_errors(&self.clients, &errs, pool_idx);
if let Some(pool_err) = reduce_pool_write_quorum_errs(&per_pool_errs) {
// A success/busy split can miss quorum without a majority error.
// Keep that refusal retryable without masking other peer failures.
let pool_err = if pool_err == Error::ErasureWriteQuorum
&& all_pools_have_participants
&& per_pool_errs.iter().any(|err| err.as_ref() == Some(&Error::TooManyOpenFiles))
&& errs.iter().flatten().all(|err| *err == Error::TooManyOpenFiles)
{
Error::TooManyOpenFiles
} else {
pool_err
};
tracing::error!("heal_bucket per_pool_errs: {per_pool_errs:?}");
tracing::error!("heal_bucket reduce_write_quorum_errs: {pool_err}");
return Err(pool_err);
@@ -1823,7 +1810,6 @@ mod tests {
#[derive(Debug)]
struct TestPeerS3Client {
pools: Option<Vec<usize>>,
heal_bucket_result: Result<HealResultItem>,
make_bucket_result: Result<()>,
list_bucket_result: Result<Vec<BucketInfo>>,
delete_bucket_result: Result<()>,
@@ -1833,7 +1819,7 @@ mod tests {
#[async_trait]
impl PeerS3Client for TestPeerS3Client {
async fn heal_bucket(&self, _bucket: &str, _opts: &HealOpts) -> Result<HealResultItem> {
self.heal_bucket_result.clone()
unreachable!("not used by quorum tests")
}
async fn make_bucket(&self, _bucket: &str, _opts: &MakeBucketOptions) -> Result<()> {
@@ -1849,11 +1835,8 @@ mod tests {
self.delete_bucket_result.clone()
}
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
Ok(BucketInfo {
name: bucket.to_string(),
..Default::default()
})
async fn get_bucket_info(&self, _bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
unreachable!("not used by quorum tests")
}
fn get_pools(&self) -> Option<Vec<usize>> {
@@ -1865,17 +1848,6 @@ mod tests {
test_peer_with_make_bucket(pools, Ok(()))
}
fn test_peer_with_heal_bucket(pools: &[usize], heal_bucket_result: Result<HealResultItem>) -> Client {
Arc::new(Box::new(TestPeerS3Client {
pools: Some(pools.to_vec()),
heal_bucket_result,
make_bucket_result: Ok(()),
list_bucket_result: Ok(Vec::new()),
delete_bucket_result: Ok(()),
make_bucket_calls: Arc::new(AtomicUsize::new(0)),
}))
}
fn test_peer_with_make_bucket(pools: &[usize], make_bucket_result: Result<()>) -> Client {
test_peer_with_results(pools, make_bucket_result, Ok(Vec::new()))
}
@@ -1913,7 +1885,6 @@ mod tests {
) -> Client {
Arc::new(Box::new(TestPeerS3Client {
pools: Some(pools.to_vec()),
heal_bucket_result: Ok(HealResultItem::default()),
make_bucket_result,
list_bucket_result,
delete_bucket_result,
@@ -2663,119 +2634,6 @@ mod tests {
assert_eq!(err, Error::VolumeExists);
}
#[tokio::test]
async fn test_heal_bucket_preserves_busy_refusal_below_error_quorum() {
for busy_count in 2..=4 {
let peer_sys = S3PeerSys {
clients: (0..4)
.map(|peer| {
test_peer_with_heal_bucket(
&[0],
if peer < busy_count {
Err(Error::TooManyOpenFiles)
} else {
Ok(HealResultItem::default())
},
)
})
.collect(),
pools_count: 1,
};
let error = peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect_err("busy peers must not turn a failed heal into success");
assert_eq!(error, Error::TooManyOpenFiles, "busy peers: {busy_count}");
}
}
#[tokio::test]
async fn test_heal_bucket_busy_peer_preserves_success_quorum() {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer(&[0]),
test_peer(&[0]),
test_peer(&[0]),
],
pools_count: 1,
};
peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect("three successful peers still satisfy the four-peer write quorum");
}
#[tokio::test]
async fn test_heal_bucket_busy_peers_do_not_hide_other_failures() {
for failure in [Error::FaultyDisk, Error::VolumeNotFound, Error::ErasureWriteQuorum] {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[0], Err(failure.clone())),
test_peer(&[0]),
],
pools_count: 1,
};
let error = peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect_err("a mixed failure must still fail write quorum");
assert_eq!(error, Error::ErasureWriteQuorum, "non-busy failure: {failure}");
}
}
#[tokio::test]
async fn test_heal_bucket_busy_pool_does_not_hide_another_pool_failure() {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
test_peer(&[0]),
test_peer(&[0]),
test_peer_with_heal_bucket(&[1], Err(Error::FaultyDisk)),
],
pools_count: 2,
};
let error = peer_sys
.heal_bucket("bucket", &HealOpts::default())
.await
.expect_err("the busy split must not hide a non-busy failure from another pool");
assert_eq!(error, Error::ErasureWriteQuorum);
}
#[tokio::test]
async fn test_heal_bucket_missing_pool_participants_remains_quorum_failure() {
for occupied_pool in 0..2 {
let peer_sys = S3PeerSys {
clients: vec![
test_peer_with_heal_bucket(&[occupied_pool], Err(Error::TooManyOpenFiles)),
test_peer_with_heal_bucket(&[occupied_pool], Err(Error::TooManyOpenFiles)),
test_peer(&[occupied_pool]),
test_peer(&[occupied_pool]),
],
pools_count: 2,
};
let error = peer_sys
.heal_bucket(
"bucket",
&HealOpts {
recreate: true,
..Default::default()
},
)
.await
.expect_err("a pool without participants cannot be treated as a busy gate");
assert_eq!(error, Error::ErasureWriteQuorum, "pool with participants: {occupied_pool}");
}
}
#[test]
fn heal_bucket_mode_fails_closed_on_incomplete_topology() {
let mut opts = HealOpts::default();
File diff suppressed because it is too large Load Diff
@@ -322,19 +322,6 @@ where
with_read_version_diagnostic_context(context, boundary, future)
}
/// Capture before spawning without inventing an entry for a generic task.
#[cfg(feature = "diagnostic-read-version-caller")]
pub fn with_captured_read_version_context<F>(
boundary: ReadVersionContextBoundary,
future: F,
) -> impl std::future::Future<Output = F::Output>
where
F: std::future::Future,
{
let context = ReadVersionDiagnosticContext::capture();
with_read_version_diagnostic_context(context, boundary, Box::pin(future))
}
/// Observe a named entry even when its scope retains an earlier source.
#[cfg(feature = "diagnostic-read-version-caller")]
pub fn observe_read_version_context_boundary(boundary: ReadVersionContextBoundary) {
@@ -1350,65 +1337,4 @@ mod tests {
assert_eq!(caller, Some(ReadVersionRequestCaller::Peer));
assert_eq!(current_read_version_request_caller(), None);
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[tokio::test]
async fn captured_context_keeps_existing_values_without_creating_an_entry() {
use rustfs_io_metrics::s3_http_metrics::{
S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation, with_s3_http_operation_context,
};
let mut request = S3HttpRequestGuard::new("PUT");
let operation = request.in_scope(|| {
observe_s3_http_operation(rustfs_s3_ops::S3Operation::PutObject);
current_s3_http_operation_context().expect("captured PUT operation")
});
for inherited in [false, true] {
let make_future = || {
with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
tokio::task::yield_now().await;
(
current_read_version_request_caller(),
current_read_version_entry_source(),
current_s3_http_operation_name(),
)
})
};
let captured = if inherited {
with_s3_http_operation_context(Some(operation), async {
with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async { make_future() }).await
})
.await
})
.await
} else {
make_future()
};
assert_eq!(current_read_version_request_caller(), None);
assert_eq!(current_read_version_entry_source(), None);
let observed = tokio::spawn(captured).await.expect("captured child joins");
let expected = if inherited {
(
Some(ReadVersionRequestCaller::Object),
Some(ReadVersionEntrySource::TierConfigReload),
Some("s3:PutObject"),
)
} else {
(None, None, None)
};
assert_eq!(observed, expected);
assert_eq!(current_s3_http_operation_name(), None);
}
let polled = std::sync::atomic::AtomicBool::new(false);
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
let unpolled = with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
polled.store(true, std::sync::atomic::Ordering::Relaxed);
});
drop(unpolled);
})
.await;
assert!(!polled.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(current_read_version_entry_source(), None);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,327 +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::*;
const MINIO_CONFIG: &[u8] = include_bytes!("../../../tests/fixtures/server-config/minio-2025-09-07.json");
fn roundtrip(cfg: &Config, seed: &[u8]) -> (Config, Value) {
let encoded = encode_server_config_blob(cfg, Some(seed)).expect("encode config with seed");
let decoded = decode_server_config_blob(&encoded).expect("reload saved config");
let repeated = encode_server_config_blob(&decoded, Some(&encoded)).expect("encode reloaded config");
let encoded_value: Value = serde_json::from_slice(&encoded).unwrap();
let repeated_value: Value = serde_json::from_slice(&repeated).unwrap();
assert_eq!(encoded_value, repeated_value, "a second save must preserve JSON values");
(decoded, encoded_value)
}
#[test]
fn pinned_minio_config_preserves_unsupported_heal_settings_without_applying_them() {
assert!(
Config::unmarshal(MINIO_CONFIG).is_err(),
"the complete fixture exercises the external fallback"
);
let cfg = decode_server_config_blob(MINIO_CONFIG).expect("decode real MinIO config");
let heal = cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).unwrap_or_default();
for key in ["bitrotscan", "drive_workers", "max_io", "max_sleep"] {
assert!(heal.lookup(key).is_none(), "MinIO heal controls must not be mapped by name");
}
assert_eq!(
cfg.get_value(STORAGE_CLASS_SUB_SYS, DEFAULT_DELIMITER)
.unwrap()
.get("optimize"),
"availability"
);
let (_, saved) = roundtrip(&cfg, MINIO_CONFIG);
let original: Value = serde_json::from_slice(MINIO_CONFIG).unwrap();
assert_eq!(saved["heal"], original["heal"], "unsupported KVS entries survive unchanged");
assert_eq!(saved["cache"], original["cache"]);
}
#[test]
fn scalar_default_alias_reset_cannot_expose_shadow_overrides() {
for (subsystem, key, old, shadow, defaults) in [
(SCANNER_SUB_SYS, rustfs_config::SCANNER_CYCLE, "61", "59", &*scanner::DEFAULT_KVS),
(HEAL_SUB_SYS, rustfs_config::HEAL_BITROT_CYCLE, "off", "48h", &*heal::DEFAULT_KVS),
] {
let seed = serde_json::to_vec(&serde_json::json!({"version":"33", subsystem: {
"default": {key: old, "future":"preferred"},
"_": [{"key":key,"value":shadow,"future_attribute":"keep"}, {"key":"future","value":"shadow"}]
}}))
.unwrap();
let mut cfg = decode_server_config_blob(&seed).unwrap();
cfg.0.remove(subsystem);
let (decoded, saved) = roundtrip(&cfg, &seed);
assert_eq!(
decoded
.get_value(subsystem, DEFAULT_DELIMITER)
.unwrap_or_default()
.lookup(key)
.unwrap_or_else(|| defaults.get(key)),
defaults.get(key)
);
assert_eq!(saved[subsystem]["default"]["future"], "preferred");
assert_eq!(saved[subsystem]["_"][0]["value"], defaults.get(key));
assert_eq!(saved[subsystem]["_"][0]["future_attribute"], "keep");
assert_eq!(saved[subsystem]["_"][1]["value"], "shadow");
}
}
#[test]
fn root_aliases_keep_unknown_data_and_sync_known_values() {
let seed = br#"{"version":"33",
"storageclass":{"standard":"EC:2","future":"canonical"},
"storage_class":{"_":[{"key":"standard","value":"EC:4","extra":"keep"},{"key":"future","value":"native"}]},
"openid":{"default":{"config_url":"https://id.example/new","client_id":"new","future":"canonical"}},
"identity_openid":{"_":{"config_url":"https://id.example/old","client_id":"old","future":"alias"}},
"audit":{"webhook":{"default":{"enable":true,"endpoint":"https://audit.example/new","future":"audit"}}},
"logger":{"audit_webhook":{"_":{"enable":true,"endpoint":"https://audit.example/old","future":"logger"}}}
}"#;
let cfg = decode_server_config_blob(seed).unwrap();
let (decoded, saved) = roundtrip(&cfg, seed);
assert!(configs_semantically_equal(&cfg, &decoded));
assert_eq!(saved["storageclass"]["future"], "canonical");
assert_eq!(saved["storage_class"]["_"][0]["value"], "EC:2");
assert_eq!(saved["storage_class"]["_"][0]["extra"], "keep");
assert_eq!(saved["storage_class"]["_"][1]["value"], "native");
assert_eq!(saved["openid"]["default"]["future"], "canonical");
assert_eq!(saved["identity_openid"]["_"]["future"], "alias");
assert_eq!(saved["identity_openid"]["_"]["client_id"], "new");
assert_eq!(saved["audit"]["webhook"]["default"]["future"], "audit");
assert_eq!(saved["logger"]["audit_webhook"]["_"]["future"], "logger");
assert_eq!(saved["logger"]["audit_webhook"]["_"]["endpoint"], "https://audit.example/new");
}
#[test]
fn storageclass_nested_alias_reset_clears_inline_override_and_retains_attributes() {
let seed = br#"{"version":"33","storageclass":{"standard":"EC:2","_": [
{"key":"inline_block","value":"64KiB","extra":{"owner":"future"}},
{"key":"future","value":"keep"}]},
"storage_class":{"default":{"standard":"EC:4","future":"alias"}}}"#;
let (decoded, saved) = roundtrip(&Config::new(), seed);
assert_eq!(
decoded
.get_value(STORAGE_CLASS_SUB_SYS, DEFAULT_DELIMITER)
.unwrap()
.get("inline_block"),
storageclass::DEFAULT_KVS.get("inline_block")
);
assert_eq!(saved["storageclass"]["_"][0]["extra"]["owner"], "future");
assert_eq!(saved["storageclass"]["_"][1]["value"], "keep");
assert_eq!(saved["storage_class"]["default"]["future"], "alias");
}
#[test]
fn duplicate_known_kvs_entries_are_updated_consistently() {
let seed = br#"{"version":"33","scanner":[
{"key":"cycle","value":"61","extra":"first"},
{"key":"cycle","value":"62","extra":"last"}]}"#;
let mut cfg = decode_server_config_blob(seed).unwrap();
cfg.0
.get_mut(SCANNER_SUB_SYS)
.unwrap()
.get_mut(DEFAULT_DELIMITER)
.unwrap()
.insert("cycle".to_string(), "73".to_string());
let (decoded, saved) = roundtrip(&cfg, seed);
assert_eq!(decoded.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER).unwrap().get("cycle"), "73");
for entry in saved["scanner"].as_array().unwrap() {
assert_eq!(entry["value"], "73");
}
}
#[test]
fn removed_target_with_kvs_attributes_stays_disabled_under_enabled_default() {
let seed = br#"{"version":"33","notify":{"webhook":{
"default":{"enable":true,"endpoint":"https://notify.example/default"},
"removed":[{"key":"endpoint","value":"https://notify.example/old","extra":"keep"}]
}}}"#;
let mut cfg = decode_server_config_blob(seed).unwrap();
cfg.0
.get_mut(rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS)
.unwrap()
.remove("removed");
let (decoded, saved) = roundtrip(&cfg, seed);
let removed = decoded
.get_value(rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS, "removed")
.unwrap();
assert_eq!(removed.get(ENABLE_KEY), "off");
assert_eq!(saved["notify"]["webhook"]["removed"][0]["extra"], "keep");
assert_eq!(saved["notify"]["webhook"]["removed"][0]["value"], "");
}
#[test]
fn empty_preferred_aliases_keep_presence_precedence_but_accept_new_settings() {
let seed = br#"{"version":"33","openid":null,"identity_openid":{"default":{"config_url":"https://old.example"}},
"audit":null,"logger":{"webhook":{"default":{"enable":true,"endpoint":"https://old.example"}}},
"scanner":{"default":null,"_":{"cycle":"59","future":"keep"}}}"#;
let cfg = decode_server_config_blob(seed).unwrap();
assert!(build_oidc_object(&cfg).is_empty());
assert!(build_audit_object(&cfg).is_empty());
assert!(build_scalar_config_object(&cfg, scanner_config_descriptor()).is_empty());
let (_, reset) = roundtrip(&cfg, seed);
assert!(reset["openid"].is_null(), "the preferred null alias must keep shadow config masked");
assert!(reset["identity_openid"]["default"].get("config_url").is_none());
let reset_cfg = decode_server_config_blob(&serde_json::to_vec(&reset).unwrap()).unwrap();
assert!(
build_oidc_object(&reset_cfg).is_empty(),
"the old shadow provider must stay unapplied after save"
);
let updated = decode_server_config_blob(br#"{"version":"33","openid":{"default":{"config_url":"https://new.example"}},"audit":{"webhook":{"default":{"enable":true,"endpoint":"https://new.example"}}},"scanner":{"cycle":"73"}}"#).unwrap();
let (decoded, _) = roundtrip(&updated, seed);
assert!(configs_semantically_equal(&updated, &decoded));
}
#[test]
fn storageclass_null_precedence_is_materialized_before_alias_resave() {
let seed = br#"{"version":"33","storageclass":null,"storage_class":{"standard":"EC:4","rrs":"EC:2","optimize":"capacity"}}"#;
let cfg = decode_server_config_blob(seed).unwrap();
let (_, saved) = roundtrip(&cfg, seed);
assert_eq!(saved["storageclass"]["standard"], "");
assert_eq!(saved["storageclass"]["rrs"], "");
assert_eq!(saved["storage_class"]["standard"], "");
assert_eq!(saved["storage_class"]["rrs"], "");
assert_eq!(saved["storage_class"]["optimize"], "availability");
}
#[derive(Clone, Default)]
struct LogCapture(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for LogCapture {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
type Writer = Self;
fn make_writer(&'a self) -> Self {
self.clone()
}
}
fn captured_logs(run: impl FnOnce(&tracing::Dispatch)) -> String {
let capture = LogCapture::default();
let dispatch = tracing::Dispatch::new(
tracing_subscriber::fmt()
.without_time()
.with_ansi(false)
.with_writer(capture.clone())
.finish(),
);
tracing::dispatcher::with_default(&dispatch, || run(&dispatch));
String::from_utf8(capture.0.lock().unwrap().clone()).unwrap()
}
#[test]
fn compatibility_reports_are_bounded_summaries_and_never_log_untrusted_payloads() {
let make_blob = |prefix: &str, keys: usize| {
let mut settings = Map::new();
for i in 0..keys {
settings.insert(format!("SECRET_KEY_{prefix}_{i}"), Value::String("SECRET_VALUE".to_string()));
}
serde_json::to_vec(&serde_json::json!({"version":"33", "heal":settings, "scanner":"SECRET_LEGACY_PAYLOAD"})).unwrap()
};
let a = make_blob("a", 1000);
let b = make_blob("b", 65);
let logs = captured_logs(|dispatch| {
for _ in 0..100 {
decode_server_config_blob(&a).unwrap();
}
decode_server_config_blob(&b).unwrap();
decode_server_config_blob(&a).unwrap();
let barrier = std::sync::Barrier::new(12);
let concurrent = make_blob("concurrent", 80);
std::thread::scope(|scope| {
for _ in 0..12 {
let (barrier, concurrent) = (&barrier, &concurrent);
scope.spawn(move || {
tracing::dispatcher::with_default(dispatch, || {
barrier.wait();
decode_server_config_blob(concurrent).unwrap();
})
});
}
});
});
assert_eq!(
logs.matches(EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED).count(),
3,
"one summary per diagnostic set, including concurrent readers: {logs}"
);
assert!(logs.contains("unknown_key_count=1000"));
assert!(!logs.contains("SECRET"));
assert!(logs.len() < 2000, "log volume must not grow with unknown-key count");
}
#[test]
fn malformed_known_values_still_fail_without_a_successful_compatibility_warning() {
let logs = captured_logs(|_| {
for seed in [
br#"{"version":"33","heal":{"future":"secret","bitrot_cycle":null}}"#.as_slice(),
br#"{"version":"33","scanner":[{"key":"cycle","value":[]}]}"#.as_slice(),
br#"{"version":"33","scanner":[false]}"#.as_slice(),
br#"{"scanner":null,"heal":"SECRET"}"#.as_slice(),
] {
assert!(decode_server_config_blob(seed).is_err());
}
});
assert!(!logs.contains(EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED));
assert!(!logs.contains("secret"));
}
#[test]
#[serial_test::serial]
fn final_decode_failures_are_deduplicated_without_changing_error_classification() {
let previous = replace_server_config_decrypt_fn_for_test(None);
let logs = captured_logs(|_| {
for _ in 0..100 {
let err = decode_persisted_server_config(br#"{SECRET_CORRUPT_A"#).unwrap_err();
assert!(is_server_config_corrupt_error(&err));
}
assert!(decode_persisted_server_config(br#"{SECRET_CORRUPT_B"#).is_err());
assert!(decode_persisted_server_config(br#"{SECRET_CORRUPT_A"#).is_err());
replace_server_config_decrypt_fn_for_test(Some(std::sync::Arc::new(|_| None)));
for _ in 0..100 {
let err = decode_persisted_server_config(b"SECRET_CIPHERTEXT").unwrap_err();
assert!(is_server_config_decrypt_error(&err));
}
});
replace_server_config_decrypt_fn_for_test(previous);
assert_eq!(logs.matches(EVENT_SERVER_CONFIG_DECODE_FAILED).count(), 2);
assert_eq!(logs.matches(EVENT_SERVER_CONFIG_DECRYPT_FAILED).count(), 1);
assert!(!logs.contains("SECRET"));
}
#[test]
#[serial_test::serial]
fn pinned_minio_plaintext_exercises_the_legacy_decrypt_entry_without_false_errors() {
// The fixture is plaintext from MinIO; this envelope is produced by RustFS.
let encrypted = rustfs_crypto::encrypt_data(b"fixture-test-key", MINIO_CONFIG).unwrap();
let previous = replace_server_config_decrypt_fn_for_test(Some(std::sync::Arc::new(|data| {
rustfs_crypto::decrypt_data(b"fixture-test-key", data).ok()
})));
let logs = captured_logs(|_| {
let (cfg, seed) = decode_persisted_server_config_with_seed(&encrypted).unwrap();
assert_eq!(seed, MINIO_CONFIG);
roundtrip(&cfg, &seed);
});
replace_server_config_decrypt_fn_for_test(previous);
assert!(!logs.contains(EVENT_SERVER_CONFIG_DECODE_FAILED));
assert!(!logs.contains(EVENT_SERVER_CONFIG_DECRYPT_FAILED));
}
@@ -1,206 +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 std::collections::VecDeque;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use serde_json::Value;
use tracing::{error, warn};
use super::{
EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED, EVENT_SERVER_CONFIG_DECODE_FAILED, EVENT_SERVER_CONFIG_DECRYPT_FAILED,
HEAL_SUB_SYS, LOG_COMPONENT_CONFIG, LOG_SUBSYSTEM_CONFIG,
};
const RECENT_REPORTS: usize = 64;
const FAILURE_REPORT_INTERVAL: Duration = Duration::from_secs(300);
#[derive(Default)]
struct Reports(VecDeque<Report>);
struct Report {
key: u64,
reported: Instant,
suppressed: u64,
}
impl Reports {
fn observe(&mut self, key: u64, now: Instant, repeat: Option<Duration>) -> Option<u64> {
if let Some(index) = self.0.iter().position(|report| report.key == key) {
let mut report = self.0.remove(index)?;
let emit = repeat.is_some_and(|interval| now.saturating_duration_since(report.reported) >= interval);
let suppressed = report.suppressed;
if emit {
report.reported = now;
report.suppressed = 0;
} else {
report.suppressed = report.suppressed.saturating_add(1);
}
self.0.push_back(report);
return emit.then_some(suppressed);
}
if self.0.len() == RECENT_REPORTS {
self.0.pop_front();
}
self.0.push_back(Report {
key,
reported: now,
suppressed: 0,
});
Some(0)
}
}
static COMPATIBILITY_REPORTS: LazyLock<Mutex<Reports>> = LazyLock::new(Mutex::default);
static FAILURE_REPORTS: LazyLock<Mutex<Reports>> = LazyLock::new(Mutex::default);
fn observe(reports: &Mutex<Reports>, key: u64, repeat: Option<Duration>) -> Option<u64> {
// Never hold this process-local diagnostics lock across tracing or I/O.
reports
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.observe(key, Instant::now(), repeat)
}
#[derive(Default)]
pub(super) struct CompatibilityDiagnostics {
digest: DefaultHasher,
unknown_keys: usize,
legacy_shapes: usize,
legacy_heal_keys: u8,
shape_kinds: u8,
}
const LEGACY_HEAL_KEYS: [&str; 4] = ["bitrotscan", "drive_workers", "max_io", "max_sleep"];
const SHAPE_KINDS: [&str; 6] = ["null", "boolean", "number", "string", "array", "object"];
fn shape(value: &Value) -> usize {
match value {
Value::Null => 0,
Value::Bool(_) => 1,
Value::Number(_) => 2,
Value::String(_) => 3,
Value::Array(_) => 4,
Value::Object(_) => 5,
}
}
impl CompatibilityDiagnostics {
pub(super) fn unknown_key(&mut self, subsystem: &str, key: &str) {
("unknown_key", subsystem, key).hash(&mut self.digest);
self.unknown_keys += 1;
if subsystem == HEAL_SUB_SYS
&& let Some(index) = LEGACY_HEAL_KEYS.iter().position(|known| *known == key)
{
self.legacy_heal_keys |= 1 << index;
}
}
pub(super) fn legacy_shape(&mut self, subsystem: &str, value: &Value) {
let kind = shape(value);
("legacy_shape", subsystem, kind).hash(&mut self.digest);
self.legacy_shapes += 1;
self.shape_kinds |= 1 << kind;
}
pub(super) fn report(&self) {
if self.unknown_keys == 0 && self.legacy_shapes == 0 {
return;
}
if observe(&COMPATIBILITY_REPORTS, self.digest.finish(), None).is_none() {
return;
}
let legacy_heal_keys = LEGACY_HEAL_KEYS
.iter()
.enumerate()
.filter_map(|(index, key)| (self.legacy_heal_keys & (1 << index) != 0).then_some(*key))
.collect::<Vec<_>>();
let legacy_shape_kinds = SHAPE_KINDS
.iter()
.enumerate()
.filter_map(|(index, kind)| (self.shape_kinds & (1 << index) != 0).then_some(*kind))
.collect::<Vec<_>>();
warn!(
event = EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED,
component = LOG_COMPONENT_CONFIG,
subsystem = LOG_SUBSYSTEM_CONFIG,
state = "ignored",
unknown_key_count = self.unknown_keys,
legacy_shape_count = self.legacy_shapes,
?legacy_heal_keys,
?legacy_shape_kinds,
"Persisted server config contains unsupported settings; unknown fields are retained but not applied"
);
}
}
#[derive(Hash)]
pub(super) enum DecodeFailure {
Corrupt,
Decrypt,
}
pub(super) fn report_decode_failure(data: &[u8], failure: DecodeFailure, encrypted_size: Option<usize>) {
let mut digest = DefaultHasher::new();
failure.hash(&mut digest);
data.hash(&mut digest);
let Some(suppressed_attempts) = observe(&FAILURE_REPORTS, digest.finish(), Some(FAILURE_REPORT_INTERVAL)) else {
return;
};
let event = match failure {
DecodeFailure::Corrupt => EVENT_SERVER_CONFIG_DECODE_FAILED,
DecodeFailure::Decrypt => EVENT_SERVER_CONFIG_DECRYPT_FAILED,
};
error!(
event,
component = LOG_COMPONENT_CONFIG,
subsystem = LOG_SUBSYSTEM_CONFIG,
state = "failed",
size = data.len(),
encrypted_size,
suppressed_attempts,
"Persisted server config could not be decoded; the read remains failed"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_keep_history_without_confusing_keys_with_versions() {
let mut reports = Reports::default();
let now = Instant::now();
assert_eq!(reports.observe(1, now, None), Some(0));
assert_eq!(reports.observe(2, now, None), Some(0));
assert_eq!(reports.observe(1, now, None), None);
for key in 3..=65 {
assert_eq!(reports.observe(key, now, None), Some(0));
}
assert_eq!(reports.0.len(), RECENT_REPORTS);
assert_eq!(reports.observe(2, now, None), Some(0), "only the least recently used entry is evicted");
}
#[test]
fn failures_report_at_the_interval_boundary_and_count_suppressed_reads() {
let mut reports = Reports::default();
let now = Instant::now();
assert_eq!(reports.observe(1, now, Some(FAILURE_REPORT_INTERVAL)), Some(0));
assert_eq!(reports.observe(1, now + Duration::from_secs(299), Some(FAILURE_REPORT_INTERVAL)), None);
assert_eq!(reports.observe(1, now + FAILURE_REPORT_INTERVAL, Some(FAILURE_REPORT_INTERVAL)), Some(1));
assert_eq!(reports.observe(2, now + FAILURE_REPORT_INTERVAL, Some(FAILURE_REPORT_INTERVAL)), Some(0));
}
}
+5 -11
View File
@@ -17,13 +17,12 @@ use rustfs_config::{
DEFAULT_SCANNER_BITROT_CYCLE_SECS, DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES,
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_DISK_IO_BUDGET,
DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS,
DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS, DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK,
DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS, SCANNER_ALERT_EXCESS_VERSION_SIZE,
SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS,
SCANNER_ALERT_EXCESS_VERSION_SIZE, SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC, SCANNER_DISK_IO_BUDGET_IOPS, SCANNER_IDLE_MODE, SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_RECOVERY_BACKOFF_CAP, SCANNER_SPEED, SCANNER_START_DELAY,
SCANNER_WALKERS_PER_DISK, SCANNER_YIELD_EVERY_N_OBJECTS,
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED, SCANNER_START_DELAY, SCANNER_WALKERS_PER_DISK,
SCANNER_YIELD_EVERY_N_OBJECTS,
};
use std::sync::LazyLock;
@@ -84,11 +83,6 @@ pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
value: DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS.to_string(),
hidden_if_empty: false,
},
KV {
key: SCANNER_RECOVERY_BACKOFF_CAP.to_owned(),
value: DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS.to_string(),
hidden_if_empty: false,
},
KV {
key: SCANNER_MAX_CONCURRENT_SET_SCANS.to_owned(),
value: DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS.to_string(),
@@ -1,630 +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 crate::disk::error::DiskError;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
use std::path::PathBuf;
use tokio::fs;
struct PhysicalFixture {
_directories: Vec<tempfile::TempDir>,
store: Arc<ECStore>,
canonical: Vec<u8>,
pending: Vec<u8>,
identity: Vec<u8>,
revision: PoolMetaRevision,
}
fn object_path(disk: &DiskStore) -> PathBuf {
disk.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_NAME)
.expect("physical fixture must use local disks")
.expect("pool metadata path should resolve")
}
async fn disks(store: &ECStore) -> Vec<Vec<DiskStore>> {
let mut all = Vec::with_capacity(store.pools.len());
for pool in &store.pools {
let set = pool.get_disks_by_key(POOL_META_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("physical fixture must keep every disk online"))
.collect::<Vec<_>>();
assert_eq!(disks.len(), 4, "fixture requires an EC 2+2 set");
all.push(disks);
}
all
}
async fn owner(store: &ECStore) -> Arc<rustfs_lock::NamespaceLockGuard> {
let lock = store.pools[0]
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("real namespace lock should be created");
Arc::new(
lock.get_write_lock(get_lock_acquire_timeout())
.await
.expect("physical recovery must hold the namespace writer"),
)
}
fn clean_state(fixture: &PhysicalFixture) -> PoolMetaWriteState {
PoolMetaWriteState::for_startup(fixture.revision.cluster_id.expect("fixture V3 revision requires identity"), false)
}
async fn snapshots(store: &ECStore) -> Vec<Vec<PoolMetaNativeInspection>> {
let mut all = Vec::new();
for pool in disks(store).await {
let mut current = Vec::new();
for disk in pool {
match disk
.pool_meta_native_recovery(Request::Inspect {
object: PoolMetaNativeObject::PoolBin,
})
.await
.expect("physical native inspection should succeed")
{
Response::Inspected(inspection) => current.push(inspection),
_ => panic!("inspection must return a disk snapshot"),
}
}
all.push(current);
}
all
}
async fn fixture(split_pools: usize) -> PhysicalFixture {
let (directories, store, _other_store) =
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
persist_v3_pool_meta_for_test(&store).await;
let previous = load_pool_meta_replicas(store.pools.clone(), true)
.await
.expect("baseline V3 pool metadata must be ordinary-readable");
let revision = PoolMetaRevision {
generation: previous
.revision
.generation
.checked_add(1)
.expect("test generation should increment"),
transaction_id: Some(uuid::Uuid::new_v4()),
..previous.revision
};
let candidate = PoolMetaCommittedCandidate {
canonical: previous.canonical.expect("baseline must have a canonical V3 COMMIT"),
meta: previous.meta.clone(),
revision: previous.revision,
};
let canonical = encode_pool_meta_v3_envelope(&previous.meta, revision, true, None).expect("next COMMIT should encode");
let pending =
encode_pool_meta_v3_envelope(&previous.meta, revision, false, Some(&candidate)).expect("next PREPARE should encode");
let identity = load_pool_meta_identity(
store.pools.clone(),
revision
.cluster_id
.expect("baseline V3 metadata should have a cluster identity"),
)
.await
.expect("real initialized identities must agree")
.identity
.expect("real fixture identity must be present");
let identity = encode_pool_meta_identity(identity).expect("real fixture identity should encode canonically");
let modified = OffsetDateTime::now_utc();
let split = split(&pending, &canonical, modified).await;
let commit = replicas(&canonical, modified + Duration::seconds(1)).await;
let prepared = replicas(&pending, modified).await;
let directory = uuid::Uuid::new_v4();
for (pool_index, pool) in disks(&store).await.into_iter().enumerate() {
let heads = if pool_index < split_pools { &split } else { &commit };
for (disk_index, disk) in pool.into_iter().enumerate() {
let object = object_path(&disk);
fs::remove_dir_all(&object)
.await
.expect("fault injection should remove only its own pool.bin fixture");
fs::create_dir_all(object.join(directory.to_string()))
.await
.expect("fixture rollback directory should be created");
fs::write(object.join("xl.meta"), &heads[disk_index])
.await
.expect("physical split head should be installed");
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &prepared[disk_index])
.await
.expect("physical PREPARE backup should be installed");
}
}
PhysicalFixture {
_directories: directories,
store,
canonical,
pending,
identity,
revision,
}
}
async fn assert_committed(fixture: &PhysicalFixture) {
let confirmed = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("ordinary full-pool read must succeed after physical recovery");
assert_eq!(confirmed.canonical.as_deref(), Some(fixture.canonical.as_slice()));
assert_eq!(confirmed.revision, fixture.revision);
assert!(!confirmed.replica_state.needs_repair);
assert!(confirmed.replica_state.repair_write_safe);
for pool in snapshots(&fixture.store).await {
let raw = pool
.iter()
.map(|disk| disk.current.clone().expect("every recovered physical head must exist"))
.collect::<Vec<_>>();
assert_eq!(
codec::decode_inline_pool_meta_replicas(&raw)
.await
.expect("every recovered shard must verify"),
vec![Bytes::copy_from_slice(&fixture.canonical)]
);
}
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_split_repair_completes_normal_cas_and_quorum_readback() {
let fixture = fixture(1).await;
let before = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(before[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(matches!(before[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("healthy ordinary COMMIT must authorize physical repair")
);
state
.ensure_write_safe("physical recovery readback")
.expect("completed recovery must disarm its transaction");
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_split_without_ordinary_commit_performs_no_mutation() {
let fixture = fixture(3).await;
let before = snapshots(&fixture.store).await;
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
assert!(
repair(&fixture.store, &mut state, &fence).await.is_err(),
"reconstruction alone must never authorize a COMMIT"
);
assert_eq!(
snapshots(&fixture.store).await,
before,
"all current heads, identities and complete backups must remain unchanged"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
}
struct IdentityEpochFork {
path: PathBuf,
original: Bytes,
replacement: Bytes,
original_identity: PersistedPoolMetaIdentity,
}
async fn identity_epoch_fork(fixture: &PhysicalFixture) -> IdentityEpochFork {
let identity_set = fixture.store.pools[0].get_disks_by_key(POOL_META_IDENTITY_NAME);
assert!(
!Arc::ptr_eq(&identity_set, &fixture.store.pools[0].get_disks_by_key(POOL_META_NAME)),
"the identity evidence must come from its independently placed physical set"
);
let identity_disks = identity_set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("actual identity set must retain all four disks"))
.collect::<Vec<_>>();
let mut raw = Vec::with_capacity(identity_disks.len());
for disk in &identity_disks {
match disk
.pool_meta_native_recovery(Request::InspectIdentity {})
.await
.expect("actual identity set must support native inspection")
{
Response::IdentityInspected { disk_id, raw: current } => {
assert!(!disk_id.is_nil());
raw.push(current);
}
_ => panic!("identity inspection must return its physical raw shard"),
}
}
assert_eq!(raw.len(), 4);
let original_identity = match decode_pool_meta_identity(&fixture.identity) {
PoolMetaIdentityReplica::Valid(identity) => identity,
_ => panic!("fixture must provide a valid initialized identity"),
};
let forked_identity = PersistedPoolMetaIdentity {
epoch: original_identity
.epoch
.checked_add(1)
.expect("test identity epoch should increment"),
..original_identity
};
let forked = encode_pool_meta_identity(forked_identity).expect("foreign epoch identity should remain validly encoded");
assert!(matches!(decode_pool_meta_identity(&forked), PoolMetaIdentityReplica::Valid(_)));
let modified = raw
.iter()
.map(|raw| {
codec::parse_inline_info(raw)
.expect("actual identity shard layout should parse")
.mod_time
})
.collect::<Option<Vec<_>>>()
.expect("every actual identity shard should have a modification time")
.into_iter()
.max()
.expect("actual identity set should contain four shards")
+ Duration::nanoseconds(1);
let replacements = codec::encode_inline_pool_meta_replicas(&raw, &forked, modified)
.await
.expect("epoch fork must retain the actual set's valid erasure layout");
assert_eq!(
codec::parse_inline_info(&raw[0]).expect("original layout").erasure,
codec::parse_inline_info(&replacements[0])
.expect("replacement layout")
.erasure
);
codec::validate_inline_pool_meta_shard(&replacements[0], 0, &forked)
.await
.expect("forked shard must pass the physical integrity checks");
let identity_path = identity_disks[0]
.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_IDENTITY_NAME)
.expect("identity fixture must use local storage")
.expect("actual identity metadata path should resolve");
IdentityEpochFork {
path: identity_path.join("xl.meta"),
original: raw[0].clone(),
replacement: replacements[0].clone(),
original_identity,
}
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_identity_actual_set_minority_epoch_fork_performs_no_apply() {
let fixture = fixture(1).await;
let fork = identity_epoch_fork(&fixture).await;
let heads = |pools: Vec<Vec<PoolMetaNativeInspection>>| {
pools
.into_iter()
.map(|pool| pool.into_iter().map(|disk| (disk.current, disk.backups)).collect::<Vec<_>>())
.collect::<Vec<_>>()
};
let before = heads(snapshots(&fixture.store).await);
fs::write(&fork.path, &fork.replacement)
.await
.expect("only one actual identity-set disk should receive the epoch fork");
let selected = load_pool_meta_identity(fixture.store.pools.clone(), fork.original_identity.cluster_id)
.await
.expect("three matching identity shards must remain ordinary-readable");
assert_eq!(selected.identity, Some(fork.original_identity));
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(reads[0].replica, PoolMetaReplica::Unreadable(_)));
assert!(matches!(reads[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
let mut state = clean_state(&fixture);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("ordinary identity quorum must not hide a physical minority epoch fork");
assert_eq!(
error
.pool_metadata_failure()
.expect("known identity fork must retain typed failure")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert!(state.write_blocked, "known minority fork must latch the write gate");
assert_eq!(
heads(snapshots(&fixture.store).await),
before,
"identity rejection must precede every pool.bin Apply"
);
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
fs::write(&fork.path, &fork.original)
.await
.expect("restoring the input identity must not clear the observed fork latch");
let error = repair(&fixture.store, &mut state, &fence)
.await
.expect_err("the same state must retain operator recovery after identity becomes consistent");
assert_eq!(
error
.pool_metadata_failure()
.expect("sticky failure must retain its classification")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert!(state.write_blocked);
assert_eq!(heads(snapshots(&fixture.store).await), before);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gate() {
let fixture = fixture(1).await;
let fork = identity_epoch_fork(&fixture).await;
let since = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed original failure timestamp should be valid");
{
let mut state = fixture.store.pool_meta_save_gate.lock().await;
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
state.write_blocked = false;
state.block_context = None;
state.aborted_transaction.store(true, Ordering::SeqCst);
let mut failure = pool_metadata_error(crate::error::PoolMetadataFailure::TransactionUnknown, "commit_cas", None);
failure.since = since;
*state
.transaction_failure
.lock()
.expect("original transaction failure mutex should lock") = Some(failure);
*state
.block_started_at
.lock()
.expect("original block timestamp mutex should lock") = Some(since);
}
let pause = Arc::new(NativeApplyPause {
reached: tokio::sync::Notify::new(),
resume: tokio::sync::Notify::new(),
});
let reached = pause.reached.notified();
tokio::pin!(reached);
reached.as_mut().enable();
let recovery = tokio::spawn({
let store = Arc::clone(&fixture.store);
let pause = Arc::clone(&pause);
async move {
AFTER_NATIVE_APPLY
.scope(pause, async move { store.recover_pool_meta_transaction().await })
.await
}
});
tokio::time::timeout(std::time::Duration::from_secs(10), reached)
.await
.expect("real runtime recovery should pause after completing physical Apply");
let readable = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("physical Apply must already make every pool ordinary-readable");
assert_eq!(readable.canonical.as_deref(), Some(fixture.canonical.as_slice()));
fs::write(&fork.path, &fork.replacement)
.await
.expect("a valid minority identity fork should arrive after physical repair");
pause.resume.notify_one();
let error = tokio::time::timeout(std::time::Duration::from_secs(10), recovery)
.await
.expect("resumed runtime recovery should finish its validation")
.expect("runtime recovery task should not panic")
.expect_err("post-Apply identity fork must reject runtime recovery");
assert_eq!(
error
.pool_metadata_failure()
.expect("runtime fork failure must remain typed")
.kind,
crate::error::PoolMetadataFailure::RecoveryRequired
);
assert_committed(&fixture).await;
{
let state = fixture.store.pool_meta_save_gate.lock().await;
assert!(
state.write_blocked,
"candidate validation must propagate its permanent latch to the original state"
);
let context = state
.block_context
.as_ref()
.expect("original state must retain the known fork context");
assert_eq!(context.kind, crate::error::PoolMetadataFailure::RecoveryRequired);
assert_eq!(context.since, since, "candidate failure must preserve the original outage timestamp");
assert_eq!(*state.block_started_at.lock().expect("original block timestamp should lock"), Some(since));
}
assert!(
!fixture
.store
.recover_pool_meta_transaction()
.await
.expect("permanently blocked recovery should return without mutation"),
"ordinary-readable repaired heads must not clear the original known-fork latch"
);
let state = fixture.store.pool_meta_save_gate.lock().await;
assert!(state.write_blocked);
assert_eq!(
state
.block_context
.as_ref()
.expect("sticky fork context must remain present")
.since,
since
);
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_partial_apply_resumes_after_conditional_rejection() {
let fixture = fixture(1).await;
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let inspected = inspect(&fixture.store.pools, &fence)
.await
.expect("real native snapshots should be obtained");
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity)
.await
.expect("physical recovery plan should validate");
let snapshot = &inspected[0].snapshots[0];
assert_eq!(
inspected[0].disks[0]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256().expect("snapshot inventory should hash"),
replacement: replacements[0][0].clone(),
})
.await
.expect("first real Apply must atomically publish and sync"),
Response::Applied
);
let before_rejected = snapshots(&fixture.store).await;
let snapshot = &inspected[0].snapshots[1];
let mut stale_inventory = snapshot.inventory_sha256().expect("snapshot inventory should hash");
stale_inventory[0] ^= 1;
assert!(matches!(
inspected[0].disks[1]
.pool_meta_native_recovery(Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: stale_inventory,
replacement: replacements[0][1].clone(),
})
.await,
Err(DiskError::ConditionalDeleteConflict)
));
assert_eq!(
snapshots(&fixture.store).await,
before_rejected,
"rejected Apply must preserve the partial repair evidence"
);
drop(inspected);
drop(fence);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let mut state = clean_state(&fixture);
assert!(
repair(&fixture.store, &mut state, &fence)
.await
.expect("a fresh owner should resume the partial physical repair")
);
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_noninline_conditional_io_round_trip() {
let fixture = fixture(0).await;
let pool = Arc::clone(&fixture.store.pools[0]);
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
let (original, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
.await
.expect("baseline conditional object must be readable");
let token = pool_meta_cas_token_from_info(info);
// This smoke test targets the physical conditional I/O boundary. The large
// opaque body deliberately exceeds the inline writer's one-block limit.
let payload = vec![0x5a; rustfs_filemeta::BLOCK_SIZE_V2 + 137];
let state = clean_state(&fixture);
let mut arm = state.arm_transaction();
save_pool_meta_object_cas(
Arc::clone(&pool),
POOL_META_NAME,
payload.clone(),
&token,
&fence,
"noninline_smoke",
&mut arm,
)
.await
.expect("normal native CAS must support noninline pool metadata storage");
let (readback, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
.await
.expect("noninline conditional write must be ordinary-readable");
assert_eq!(readback, payload);
for disk in &snapshots(&fixture.store).await[0] {
let raw = disk
.current
.as_ref()
.expect("noninline head should be installed on every disk");
let info = rustfs_filemeta::get_file_info(
raw,
RUSTFS_META_BUCKET,
POOL_META_NAME,
"",
rustfs_filemeta::FileInfoOpts {
data: true,
include_free_versions: false,
include_part_checksums: true,
},
)
.expect("physical noninline metadata should decode");
assert!(!info.inline_data());
assert!(info.data.is_none());
}
save_pool_meta_object_cas(
pool,
POOL_META_NAME,
original.clone(),
&pool_meta_cas_token_from_info(info),
&fence,
"noninline_to_inline_smoke",
&mut arm,
)
.await
.expect("normal CAS must also replace a noninline parent with inline metadata");
arm.disarm();
assert_committed(&fixture).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_physical_three_plus_one_checkpoint_converges_all_shards() {
let fixture = fixture(0).await;
let pending = replicas(&fixture.pending, OffsetDateTime::now_utc() - Duration::seconds(1)).await;
let all_disks = disks(&fixture.store).await;
fs::write(object_path(&all_disks[0][0]).join("xl.meta"), &pending[0])
.await
.expect("one stale physical PREPARE head should be installed");
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("three matching COMMIT shards must remain ordinary-readable");
assert_eq!(selection.canonical.as_deref(), Some(fixture.canonical.as_slice()));
let guard = owner(&fixture.store).await;
let fence = PoolMetaPersistenceFence::Owned(Arc::clone(&guard));
let mut state = clean_state(&fixture);
assert!(
!repair(&fixture.store, &mut state, &fence)
.await
.expect("ordinary-readable pools should use normal checkpoint recovery")
);
selection
.meta
.save_for_startup_guarded(fixture.store.pools.clone(), &mut state, guard)
.await
.expect("normal checkpoint must converge a readable 3+1 pool without an unsafe native overwrite");
let checkpoint = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("checkpoint must remain ordinary-readable across all pools");
assert!(!checkpoint.replica_state.needs_repair);
assert!(checkpoint.replica_state.repair_write_safe);
assert!(checkpoint.revision.generation > fixture.revision.generation);
let canonical = checkpoint.canonical.expect("checkpoint should expose a canonical COMMIT");
for pool in snapshots(&fixture.store).await {
let raw = pool
.into_iter()
.map(|disk| disk.current.expect("checkpoint head must exist"))
.collect::<Vec<_>>();
assert_eq!(
codec::decode_inline_pool_meta_replicas(&raw)
.await
.expect("all four checkpoint shards must verify"),
vec![Bytes::copy_from_slice(&canonical)]
);
}
}
@@ -1,736 +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::pool_meta_recovery_codec as codec;
use super::*;
use crate::disk::{
DiskAPI, DiskStore, PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest as Request,
PoolMetaNativeRecoveryResponse as Response,
};
use bytes::Bytes;
#[cfg(test)]
#[derive(Default)]
pub(super) struct NativeApplyPause {
pub(super) reached: tokio::sync::Notify,
pub(super) resume: tokio::sync::Notify,
}
#[cfg(test)]
tokio::task_local! {
pub(super) static AFTER_NATIVE_APPLY: Arc<NativeApplyPause>;
}
struct InspectedPool {
disks: Vec<DiskStore>,
snapshots: Vec<PoolMetaNativeInspection>,
}
fn invalid() -> Error {
Error::other("native pool metadata recovery requires one proven V3 transaction and complete matching disk evidence")
}
fn known_validation(source: Error, phase: &'static str) -> Error {
Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::RecoveryRequired,
phase,
Some(Arc::new(source)),
))
}
fn codec_validation(source: codec::PoolMetaCodecError, phase: &'static str) -> Error {
// A supported transport may carry a valid layout outside this narrow
// recovery codec. That is not evidence of corruption or a transaction fork.
if matches!(source, codec::PoolMetaCodecError::UnsupportedLayout) {
return Error::other(source);
}
known_validation(Error::other(source), phase)
}
fn latch_validation(state: &mut PoolMetaWriteState, error: Error) -> Error {
if let Some(context) = error.pool_metadata_failure()
&& context.kind == crate::error::PoolMetadataFailure::RecoveryRequired
{
state.block_with_context(context.clone());
}
error
}
fn current(pool: &InspectedPool) -> Result<Vec<Bytes>> {
pool.snapshots
.iter()
.map(|snapshot| snapshot.current.clone().ok_or_else(invalid))
.collect()
}
async fn inspect(pools: &[Arc<Sets>], fence: &PoolMetaPersistenceFence<'_>) -> Result<Vec<InspectedPool>> {
let mut inspected = Vec::with_capacity(pools.len());
// Pool -> disk order is stable; all capability probes precede every mutation.
for pool in pools {
fence.ensure_held()?;
let set = pool.get_disks_by_key(POOL_META_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.ok_or_else(invalid))
.collect::<Result<Vec<_>>>()?;
let probes = futures::stream::iter(disks.iter().cloned().map(|disk| async move {
match disk.pool_meta_native_recovery(Request::Probe {}).await? {
Response::Probed => Ok(()),
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<()>>>()
.await;
for result in probes {
result?;
}
inspected.push(InspectedPool {
disks,
snapshots: Vec::new(),
});
}
for pool in &mut inspected {
fence.ensure_held()?;
let results = futures::stream::iter(pool.disks.iter().cloned().map(|disk| async move {
match disk
.pool_meta_native_recovery(Request::Inspect {
object: PoolMetaNativeObject::PoolBin,
})
.await?
{
Response::Inspected(snapshot) if !snapshot.disk_id.is_nil() => Ok(snapshot),
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<PoolMetaNativeInspection>>>()
.await;
for result in results {
pool.snapshots.push(result?);
}
}
fence.ensure_held()?;
Ok(inspected)
}
async fn validate_identities(pools: &[Arc<Sets>], canonical: &[u8], fence: &PoolMetaPersistenceFence<'_>) -> Result<()> {
for pool in pools {
fence.ensure_held()?;
// Identity and pool.bin have independent placement hashes. Read every
// member of the identity's actual set instead of assuming co-location.
let set = pool.get_disks_by_key(POOL_META_IDENTITY_NAME);
let disks = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.ok_or_else(invalid))
.collect::<Result<Vec<_>>>()?;
let results = futures::stream::iter(disks.into_iter().enumerate().map(|(index, disk)| async move {
match disk.pool_meta_native_recovery(Request::InspectIdentity {}).await? {
Response::IdentityInspected { disk_id, raw } if !disk_id.is_nil() => {
codec::validate_inline_pool_meta_shard(&raw, index, canonical)
.await
.map_err(|error| codec_validation(error, "native_identity_validation"))
}
_ => Err(invalid()),
}
}))
.buffered(4)
.collect::<Vec<Result<()>>>()
.await;
for result in results {
result?;
}
}
fence.ensure_held()
}
async fn plan(
inspected: &[InspectedPool],
canonical: &[u8],
revision: PoolMetaRevision,
identity: &[u8],
) -> Result<Vec<Vec<Bytes>>> {
let mut pending = Vec::<Vec<u8>>::new();
let mut predecessors = Vec::<Vec<u8>>::new();
let mut decoded = Vec::<Vec<u8>>::new();
let mut newest = OffsetDateTime::now_utc();
for pool in inspected {
let current = current(pool)?;
for (index, snapshot) in pool.snapshots.iter().enumerate() {
if let Some(local_identity) = snapshot.identity.as_ref() {
codec::validate_inline_pool_meta_shard(local_identity, index, identity)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?;
}
for raw in std::iter::once(current[index].clone()).chain(snapshot.backups.iter().map(|backup| backup.data.clone())) {
let info =
codec::parse_inline_info(&raw).map_err(|error| codec_validation(error, "native_metadata_validation"))?;
newest = newest.max(info.mod_time.ok_or_else(invalid)?);
}
}
let mut inventories = vec![current.clone()];
let directories = pool
.snapshots
.iter()
.flat_map(|snapshot| snapshot.backups.iter().map(|backup| backup.directory))
.collect::<HashSet<_>>();
for directory in directories {
// A missing backup contributes its actual current shard only to
// integrity reconstruction. It never invents backup evidence.
inventories.push(
pool.snapshots
.iter()
.enumerate()
.map(|(index, snapshot)| {
snapshot
.backups
.iter()
.find(|backup| backup.directory == directory)
.map(|backup| backup.data.clone())
.unwrap_or_else(|| current[index].clone())
})
.collect(),
);
}
for raw in inventories {
for generation in codec::decode_inline_pool_meta_replicas(&raw)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?
{
let payload = generation.to_vec();
if !decoded.contains(&payload) {
decoded.push(payload);
}
}
}
}
let mut obsolete = Vec::<Vec<u8>>::new();
for payload in &decoded {
match decode_pool_meta_replica(payload.clone()) {
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: false,
previous: Some(previous),
..
} if candidate_revision == revision && candidate == canonical => {
if !pending.contains(payload) {
pending.push(payload.clone());
}
if !predecessors.contains(&previous.canonical) {
predecessors.push(previous.canonical);
}
}
PoolMetaReplica::Valid { revision: older, .. }
if older.is_generation_protocol()
&& older.cluster_id == revision.cluster_id
&& older.epoch == revision.epoch
&& older.generation < revision.generation =>
{
// Retained evidence from an earlier successful recovery is
// obsolete, not a competing commit authority. It remains
// fully reconstructed and bound into the native CAS.
obsolete.push(payload.clone());
}
PoolMetaReplica::Valid {
canonical: candidate,
revision: candidate_revision,
committed: true,
..
} if candidate_revision == revision && candidate == canonical => {}
PoolMetaReplica::Valid { revision: conflict, .. } if conflict.is_generation_protocol() => {
return Err(known_validation(
Error::other("native V3 transaction conflicts with committed authority"),
"native_transaction_validation",
));
}
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_) => {
return Err(known_validation(
Error::other("native V3 payload is corrupt or incompatible"),
"native_transaction_validation",
));
}
_ => {}
}
}
if pending.len() > 1 {
return Err(known_validation(
Error::other("native V3 prepare records disagree on the predecessor"),
"native_transaction_validation",
));
}
if pending.is_empty() {
return Err(invalid());
}
for payload in &decoded {
if payload.as_slice() != canonical
&& !pending.contains(payload)
&& !predecessors.contains(payload)
&& !obsolete.contains(payload)
{
return Err(invalid());
}
}
for pool in inspected {
for (index, snapshot) in pool.snapshots.iter().enumerate() {
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
let mut matched = false;
for payload in std::iter::once(canonical).chain(pending.iter().map(Vec::as_slice)) {
matched |= codec::validate_inline_pool_meta_shard(raw, index, payload).await.is_ok();
}
if !matched {
return Err(invalid());
}
for backup in &snapshot.backups {
let mut matched = false;
for payload in std::iter::once(canonical).chain(
pending
.iter()
.chain(predecessors.iter())
.chain(obsolete.iter())
.map(Vec::as_slice),
) {
matched |= codec::validate_inline_pool_meta_shard(&backup.data, index, payload)
.await
.is_ok();
}
if !matched {
return Err(invalid());
}
}
}
}
let modified = newest.checked_add(Duration::nanoseconds(1)).ok_or_else(invalid)?;
let mut replacements = Vec::with_capacity(inspected.len());
for pool in inspected {
replacements.push(
codec::encode_inline_pool_meta_replicas(&current(pool)?, canonical, modified)
.await
.map_err(|error| codec_validation(error, "native_metadata_validation"))?,
);
}
Ok(replacements)
}
pub(super) async fn repair(
store: &ECStore,
state: &mut PoolMetaWriteState,
fence: &PoolMetaPersistenceFence<'_>,
) -> Result<bool> {
fence.ensure_held()?;
let reads = read_pool_meta_replicas(store.pools.clone(), true).await;
if !reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
{
return Ok(false);
}
state.ensure_write_safe("native pool metadata recovery")?;
if reads
.iter()
.any(|read| matches!(read.replica, PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)))
{
return Err(latch_validation(
state,
known_validation(Error::other("pool metadata is corrupt or incompatible"), "native_metadata_validation"),
));
}
if reads
.iter()
.any(|read| !matches!(read.replica, PoolMetaReplica::Valid { .. } | PoolMetaReplica::Unreadable(_)))
{
return Err(invalid());
}
// Only an ordinary, successfully read COMMIT grants authority. Rebuilding
// two erasure shards below proves integrity, never a commit decision.
let ordinary_commits = reads
.iter()
.filter_map(|read| match &read.replica {
PoolMetaReplica::Valid {
raw,
canonical,
revision,
committed: true,
..
} if revision.is_generation_protocol() && raw == canonical => Some((canonical.clone(), *revision)),
_ => None,
})
.collect::<Vec<_>>();
if ordinary_commits.is_empty() {
return Err(invalid());
}
let selection = select_pool_meta_replica_reads(reads)
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
state
.validate_selection(&selection)
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
if selection.meta.pools.len() != store.pools.len()
|| selection
.meta
.pools
.iter()
.zip(&store.pools)
.enumerate()
.any(|(index, (meta, pool))| meta.id != index || meta.cmd_line != pool.endpoints.cmd_line)
{
return Err(invalid());
}
let canonical = selection.canonical.as_ref().ok_or_else(invalid)?;
if !selection.revision.is_generation_protocol()
|| !ordinary_commits
.iter()
.any(|(raw, revision)| raw == canonical && *revision == selection.revision)
{
return Err(invalid());
}
let cluster_id = selection.revision.cluster_id.ok_or_else(invalid)?;
if state.expected_cluster_id != Some(cluster_id) {
return Err(invalid());
}
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
if identities.iter().any(|read| {
matches!(
read.replica,
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
)
}) {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
));
}
if identities
.iter()
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
{
return Err(invalid());
}
let identity = select_pool_meta_identity(identities, cluster_id)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
state
.validate_identity(&identity)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
let identity = identity.identity.ok_or_else(invalid)?;
if !identity.initialized {
return Err(invalid());
}
if identity.epoch != selection.revision.epoch {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity epoch conflicts with metadata"), "native_identity_validation"),
));
}
let identity_bytes = encode_pool_meta_identity(identity)?;
let repair_started = std::time::Instant::now();
validate_identities(&store.pools, &identity_bytes, fence)
.await
.map_err(|error| latch_validation(state, error))?;
let inspected = inspect(&store.pools, fence).await?;
let replacements = plan(&inspected, canonical, selection.revision, &identity_bytes)
.await
.map_err(|error| latch_validation(state, error))?;
fence.ensure_held()?;
let mut arm = state.arm_transaction();
arm.phase = Some("native_repair_apply");
for (pool, replacements) in inspected.iter().zip(replacements) {
for ((disk, snapshot), replacement) in pool.disks.iter().zip(&pool.snapshots).zip(replacements) {
fence.ensure_held()?;
let request = Request::Apply {
object: PoolMetaNativeObject::PoolBin,
expected_disk_id: snapshot.disk_id,
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
expected_backup_inventory_sha256: snapshot.inventory_sha256()?,
replacement,
};
match disk.pool_meta_native_recovery(request).await? {
Response::Applied => {}
_ => return Err(invalid()),
}
}
}
#[cfg(test)]
if let Ok(pause) = AFTER_NATIVE_APPLY.try_with(Arc::clone) {
pause.reached.notify_one();
pause.resume.notified().await;
}
fence.ensure_held()?;
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
if confirmed.canonical.as_ref() != Some(canonical)
|| confirmed.revision != selection.revision
|| !confirmed.replica_state.repair_write_safe
|| confirmed.replica_state.needs_repair
{
return Err(invalid());
}
// Re-enter the normal V3 conditional-write path, including native CAS,
// full tail completion, and the final ordinary quorum readback.
for (pool, token) in store.pools.iter().cloned().zip(&confirmed.cas_tokens) {
save_pool_meta_object_cas(pool, POOL_META_NAME, canonical.clone(), token, fence, "native_repair_confirm", &mut arm)
.await?;
}
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
if confirmed.canonical.as_ref() != Some(canonical)
|| confirmed.revision != selection.revision
|| confirmed.replica_state.needs_repair
|| !confirmed.replica_state.repair_write_safe
{
return Err(invalid());
}
validate_identities(&store.pools, &identity_bytes, fence)
.await
.map_err(|error| latch_validation(state, error))?;
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
if identities.iter().any(|read| {
matches!(
read.replica,
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
)
}) {
return Err(latch_validation(
state,
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
));
}
if identities
.iter()
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
{
return Err(invalid());
}
let identity = select_pool_meta_identity(identities, cluster_id)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
state
.validate_identity(&identity)
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
if identity.identity.map(encode_pool_meta_identity).transpose()?.as_deref() != Some(identity_bytes.as_slice()) {
return Err(latch_validation(
state,
known_validation(Error::other("native identity changed after repair"), "native_identity_validation"),
));
}
fence.ensure_held()?;
arm.disarm();
info!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
state = "pool_metadata_native_repaired",
generation = selection.revision.generation,
pools = inspected.len(),
disks = inspected.iter().map(|pool| pool.disks.len()).sum::<usize>(),
elapsed_ms = repair_started.elapsed().as_secs_f64() * 1000.0,
"Pool metadata native replicas repaired"
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
struct Fixture {
pools: Vec<InspectedPool>,
commit: Vec<u8>,
pending: Vec<u8>,
identity: Vec<u8>,
revision: PoolMetaRevision,
modified: OffsetDateTime,
}
async fn fixture() -> Fixture {
let cluster_id = uuid::Uuid::new_v4();
let revision = PoolMetaRevision {
version: POOL_META_GENERATION_VERSION,
cluster_id: Some(cluster_id),
epoch: 1,
generation: 1,
transaction_id: Some(uuid::Uuid::new_v4()),
};
let meta = PoolMeta {
version: POOL_META_GENERATION_VERSION,
..Default::default()
};
let previous_meta = PoolMeta {
version: POOL_META_V1_VERSION,
..Default::default()
};
let previous = PoolMetaCommittedCandidate {
canonical: previous_meta.encode_config_data_for_v2_gate(true).unwrap(),
meta: previous_meta,
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
};
let pending = encode_pool_meta_v3_envelope(&meta, revision, false, Some(&previous)).unwrap();
let commit = encode_pool_meta_v3_envelope(&meta, revision, true, None).unwrap();
let identity = encode_pool_meta_identity(PersistedPoolMetaIdentity {
version: POOL_META_IDENTITY_VERSION,
cluster_id,
epoch: 1,
initialized: true,
fresh_bootstrap_nonce: None,
})
.unwrap();
let modified = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
let current_split = split(&pending, &commit, modified).await;
let current_commit = replicas(&commit, modified + Duration::seconds(1)).await;
let backups = replicas(&pending, modified).await;
let identities = replicas(&identity, modified).await;
let directory = uuid::Uuid::new_v4();
let pools = [current_split.clone(), current_split, current_commit.clone(), current_commit]
.into_iter()
.map(|current| InspectedPool {
disks: Vec::new(),
snapshots: current
.into_iter()
.enumerate()
.map(|(index, current)| PoolMetaNativeInspection {
disk_id: uuid::Uuid::new_v4(),
identity: Some(identities[index].clone()),
current: Some(current),
backups: vec![crate::disk::PoolMetaNativeBackup {
directory,
data: backups[index].clone(),
}],
})
.collect(),
})
.collect();
Fixture {
pools,
commit,
pending,
identity,
revision,
modified,
}
}
#[tokio::test]
async fn pool_meta_native_plan_restores_physical_split_with_fresh_common_identity() {
let f = fixture().await;
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity).await.unwrap();
assert_eq!(repaired.len(), 4);
for pool in repaired {
assert_eq!(
codec::decode_inline_pool_meta_replicas(&pool).await.unwrap(),
vec![Bytes::copy_from_slice(&f.commit)]
);
let times = pool
.iter()
.map(|raw| codec::parse_inline_info(raw).unwrap().mod_time.unwrap())
.collect::<HashSet<_>>();
assert_eq!(times.len(), 1);
assert!(*times.iter().next().unwrap() > f.modified + Duration::seconds(1));
}
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_single_hidden_identity_fork() {
let mut f = fixture().await;
let wrong = replicas(b"different identity", f.modified).await;
f.pools[0].snapshots[0].identity = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_hidden_higher_generation_and_unknown_backup() {
let mut f = fixture().await;
let higher_revision = PoolMetaRevision {
generation: 2,
transaction_id: Some(uuid::Uuid::new_v4()),
..f.revision
};
let higher = encode_pool_meta_v3_envelope(
&PoolMeta {
version: POOL_META_GENERATION_VERSION,
..Default::default()
},
higher_revision,
true,
None,
)
.unwrap();
let wrong = replicas(&higher, f.modified + Duration::seconds(2)).await;
f.pools[0].snapshots[0].current = Some(wrong[0].clone());
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
f.pools[0].snapshots[0].current = Some(replicas(&f.pending, f.modified).await[0].clone());
f.pools[0].snapshots[0].backups[0].data = wrong[0].clone();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
#[tokio::test]
async fn pool_meta_native_plan_accepts_retained_older_recovery_backups() {
let mut f = fixture().await;
let older_commit = f.commit.clone();
let older_pending = f.pending.clone();
let next = PoolMetaRevision {
generation: 2,
transaction_id: Some(uuid::Uuid::new_v4()),
..f.revision
};
let PoolMetaReplica::Valid { meta, .. } = decode_pool_meta_replica(older_commit.clone()) else {
panic!("valid fixture")
};
let previous = PoolMetaCommittedCandidate {
canonical: older_commit.clone(),
meta: meta.clone(),
revision: f.revision,
};
f.commit = encode_pool_meta_v3_envelope(&meta, next, true, None).unwrap();
f.pending = encode_pool_meta_v3_envelope(&meta, next, false, Some(&previous)).unwrap();
f.revision = next;
let current = split(&f.pending, &f.commit, f.modified + Duration::seconds(2)).await;
let old_commit = replicas(&older_commit, f.modified + Duration::seconds(1)).await;
let current_pending = replicas(&f.pending, f.modified + Duration::seconds(2)).await;
let new_directory = uuid::Uuid::new_v4();
let old_directory = uuid::Uuid::new_v4();
for pool in &mut f.pools {
for (index, snapshot) in pool.snapshots.iter_mut().enumerate() {
snapshot.current = Some(current[index].clone());
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
directory: new_directory,
data: current_pending[index].clone(),
});
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
directory: old_directory,
data: old_commit[index].clone(),
});
}
}
assert!(older_pending != f.pending);
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_ok());
}
#[tokio::test]
async fn pool_meta_native_plan_rejects_missing_or_corrupt_evidence() {
let mut f = fixture().await;
let before = f.pools[1].snapshots[2].current.take();
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
f.pools[1].snapshots[2].current = before;
f.pools[1].snapshots[2].backups[0].data = Bytes::from_static(b"invalid metadata");
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
}
}
#[cfg(all(test, feature = "test-util"))]
#[path = "pool_meta_native_integration_tests.rs"]
mod integration_tests;
@@ -1,569 +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.
//! Integrity-only codec for conditional recovery of an inline `pool.bin` head.
//! Reconstructing a payload here never grants authority to publish it.
use crate::disk::RUSTFS_META_BUCKET;
use crate::erasure::coding::encode::IntegrityEncodeMode;
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, Erasure, calc_shard_size};
use crate::io_support::shard_integrity::{PartProofReader, ShardVerifier};
use bytes::Bytes;
use rustfs_filemeta::{
BLOCK_SIZE_V2, ChecksumAlgo, ChecksumInfo, ERASURE_ALGORITHM, FileInfo, FileInfoOpts, FileMeta, get_file_info,
};
use rustfs_utils::HashAlgorithm;
use rustfs_utils::http::{SUFFIX_ACTUAL_SIZE, SUFFIX_INLINE_DATA, get_consistent_str, insert_str, remove_str};
use std::io::{self, Cursor};
use std::sync::Arc;
use time::OffsetDateTime;
use uuid::Uuid;
const OBJECT: &str = "pool.bin";
const MAX_SHARDS: usize = 16;
#[derive(Debug, thiserror::Error)]
pub(super) enum PoolMetaCodecError {
#[error("unsupported inline pool metadata layout")]
UnsupportedLayout,
#[error("pool metadata shard does not match its physical disk")]
ShardPlacement,
#[error("pool metadata replicas disagree on persistent attributes")]
InconsistentMetadata,
#[error("pool metadata payload does not match its metadata or shards")]
PayloadMismatch,
#[error("invalid inline pool metadata")]
Metadata(#[source] rustfs_filemeta::Error),
#[error("inline pool metadata integrity verification failed")]
Integrity(#[source] io::Error),
}
impl From<rustfs_filemeta::Error> for PoolMetaCodecError {
fn from(value: rustfs_filemeta::Error) -> Self {
Self::Metadata(value)
}
}
impl From<io::Error> for PoolMetaCodecError {
fn from(value: io::Error) -> Self {
Self::Integrity(value)
}
}
type Result<T> = std::result::Result<T, PoolMetaCodecError>;
/// Rejects layouts outside the narrow, current inline pool metadata writer.
pub(super) fn parse_inline_info(raw: &Bytes) -> Result<FileInfo> {
if FileMeta::read_format_versions(raw)? != (1, 3, 3, 3) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let meta = FileMeta::load(raw)?;
if meta.versions.len() != 1 {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
if meta.versions[0]
.parse_version_meta()?
.object
.is_none_or(|object| object.bitrot_checksum_algo != ChecksumAlgo::HighwayHash)
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let info = get_file_info(
raw,
RUSTFS_META_BUCKET,
OBJECT,
"",
FileInfoOpts {
data: true,
include_free_versions: true,
include_part_checksums: true,
},
)?;
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let erasure = &info.erasure;
let shards = erasure.data_blocks.checked_add(erasure.parity_blocks);
if !info.is_valid()
|| info.deleted
|| info.mark_deleted
|| info.version_id.is_some_and(|id| !id.is_nil())
|| info.versioned
|| info.uses_legacy_checksum
|| info.data_dir.is_none_or(|id| id.is_nil())
|| info.mod_time.is_none_or(|modified| modified <= OffsetDateTime::UNIX_EPOCH)
|| info.checksum.is_some()
|| erasure.algorithm != ERASURE_ALGORITHM
|| erasure.block_size != BLOCK_SIZE_V2
|| erasure.data_blocks == 0
|| erasure.data_blocks != erasure.parity_blocks
|| shards.is_none_or(|count| !(2..=MAX_SHARDS).contains(&count))
|| size == 0
|| size > BLOCK_SIZE_V2
|| info.parts.len() != 1
|| info.data.is_none()
|| get_consistent_str(&info.metadata, SUFFIX_INLINE_DATA) != Some("true")
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let part = &info.parts[0];
let etag = info.metadata.get("etag").ok_or(PoolMetaCodecError::UnsupportedLayout)?;
if part.number != 1
|| part.size != size
|| part.actual_size != info.size
|| part.etag != *etag
|| part.index.is_some()
|| part.checksums.is_some()
|| part.error.is_some()
|| etag.len() != 32
|| !etag
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| get_consistent_str(&info.metadata, SUFFIX_ACTUAL_SIZE).and_then(|actual| actual.parse::<usize>().ok()) != Some(size)
|| !erasure.checksums.is_empty()
{
return Err(PoolMetaCodecError::UnsupportedLayout);
}
Ok(info)
}
fn validate_placement(info: &FileInfo, physical_index: usize, disk_count: usize) -> Result<()> {
if info.erasure.distribution.len() != disk_count
|| info.erasure.data_blocks + info.erasure.parity_blocks != disk_count
|| info.erasure.distribution.get(physical_index).copied() != Some(info.erasure.index)
{
return Err(PoolMetaCodecError::ShardPlacement);
}
Ok(())
}
fn erasure(info: &FileInfo) -> Result<Erasure> {
Erasure::try_new(info.erasure.data_blocks, info.erasure.parity_blocks, info.erasure.block_size)
.map_err(|_| PoolMetaCodecError::UnsupportedLayout)
}
fn md5(payload: &[u8]) -> String {
hex_simd::encode_to_string(HashAlgorithm::Md5.hash_encode(payload).as_ref(), hex_simd::AsciiCase::Lower)
}
async fn verified_shard(info: &FileInfo) -> Result<Vec<u8>> {
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let shard_size = calc_shard_size(size, info.erasure.data_blocks);
let data = info.data.as_ref().ok_or(PoolMetaCodecError::UnsupportedLayout)?;
if data.len() != shard_size + HashAlgorithm::HighwayHash256S.size() {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let mut reader = BitrotReader::new(Cursor::new(data.clone()), shard_size, HashAlgorithm::HighwayHash256S, false);
if let Some(integrity) = &info.parts[0].integrity {
let proof = PartProofReader::new(integrity.clone(), std::slice::from_ref(info), &[None], RUSTFS_META_BUCKET, OBJECT)?;
reader.set_integrity(ShardVerifier::new(proof, info.erasure.index - 1, 0, None)?)?;
}
let mut shard = vec![0; shard_size];
if reader.read(&mut shard).await? != shard_size {
return Err(PoolMetaCodecError::PayloadMismatch);
}
Ok(shard)
}
fn generation_key(info: &FileInfo) -> FileInfo {
let mut key = info.clone();
key.erasure.index = 0;
key.data = None;
key
}
/// Decodes every reconstructable generation without making a quorum decision.
///
/// All physical disks must be present and every supplied shard must verify.
/// Groups below the erasure reconstruction threshold are left undecoded; the
/// caller must match every such shard against an independently known payload
/// with [`validate_inline_pool_meta_shard`] before proposing any mutation.
pub(super) async fn decode_inline_pool_meta_replicas(raw: &[Bytes]) -> Result<Vec<Bytes>> {
if !(2..=MAX_SHARDS).contains(&raw.len()) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
struct Group {
key: FileInfo,
present: usize,
shards: Vec<Option<Vec<u8>>>,
}
let mut groups: Vec<Group> = Vec::new();
for (physical_index, bytes) in raw.iter().enumerate() {
let info = parse_inline_info(bytes)?;
validate_placement(&info, physical_index, raw.len())?;
let shard = verified_shard(&info).await?;
let key = generation_key(&info);
let group_index = if let Some(index) = groups.iter().position(|group| group.key == key) {
index
} else {
groups.push(Group {
key,
present: 0,
shards: vec![None; raw.len()],
});
groups.len() - 1
};
let group = &mut groups[group_index];
let slot = &mut group.shards[info.erasure.index - 1];
if slot.is_some() {
return Err(PoolMetaCodecError::ShardPlacement);
}
*slot = Some(shard);
group.present += 1;
}
let mut decoded = Vec::with_capacity(groups.len());
for mut group in groups {
if group.present < group.key.erasure.data_blocks {
continue;
}
let codec = erasure(&group.key)?;
codec.decode_data(&mut group.shards)?;
let size = usize::try_from(group.key.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
let mut payload = Vec::with_capacity(size);
for shard in group.shards.iter().take(group.key.erasure.data_blocks) {
let shard = shard.as_ref().ok_or(PoolMetaCodecError::PayloadMismatch)?;
let remaining = size.saturating_sub(payload.len());
payload.extend_from_slice(&shard[..remaining.min(shard.len())]);
}
if payload.len() != size || group.key.metadata.get("etag") != Some(&md5(&payload)) {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let encoded = codec.encode_data(&payload)?;
if group
.shards
.iter()
.zip(&encoded)
.any(|(actual, expected)| actual.as_ref().is_some_and(|actual| actual.as_slice() != expected.as_ref()))
{
return Err(PoolMetaCodecError::PayloadMismatch);
}
decoded.push(Bytes::from(payload));
}
Ok(decoded)
}
/// Verifies a minority shard against a payload whose authority was established elsewhere.
pub(super) async fn validate_inline_pool_meta_shard(raw: &Bytes, physical_index: usize, payload: &[u8]) -> Result<()> {
let info = parse_inline_info(raw)?;
validate_placement(&info, physical_index, info.erasure.distribution.len())?;
if usize::try_from(info.size).ok() != Some(payload.len()) || info.metadata.get("etag") != Some(&md5(payload)) {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let actual = verified_shard(&info).await?;
let encoded = erasure(&info)?.encode_data(payload)?;
if encoded
.get(info.erasure.index - 1)
.is_none_or(|expected| expected.as_ref() != actual.as_slice())
{
return Err(PoolMetaCodecError::PayloadMismatch);
}
Ok(())
}
fn persistent_attributes(info: &FileInfo) -> FileInfo {
let mut attributes = generation_key(info);
attributes.data_dir = None;
attributes.mod_time = None;
attributes.size = 0;
attributes.parts.clear();
attributes.erasure.checksums.clear();
attributes.metadata.remove("etag");
remove_str(&mut attributes.metadata, SUFFIX_ACTUAL_SIZE);
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut attributes.metadata);
attributes
}
/// Builds disk-specific inline heads while retaining each disk's existing FileMeta.
/// The caller supplies the independently authorized canonical COMMIT payload.
pub(super) async fn encode_inline_pool_meta_replicas(
raw: &[Bytes],
canonical: &[u8],
modified: OffsetDateTime,
) -> Result<Vec<Bytes>> {
if !(2..=MAX_SHARDS).contains(&raw.len()) || canonical.is_empty() || canonical.len() > BLOCK_SIZE_V2 {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
let mut infos = Vec::with_capacity(raw.len());
for (physical_index, bytes) in raw.iter().enumerate() {
let info = parse_inline_info(bytes)?;
validate_placement(&info, physical_index, raw.len())?;
verified_shard(&info).await?;
if info.mod_time.is_none_or(|previous| modified <= previous) {
return Err(PoolMetaCodecError::UnsupportedLayout);
}
if let Some(first) = infos.first()
&& persistent_attributes(first) != persistent_attributes(&info)
{
return Err(PoolMetaCodecError::InconsistentMetadata);
}
infos.push(info);
}
let mut base = infos.first().ok_or(PoolMetaCodecError::UnsupportedLayout)?.clone();
let protected = infos.iter().any(|info| info.parts[0].integrity.is_some());
let codec = Arc::new(erasure(&base)?);
let mut writers: Vec<Option<BitrotWriterWrapper>> = std::iter::repeat_with(|| None).take(raw.len()).collect();
let (_, written, shards, integrity) = Arc::clone(&codec)
.encode_with_shard_integrity(
Cursor::new(Bytes::copy_from_slice(canonical)),
&mut writers,
raw.len(),
1,
IntegrityEncodeMode::Inline(canonical.len()),
protected,
)
.await?;
if written != canonical.len() {
return Err(PoolMetaCodecError::PayloadMismatch);
}
let shards = shards.ok_or(PoolMetaCodecError::PayloadMismatch)?;
let etag = md5(canonical);
base.data_dir = Some(Uuid::new_v4());
base.mod_time = Some(modified);
base.size = i64::try_from(canonical.len()).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
base.metadata.insert("etag".to_owned(), etag.clone());
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, canonical.len().to_string());
base.parts.clear();
base.add_object_part(1, etag, canonical.len(), Some(modified), base.size, None, None);
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut base.metadata);
if let Some(integrity) = integrity {
integrity.set_inline_metadata(&mut base)?;
base.parts[0].integrity = Some(integrity.part);
}
base.persist_shard_integrity()?;
base.erasure.checksums = vec![ChecksumInfo {
part_number: 1,
algorithm: HashAlgorithm::HighwayHash256S,
hash: Bytes::new(),
}];
base.set_inline_data();
let mut replacement = Vec::with_capacity(raw.len());
for (bytes, original) in raw.iter().zip(infos) {
let mut info = base.clone();
info.erasure.index = original.erasure.index;
info.data = Some(
shards
.get(info.erasure.index - 1)
.ok_or(PoolMetaCodecError::ShardPlacement)?
.clone(),
);
let mut meta = FileMeta::load(bytes)?;
meta.add_version(info)?;
replacement.push(Bytes::from(meta.marshal_msg()?));
}
Ok(replacement)
}
#[cfg(test)]
pub(super) async fn fixture_inline_pool_meta_replicas(payload: &[u8], modified: OffsetDateTime) -> Vec<Bytes> {
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
let mut base = FileInfo::new(".rustfs.sys/pool.bin", 2, 2);
base.volume = RUSTFS_META_BUCKET.to_owned();
base.name = OBJECT.to_owned();
base.data_dir = Some(Uuid::new_v4());
base.mod_time = Some(modified);
base.size = i64::try_from(payload.len()).expect("test payload should fit i64");
let etag = md5(payload);
base.metadata.insert("etag".to_owned(), etag.clone());
base.metadata
.insert("content-type".to_owned(), "application/octet-stream".to_owned());
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, payload.len().to_string());
base.set_inline_data();
base.add_object_part(1, etag, payload.len(), Some(modified), base.size, None, None);
let codec = erasure(&base).expect("fixture layout should construct");
let shards = codec.encode_data(payload).expect("fixture erasure encoding should succeed");
let mut raw = Vec::with_capacity(4);
for coding_index in &base.erasure.distribution {
let mut writer =
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), codec.shard_size(), HashAlgorithm::HighwayHash256S);
writer
.write(&shards[*coding_index - 1])
.await
.expect("fixture bitrot writer should write");
writer.shutdown().await.expect("fixture bitrot writer should finish");
let mut info = base.clone();
info.erasure.index = *coding_index;
info.data = Some(Bytes::from(writer.into_inline_data().expect("fixture writer should retain inline bytes")));
let mut meta = FileMeta::new();
meta.add_version(info).expect("test inline FileInfo should serialize");
raw.push(Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize")));
}
raw
}
#[cfg(test)]
pub(super) async fn fixture_split_inline_pool_meta_replicas(
prepare: &[u8],
commit: &[u8],
modified: OffsetDateTime,
) -> Vec<Bytes> {
let mut raw = fixture_inline_pool_meta_replicas(prepare, modified).await;
let committed = fixture_inline_pool_meta_replicas(commit, modified + time::Duration::seconds(1)).await;
raw[2..].clone_from_slice(&committed[2..]);
raw
}
#[cfg(test)]
mod tests {
use super::*;
use fixture_inline_pool_meta_replicas as fixtures;
fn time(seconds: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(seconds).expect("test timestamp should be valid")
}
fn metadata(info: FileInfo) -> Bytes {
let mut meta = FileMeta::new();
meta.add_version(info).expect("test inline FileInfo should serialize");
Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize"))
}
#[tokio::test]
async fn pool_meta_codec_reconstructs_both_halves_without_treating_them_as_authority() {
let raw = fixture_split_inline_pool_meta_replicas(b"prepare with predecessor", b"commit", time(10)).await;
let decoded = decode_inline_pool_meta_replicas(&raw)
.await
.expect("two shards should reconstruct each payload");
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].as_ref(), b"prepare with predecessor");
assert_eq!(decoded[1].as_ref(), b"commit");
}
#[tokio::test]
async fn pool_meta_codec_requires_known_payload_for_single_minority_shard() {
let previous = fixtures(b"previous", time(10)).await;
let commit = fixtures(b"commit", time(11)).await;
let raw = vec![previous[0].clone(), commit[1].clone(), commit[2].clone(), commit[3].clone()];
let decoded = decode_inline_pool_meta_replicas(&raw)
.await
.expect("three commit shards should reconstruct");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].as_ref(), b"commit");
validate_inline_pool_meta_shard(&raw[0], 0, b"previous")
.await
.expect("known predecessor should verify");
assert!(matches!(
validate_inline_pool_meta_shard(&raw[0], 0, b"commit").await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_bitrot_even_with_other_reconstructable_shards() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
let mut data = info.data.take().expect("fixture should be inline").to_vec();
data[HashAlgorithm::HighwayHash256S.size()] ^= 1;
info.data = Some(Bytes::from(data));
raw[0] = metadata(info);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::Integrity(_))
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_trailing_inline_bytes() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
let mut data = info.data.take().expect("fixture should be inline").to_vec();
data.push(0);
info.data = Some(Bytes::from(data));
raw[0] = metadata(info);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_duplicate_physical_shard_mapping() {
let mut raw = fixtures(b"commit", time(10)).await;
raw.swap(0, 1);
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::ShardPlacement)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_payload_md5_disagreement_after_valid_bitrot() {
let mut raw = fixtures(b"commit", time(10)).await;
for bytes in &mut raw {
let mut info = parse_inline_info(bytes).expect("fixture should parse");
info.metadata
.insert("etag".to_owned(), "00000000000000000000000000000000".to_owned());
info.parts[0].etag = "00000000000000000000000000000000".to_owned();
*bytes = metadata(info);
}
assert!(matches!(
decode_inline_pool_meta_replicas(&raw).await,
Err(PoolMetaCodecError::PayloadMismatch)
));
}
#[tokio::test]
async fn pool_meta_codec_reencodes_split_heads_to_one_canonical_generation() {
let prepare = fixtures(b"prepare with predecessor", time(10)).await;
let commit = fixtures(b"commit", time(11)).await;
let raw = vec![prepare[0].clone(), prepare[1].clone(), commit[2].clone(), commit[3].clone()];
let repaired = encode_inline_pool_meta_replicas(&raw, b"commit", time(12))
.await
.expect("split heads should encode");
let decoded = decode_inline_pool_meta_replicas(&repaired)
.await
.expect("repaired set should decode");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].as_ref(), b"commit");
let info = parse_inline_info(&repaired[0]).expect("repaired metadata should parse");
assert_eq!(info.mod_time, Some(time(12)));
assert_eq!(info.metadata.get("content-type").map(String::as_str), Some("application/octet-stream"));
for (index, bytes) in repaired.iter().enumerate() {
validate_inline_pool_meta_shard(bytes, index, b"commit")
.await
.expect("every repaired disk should match commit");
}
}
#[tokio::test]
async fn pool_meta_codec_preserves_required_persistent_metadata_agreement() {
let mut raw = fixtures(b"commit", time(10)).await;
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
info.metadata.insert("content-type".to_owned(), "text/plain".to_owned());
raw[0] = metadata(info);
assert!(matches!(
encode_inline_pool_meta_replicas(&raw, b"commit", time(11)).await,
Err(PoolMetaCodecError::InconsistentMetadata)
));
}
#[tokio::test]
async fn pool_meta_codec_requires_strictly_newer_common_modification_time() {
let raw = fixtures(b"commit", time(10)).await;
assert!(matches!(
encode_inline_pool_meta_replicas(&raw, b"commit", time(10)).await,
Err(PoolMetaCodecError::UnsupportedLayout)
));
}
#[tokio::test]
async fn pool_meta_codec_rejects_noninline_heads_and_wrong_disk_count() {
let mut raw = fixtures(b"commit", time(10)).await;
assert!(matches!(
decode_inline_pool_meta_replicas(&raw[..3]).await,
Err(PoolMetaCodecError::ShardPlacement)
));
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
info.data = None;
raw[0] = metadata(info);
assert!(matches!(parse_inline_info(&raw[0]), Err(PoolMetaCodecError::UnsupportedLayout)));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+164 -11
View File
@@ -19,6 +19,7 @@ use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
use crate::multipart_listing::paginate_multipart_listing;
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
object::{DeleteAccounting, DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
range::HTTPRangeSpec,
@@ -59,10 +60,16 @@ use std::{
use tokio::sync::RwLock;
use tokio::sync::broadcast::{Receiver, Sender};
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use tracing::{error, info};
use uuid::Uuid;
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
fn is_idempotent_delete_prefix_error(err: &Error) -> bool {
@@ -317,17 +324,6 @@ impl Sets {
&self.ctx
}
#[cfg(test)]
pub(crate) async fn stop_endpoint_monitor_for_test(&self) {
if let Some(exit_signal) = &self.exit_signal {
let _ = exit_signal.send(());
// Wait for an in-flight reconnect pass as well as its receiver to exit.
while exit_signal.receiver_count() != 0 {
tokio::task::yield_now().await;
}
}
}
/// Keep simulated peers' metadata ownership separate while sharing disks and lock clients.
#[cfg(test)]
pub(crate) fn set_instance_ctx_for_test(&mut self, ctx: Arc<InstanceContext>) {
@@ -886,6 +882,67 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::list::ListOperations for Sets {
type Error = Error;
type ListObjectsV2Info = ListObjectsV2Info;
type ListObjectVersionsInfo = ListObjectVersionsInfo;
type ObjectInfoOrErr = ObjectInfoOrErr;
type WalkOptions = WalkOptions;
type WalkCancellation = CancellationToken;
type WalkResultSender = tokio::sync::mpsc::Sender<ObjectInfoOrErr>;
#[tracing::instrument(level = "trace", skip(self))]
async fn list_objects_v2(
self: Arc<Self>,
bucket: &str,
prefix: &str,
continuation_token: Option<String>,
delimiter: Option<String>,
max_keys: i32,
fetch_owner: bool,
start_after: Option<String>,
incl_deleted: bool,
) -> Result<ListObjectsV2Info> {
self.inner_list_objects_v2(
bucket,
prefix,
continuation_token,
delimiter,
max_keys,
fetch_owner,
start_after,
incl_deleted,
)
.await
}
#[tracing::instrument(skip(self))]
async fn list_object_versions(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<ListObjectVersionsInfo> {
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
.await
}
async fn walk(
self: Arc<Self>,
rx: CancellationToken,
bucket: &str,
prefix: &str,
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
opts: WalkOptions,
) -> Result<()> {
self.walk_internal(rx, bucket, prefix, result, opts).await
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
type Error = Error;
@@ -1458,7 +1515,9 @@ mod tests {
use crate::layout::endpoint::Endpoint;
use crate::layout::endpoints::SetupType;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::list::ListOperations as _;
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use rustfs_lock::client::local::LocalClient;
use serial_test::serial;
struct SetupTypeGuard {
@@ -2038,6 +2097,100 @@ mod tests {
assert_eq!(second.common_prefixes, expected_prefixes[1..]);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn sets_list_objects_v2_lists_objects_within_the_pool() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let format = FormatV3::new(1, 2);
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
std::mem::forget(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
0,
0,
endpoints.clone(),
format.clone(),
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
)
.await;
let sets = Arc::new(Sets {
id: format.id,
disk_set: vec![set_disks],
pool_idx: 0,
endpoints: PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 2,
endpoints: Endpoints::from(endpoints),
cmd_line: String::new(),
platform: String::new(),
},
format,
parity_count: 1,
set_count: 1,
set_drive_count: 2,
default_parity_count: 1,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx: bootstrap_ctx(),
});
let bucket = format!("bucket-{}", Uuid::new_v4().simple());
let object = format!("object-{}", Uuid::new_v4().simple());
sets.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"hello".to_vec());
sets.put_object(&bucket, &object, &mut reader, &ObjectOptions::default())
.await
.expect("object should be written");
let result = sets
.clone()
.list_objects_v2(&bucket, "", None, None, 1000, false, None, false)
.await
.expect("pool-level listing should succeed");
assert_eq!(result.objects.len(), 1);
assert_eq!(result.objects[0].name, object);
}
#[tokio::test]
async fn sets_check_abandoned_parts_rejects_invalid_set_scope() {
let format = FormatV3::new(1, 1);

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