mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6abb1cba37 | |||
| cc5d6aed29 | |||
| e0fe63c0bb | |||
| b32b9f0bdc | |||
| fe755526c6 | |||
| d374838995 | |||
| 9b5bca04bf | |||
| b8f6aac117 | |||
| 0b556e35fb | |||
| 7d4d6bcc93 | |||
| 4034ce46fb |
@@ -6,17 +6,7 @@
|
||||
- Route every new sync/flush through the durability-mode and bucket override
|
||||
gates; mode `none` must not pay the new fsync.
|
||||
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
|
||||
one small operation into many `spawn_blocking` round trips; coalesce `open`,
|
||||
`read`/`write`, `fsync`, and `close` into one blocking closure per disk.
|
||||
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
|
||||
directory once (`openat2` on Linux) and reuse the handle across sibling reads
|
||||
instead of re-walking ancestor components per file or re-opening identical
|
||||
`source_parent == destination_parent` directories.
|
||||
- After `fstat`/`file.metadata()`, require `Vec::with_capacity(len + 1)` before
|
||||
`read_to_end` so small metadata reads do not trigger 6–7 exponential-growth
|
||||
`read()` syscalls.
|
||||
- Avoid redundant `store.load()` or `root_missing()` round-trips under the same
|
||||
exclusive lock when the state or original bytes were just loaded or persisted.
|
||||
one small operation into many `spawn_blocking` round trips.
|
||||
- Measure lock hold time across I/O and compare acquisition order for ABBA.
|
||||
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
|
||||
section when they need not be there.
|
||||
|
||||
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
|
||||
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
||||
| Confirmation | Complete the gate below and end the turn. |
|
||||
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
|
||||
| 7 | [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.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-linux=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
|
||||
sha256-darwin=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
|
||||
sha256-linux=a5f33e6b51d5e6f6be9cd2c7f3464e3d810500d0064ea2447eae58e60c584b66
|
||||
sha256-darwin=a5f33e6b51d5e6f6be9cd2c7f3464e3d810500d0064ea2447eae58e60c584b66
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
|
||||
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
|
||||
sha256-darwin=c43e78171a8a39bf170473dfcc349c4cb8c1155cdc6a4a8b39398564f23fbcff
|
||||
sha256-linux=fb5598b9e4cb48bd5c775c40b27d4708762d89573ba4bed231aa9f486e415991
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
|
||||
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
|
||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
|
||||
sha256=2db635627cc625fc89af9e1f92a9aa814f9f438b89eaf7667be892ade61b5807
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -52,7 +52,6 @@ script-tests: ## Run shell script tests
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
||||
|
||||
@@ -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"
|
||||
@@ -362,17 +351,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"
|
||||
|
||||
@@ -74,6 +74,7 @@ Notes:
|
||||
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
|
||||
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
|
||||
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
|
||||
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
|
||||
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
|
||||
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"never_ran_grace_until": "2026-09-08T00:00:00Z"
|
||||
},
|
||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
||||
{
|
||||
"workflow": ".github/workflows/runner-hygiene.yml",
|
||||
"max_age_hours": 792,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
|
||||
|
||||
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
|
||||
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
|
||||
|
||||
| Label | Paid by | What it is | Use it for |
|
||||
|---|---|---|---|
|
||||
@@ -15,7 +15,7 @@ Every other label in this repository is capacity RustFS pays for. ARC pods and l
|
||||
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
|
||||
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
|
||||
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
|
||||
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
|
||||
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
|
||||
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
|
||||
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
|
||||
|
||||
@@ -40,7 +40,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
|
||||
|
||||
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
|
||||
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
|
||||
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
|
||||
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
|
||||
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
|
||||
|
||||
## Where self-hosted minutes leak
|
||||
@@ -62,7 +62,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
|
||||
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
|
||||
```
|
||||
|
||||
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||
|
||||
## Checklist for a new or moved job
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ on:
|
||||
- 'scripts/test_package_service_scripts.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/test_docker_workflow.py'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
@@ -47,6 +48,7 @@ on:
|
||||
- 'scripts/test_package_service_scripts.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/test_docker_workflow.py'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
@@ -153,6 +155,9 @@ jobs:
|
||||
- name: Check preview release workflow policy
|
||||
run: ./scripts/security/check_preview_release_workflow.sh
|
||||
|
||||
- name: Check performance A/B workflow trust boundary
|
||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
||||
|
||||
- name: Check tier evidence workflow isolation
|
||||
run: ./scripts/security/check_tier_artifact_workflow.sh
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
name: BTF prototype reference
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/inspect_btf_prototypes.py"
|
||||
- "scripts/test_inspect_btf_prototypes.py"
|
||||
- "scripts/fixtures/btf_prototypes.c"
|
||||
- ".github/workflows/btf-prototype-reference.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: btf-prototype-reference-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reference:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check prototype and CLI contracts
|
||||
run: python3 -B scripts/test_inspect_btf_prototypes.py
|
||||
- name: Install only missing native reference tools
|
||||
run: |
|
||||
select_bpftool() {
|
||||
local candidate
|
||||
local candidates=()
|
||||
if command -v bpftool >/dev/null; then
|
||||
candidates+=("$(command -v bpftool)")
|
||||
fi
|
||||
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
candidates+=("$candidate")
|
||||
fi
|
||||
done
|
||||
while IFS= read -r candidate; do
|
||||
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
|
||||
&& "$candidate" version >/dev/null 2>&1; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
|
||||
return 1
|
||||
}
|
||||
packages=()
|
||||
command -v clang >/dev/null || packages+=(clang)
|
||||
command -v llvm-objcopy >/dev/null || packages+=(llvm)
|
||||
select_bpftool >/dev/null || packages+=(linux-tools-generic)
|
||||
if (( ${#packages[@]} )); then
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y "${packages[@]}"
|
||||
fi
|
||||
# File parsing needs a working binary, not a host-kernel wrapper.
|
||||
selected_bpftool="$(select_bpftool)"
|
||||
"$selected_bpftool" version
|
||||
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
|
||||
- name: Compare compiled declarations with bpftool
|
||||
run: |
|
||||
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
|
||||
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
|
||||
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf"
|
||||
@@ -181,18 +181,12 @@ jobs:
|
||||
selected="all"
|
||||
fi
|
||||
|
||||
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
|
||||
# so the packaged binary and console smoke execute the artifact.
|
||||
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
|
||||
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
|
||||
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
|
||||
# pins the glibc 2.31 floor on the same architecture; musl stays a
|
||||
# native cargo build.
|
||||
|
||||
all='{"include":[
|
||||
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
|
||||
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
|
||||
]}'
|
||||
@@ -250,11 +244,7 @@ jobs:
|
||||
with:
|
||||
rust-version: stable
|
||||
target: ${{ matrix.target }}
|
||||
# rust-cache keys include OS and architecture but not the runner image.
|
||||
# The Ubicloud aarch64 legs therefore use their own suffix so they
|
||||
# cannot restore a cache written on a different arm64 image.
|
||||
# x86_64, macOS, and Windows keys stay unsuffixed.
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
|
||||
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
||||
# other tag, no main run and no PR can restore it, so every release
|
||||
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
||||
@@ -774,9 +764,9 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
|
||||
# images follow the GitHub runner image and often do. Install the
|
||||
# official CLI bundle when it is missing so R2 uploads work on either.
|
||||
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
|
||||
# images did). Install the official CLI bundle on demand so R2 uploads
|
||||
# survive a fresh runner without relying on a distro package.
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
echo "aws CLI not found on runner; installing..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
|
||||
@@ -45,7 +45,7 @@ on:
|
||||
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
|
||||
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
||||
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
||||
# and e2e-replication-nightly (04:00) lanes.
|
||||
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
|
||||
- cron: "43 7 * * 0"
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -38,8 +38,8 @@ name: e2e-nightly
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
|
||||
# and stale (01:30).
|
||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
|
||||
# stale (01:30) and performance-ab (06:00).
|
||||
- cron: "29 4 * * *"
|
||||
|
||||
# Only alert-on-failure needs more than read access; it declares its own
|
||||
|
||||
@@ -19,11 +19,9 @@ 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/**"
|
||||
@@ -130,7 +128,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: |
|
||||
@@ -188,28 +185,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
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
|
||||
#
|
||||
# Two entry points, honestly scoped:
|
||||
# * schedule (nightly, on main): post-merge detection — catches a regression
|
||||
# within 24h of landing, not before merge.
|
||||
# * workflow_dispatch: an explicitly selected trusted ref.
|
||||
# The dispatch input can run the gate with --allow-regression so a deliberate
|
||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
||||
# blocked (rustfs/backlog#935 correction 1).
|
||||
|
||||
name: Performance A/B
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
duration:
|
||||
description: "warp duration per round"
|
||||
required: false
|
||||
default: "12s"
|
||||
type: string
|
||||
allow_regression:
|
||||
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
warp-ab:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Warp A/B budget gate
|
||||
runs-on: sm-standard-2
|
||||
# A normal nightly restores the last successful binary and builds only the
|
||||
# candidate; daily access keeps that cache warm. A cache miss may build both
|
||||
# and needs room for the A/B run plus artifact and cache publication.
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # baseline may be an earlier successful scheduled head
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install warp
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WARP_VERSION="1.0.0"
|
||||
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
|
||||
| sudo tar -xz -C /usr/local/bin warp
|
||||
warp --version
|
||||
|
||||
- name: Decide exemption
|
||||
id: exempt
|
||||
env:
|
||||
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
||||
run: |
|
||||
allow="false"
|
||||
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
||||
allow="true"
|
||||
fi
|
||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A failed regression run must keep comparing against the last known-good
|
||||
# scheduled head. Otherwise the next nightly would absorb the regression
|
||||
# into its baseline and turn green without a fix.
|
||||
- name: Find last successful scheduled baseline
|
||||
id: scheduled_baseline
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "performance-ab.yml",
|
||||
event: "schedule",
|
||||
status: "success",
|
||||
per_page: 1,
|
||||
});
|
||||
return data.workflow_runs[0]?.head_sha ?? "";
|
||||
|
||||
# Manual runs compare a selected ref with current main. Scheduled runs
|
||||
# compare current main with the last successful scheduled head. With no
|
||||
# history, the first run measures the candidate against itself and seeds
|
||||
# that head only if the complete rig succeeds.
|
||||
- name: Resolve baseline / candidate commits
|
||||
id: commits
|
||||
env:
|
||||
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
candidate_sha="$(git rev-parse HEAD)"
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
|
||||
else
|
||||
baseline_sha="$(git rev-parse origin/main)"
|
||||
fi
|
||||
git cat-file -e "${baseline_sha}^{commit}"
|
||||
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
|
||||
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "baseline commit: $baseline_sha"
|
||||
echo "candidate commit: $candidate_sha"
|
||||
|
||||
# Exact-key restore of the candidate binary saved by its successful
|
||||
# scheduled run. A miss leaves cache-hit unset and falls back to a source
|
||||
# build of that known-good head.
|
||||
- name: Restore cached baseline binary
|
||||
id: baseline_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
|
||||
|
||||
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
|
||||
# baseline commit, a cache miss would make the rig build the same commit
|
||||
# twice (~65min per side with the post-#4806 LTO profile — no job budget
|
||||
# fits that). Build it once here, reuse it for both phases, and save it
|
||||
# back to the cache so the next run hits.
|
||||
- name: Build baseline on cache miss (same-commit self-heal)
|
||||
id: selfheal
|
||||
if: >-
|
||||
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
||||
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --release --bin rustfs
|
||||
mkdir -p baseline-bin
|
||||
cp target/release/rustfs baseline-bin/rustfs
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build baseline on cache miss (different candidate)
|
||||
id: baseline_build
|
||||
if: >-
|
||||
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
|
||||
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
|
||||
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
|
||||
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
|
||||
mkdir -p baseline-bin
|
||||
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
|
||||
git worktree remove --force "$baseline_root"
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build candidate binary
|
||||
id: candidate_build
|
||||
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --release --bin rustfs
|
||||
mkdir -p candidate-bin
|
||||
cp target/release/rustfs candidate-bin/rustfs
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Save self-healed baseline to cache
|
||||
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
|
||||
|
||||
- name: Run warp A/B and gate
|
||||
id: ab
|
||||
env:
|
||||
INPUT_DURATION: ${{ github.event.inputs.duration }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
|
||||
# -> A2 baseline for each workload and drive-sync cell. It requires three
|
||||
# rounds per leg to emit tail latency and error-rate evidence.
|
||||
# --health-timeout 180 outlasts the server's own 120s startup-readiness
|
||||
# budget, which the rig's previous 60s health poll undershot (the first
|
||||
# two nightly failures). perf-6 recalibrates these once the noise study
|
||||
# lands.
|
||||
duration="${INPUT_DURATION:-12s}"
|
||||
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
||||
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
||||
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
||||
selfheal_built="${{ steps.selfheal.outputs.built }}"
|
||||
baseline_built="${{ steps.baseline_build.outputs.built }}"
|
||||
candidate_built="${{ steps.candidate_build.outputs.built }}"
|
||||
|
||||
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
|
||||
|
||||
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
|
||||
chmod +x baseline-bin/rustfs
|
||||
base_bin="$PWD/baseline-bin/rustfs"
|
||||
args+=(--baseline-bin "$base_bin")
|
||||
if [[ "$baseline_hit" == "true" ]]; then
|
||||
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
|
||||
elif [[ "$selfheal_built" == "true" ]]; then
|
||||
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
||||
else
|
||||
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
|
||||
fi
|
||||
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
||||
# No commits landed since the last successful baseline, so reuse
|
||||
# the one binary for both phases and measure only rig drift.
|
||||
args+=(--candidate-bin "$base_bin")
|
||||
cand_src="same binary as baseline (same commit)"
|
||||
elif [[ "$candidate_built" == "true" ]]; then
|
||||
chmod +x candidate-bin/rustfs
|
||||
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
|
||||
cand_src="source build of the checked-out ref"
|
||||
else
|
||||
echo "::error::candidate binary was not built" >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
echo "::error::baseline binary was not restored or built" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "baseline binary: $base_src"
|
||||
echo "candidate binary: $cand_src"
|
||||
|
||||
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
||||
args+=(--allow-regression --exemption-reason "workflow dispatch override")
|
||||
fi
|
||||
# Do not let a gate FAIL abort the job here; capture status and surface
|
||||
# it after the step summary is written.
|
||||
set +e
|
||||
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
|
||||
echo "status=$?" >> "$GITHUB_OUTPUT"
|
||||
set -e
|
||||
# Locate the newest run dir + gate.md for the summary/comment/artifact
|
||||
# steps. On a startup failure there is no gate.md, but the run dir still
|
||||
# holds server-logs/ for diagnosis.
|
||||
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
|
||||
# shellcheck disable=SC2012
|
||||
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
|
||||
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
|
||||
# shellcheck disable=SC2012
|
||||
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
|
||||
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload A/B results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: hotpath-warp-ab-${{ github.run_number }}
|
||||
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
|
||||
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
|
||||
# is diagnosable. Short retention: this is churny nightly debug data.
|
||||
path: target/hotpath-abba/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Write gate summary
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
gate_md="${{ steps.ab.outputs.gate_md }}"
|
||||
run_dir="${{ steps.ab.outputs.run_dir }}"
|
||||
{
|
||||
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
|
||||
echo
|
||||
if [[ "$status" == "0" ]]; then
|
||||
echo "Rig/gate exit: \`0\` (pass or warn)."
|
||||
else
|
||||
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
|
||||
fi
|
||||
echo
|
||||
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
|
||||
cat "$gate_md"
|
||||
else
|
||||
echo "No \`gate.md\` produced — the rig failed **before** the gate"
|
||||
echo "(most likely server startup / health). Failing phase(s) below;"
|
||||
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
|
||||
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
|
||||
for f in "$run_dir"/server-logs/*.log; do
|
||||
[[ -f "$f" ]] || continue
|
||||
echo
|
||||
echo "<details><summary>$(basename "$f")</summary>"
|
||||
echo
|
||||
echo '```'
|
||||
tail -n 30 "$f"
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Stage successful candidate baseline
|
||||
if: >-
|
||||
steps.ab.outputs.status == '0' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp candidate-bin/rustfs baseline-bin/rustfs
|
||||
|
||||
- name: Cache successful candidate baseline
|
||||
if: >-
|
||||
steps.ab.outputs.status == '0' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
|
||||
|
||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
||||
|
||||
- name: Enforce gate
|
||||
if: always()
|
||||
run: |
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
if [[ -z "$status" ]]; then
|
||||
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$status" != "0" ]]; then
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
|
||||
exit "$status"
|
||||
fi
|
||||
echo "warp A/B budget gate passed."
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [warp-ab]
|
||||
# `always()` is required: without it this job is skipped when a needed
|
||||
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
|
||||
# ci-8); manual dispatch failures are already watched by a human.
|
||||
# `cancelled` is included alongside `failure` on purpose: a job that hits
|
||||
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
|
||||
# timeouts went silent precisely because the guard was failure-only. The
|
||||
# composite action already reports cancelled/timed-out jobs in the issue
|
||||
# body.
|
||||
if: >-
|
||||
github.repository == 'rustfs/rustfs' && (
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
|
||||
@@ -16,8 +16,8 @@
|
||||
#
|
||||
# Forces a job failure and then runs .github/actions/schedule-failure-issue
|
||||
# through the exact `needs` + `always() && contains(needs.*.result,
|
||||
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
|
||||
# Dispatch it twice to verify both the issue-creation and
|
||||
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
|
||||
# performance-ab). Dispatch it twice to verify both the issue-creation and
|
||||
# the dedupe-comment paths, then close the resulting
|
||||
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
|
||||
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
- "mint"
|
||||
- "minio-interop"
|
||||
- "Nightly GNU Build"
|
||||
- "Performance A/B"
|
||||
- "Runner Hygiene"
|
||||
types: [completed]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+132
-210
@@ -255,7 +255,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -266,7 +266,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -365,9 +365,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "arrow"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f04e0a89cc175eaf3740282537851e6fd9d7276f97730b9d0ea2859c11a85df"
|
||||
checksum = "4c25317ebddb8f2e6f006759336c95e5ded847a446f3524c27853bfe7d77a69b"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -386,9 +386,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-arith"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "157943c720919e8f5b6731407ce5cd4c9a5ef47a75bf10edaf24b7081b5763ef"
|
||||
checksum = "2ab555c0ffe9cb817041c0cee80baa9663a22225d0a035693579771f2693cadc"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -400,9 +400,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-array"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b5b1682924f11ecf6929689c16cbc69efbb6dd141b06ad98ee1cb10d858c43"
|
||||
checksum = "2dae1d91737421e92c6dd7c7f9d0f2d25198fd6fc539738b628f4dad10adf533"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arrow-buffer",
|
||||
@@ -419,9 +419,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-buffer"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5d687afc6c927965a8a759f3b68de91933d9dfa2e1bb6e156bac84b85665e92"
|
||||
checksum = "1fe9cd0064f2d7cf8ecfaf77cd70e67a980bc71aea92b63102bd37a7d3379a09"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"half",
|
||||
@@ -431,9 +431,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-cast"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f23c3379feede42135ef27ca60e67f6e643cd4343ae74e4c8b16c83f55afd85"
|
||||
checksum = "ee510bc252781d700400979a96bd1eadebeae23901132bbabd92118da1b376c5"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -453,9 +453,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-csv"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85b1984628b091b8319b3c305fce21d04f9e9b2578bb733b0d0108462ae1d698"
|
||||
checksum = "ace83d5935421b950fea3a0a3f9972c0837c297ef98ade57127a5661b681112c"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-cast",
|
||||
@@ -468,9 +468,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-data"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0a031c7ff5e2bdb443117054fb466af1d4df5b7f0ecbe197713dc8b113b1ba"
|
||||
checksum = "54f128c73bb7fe9f7137218f910edd5b436f390e0f040b1956f5028390787897"
|
||||
dependencies = [
|
||||
"arrow-buffer",
|
||||
"arrow-schema",
|
||||
@@ -481,9 +481,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-ipc"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5dd1afadda2eb821aaf851bb4c0adef60c30cc0f1a29bd02fb99dd07fb9f58d"
|
||||
checksum = "8e244f39b82fa7a823e8b5984a0bd988234f9c92c1df79378fb85090e6e4c821"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -497,9 +497,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-json"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2bc4d56a0dad84cc51c5812ccff48b642047a1f3be80349cd84318983e79edb"
|
||||
checksum = "727b21ebf8894dcd9504d6ab66bffdac338391e1f85cde594526a0210299ea40"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -522,9 +522,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-ord"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "561b8209faa2cd489764b7fd07d7cb9e8d274d78048c38de59f77c41140cb834"
|
||||
checksum = "70d376b9b883239ae0f9963cdeeb59126ccaac08d60adf208a6ad3e2dd4048c3"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -535,9 +535,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-row"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c68b87a569f92b90c46cb0a09d86bd9609ddc9b1bf1fc5a503c8c6907c14e"
|
||||
checksum = "48096811f92b5c3a0c59d33cfecd7d7d2ed037d81e54a27a42d232e7700e6de2"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -548,9 +548,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-schema"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e87acb0c6bf6237fca7a50d7b51e801c98a18a5fe6f3bb34f7d77b85ca165388"
|
||||
checksum = "37b6c8a6b12cf3869d299ce36ed8e1cd6fe9836264b68fb6c6538473a36124a1"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
@@ -558,9 +558,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-select"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c844141958d805b47d1208e0d12aac53fcf388ce8011639fe82aa492adde0bb4"
|
||||
checksum = "e523e85fa9fb3181a391bf0edfa6a1ae0dc46df8cbf7f6b96034dcb1e2a7a04a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arrow-array",
|
||||
@@ -572,9 +572,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-string"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc130892b1a9552da77971924b390e06cf375d1f7e4344a7b7b40ca4426fa470"
|
||||
checksum = "b931a905a0e4b8832d9f54fb369cf6e545f9dc03cb3d9611216a7bd2653df60a"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -1461,6 +1461,12 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -1514,23 +1520,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.73.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "binrw"
|
||||
version = "0.15.2"
|
||||
@@ -1629,7 +1618,7 @@ version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1648,7 +1637,7 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1743,13 +1732,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.0"
|
||||
version = "9.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6737ce8b81460bf9407909f73ff50bf13f7cb8577e1e76715f57ed0eba58ba44"
|
||||
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
"brotli-decompressor 6.2.0",
|
||||
"brotli-decompressor 6.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1764,9 +1753,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.2.0"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
|
||||
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
@@ -1880,7 +1869,7 @@ dependencies = [
|
||||
"maybe-owned",
|
||||
"rustix",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
@@ -1984,15 +1973,6 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
@@ -2093,7 +2073,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
@@ -2109,17 +2089,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
@@ -2236,7 +2205,7 @@ version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||
dependencies = [
|
||||
"brotli 9.1.0",
|
||||
"brotli 9.0.0",
|
||||
"bzip2",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
@@ -2244,7 +2213,7 @@ dependencies = [
|
||||
"lz4",
|
||||
"memchr",
|
||||
"zstd 0.14.3",
|
||||
"zstd-safe",
|
||||
"zstd-safe 8.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2327,9 +2296,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "const_format"
|
||||
version = "0.2.37"
|
||||
version = "0.2.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454"
|
||||
checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
|
||||
dependencies = [
|
||||
"const_format_proc_macros",
|
||||
"konst",
|
||||
@@ -2337,9 +2306,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "const_format_proc_macros"
|
||||
version = "0.2.37"
|
||||
version = "0.2.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659"
|
||||
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2579,9 +2548,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.10"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
|
||||
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -2596,11 +2565,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -2657,9 +2626,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.6"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -3669,9 +3638,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.3"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
|
||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
||||
dependencies = [
|
||||
"const-oid 0.10.2",
|
||||
"pem-rfc7468 1.0.0",
|
||||
@@ -3941,7 +3910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -3976,7 +3945,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4077,7 +4046,6 @@ dependencies = [
|
||||
"rustfs-utils",
|
||||
"rustls",
|
||||
"s3s",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -4104,7 +4072,7 @@ version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||
dependencies = [
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
@@ -4169,9 +4137,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.14.3"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
|
||||
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
@@ -4290,7 +4258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4607,9 +4575,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -4621,7 +4589,7 @@ version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"rustversion",
|
||||
"typenum",
|
||||
]
|
||||
@@ -5273,9 +5241,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath"
|
||||
version = "0.29.2"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b4af2b5ecf2650278fe33b0e49b68c8c484d2306475c127628316dea740cf5c"
|
||||
checksum = "73f137bde4c97275aef220f3a1566f39721fbd53086a444c8d841b5f345719dc"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -5308,15 +5276,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-drain"
|
||||
version = "0.29.2"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5cceb6c15fbaaeb00b51acc7be7fad533d0b56cb422b1cfc16cf9ca1624d33a"
|
||||
checksum = "87df2604170a3a0b512a837eba4d5d18671e54df8d450cc894af36d41291dba5"
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-macros"
|
||||
version = "0.29.2"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c0a42eb299ebb7ec6b9161489e5bf132603a34ca12cecadb8490937aa0492a"
|
||||
checksum = "25dcd4a80153dd66b34b8ae5d02572d71cc3ff55aff397578cd471d645a8a740"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5645,7 +5613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6296,9 +6264,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.23.0"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
|
||||
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
|
||||
dependencies = [
|
||||
"sha2 0.11.1",
|
||||
]
|
||||
@@ -6436,7 +6404,7 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"indexmap 2.14.2",
|
||||
"metrics",
|
||||
"ordered-float 5.5.1",
|
||||
"ordered-float 5.5.0",
|
||||
"quanta",
|
||||
"radix_trie",
|
||||
"rand 0.9.5",
|
||||
@@ -6903,7 +6871,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7036,7 +7004,7 @@ name = "oauth2"
|
||||
version = "5.1.0"
|
||||
source = "git+https://github.com/ramosbugs/oauth2-rs?rev=89585f4a280afd7b285eaf1c9be0068874e03d22#89585f4a280afd7b285eaf1c9be0068874e03d22"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
@@ -7370,9 +7338,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.5.1"
|
||||
version = "5.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
|
||||
checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
@@ -7489,9 +7457,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pageant"
|
||||
version = "0.2.5"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0bc922d9aa8cfcf598de27b3c6122cb2eeda9d6fe71527a22ebcb7f633540ff"
|
||||
checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"byteorder",
|
||||
@@ -7560,9 +7528,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parquet"
|
||||
version = "59.3.2"
|
||||
version = "59.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eb260d566f2fbdabe356d26aaa5d13403aac29b5b4e44463b8f9bd7b2b12680"
|
||||
checksum = "8b3d2ef0fb136e9341570f219ecb168902e76079c6d81ad555f0a4840b1e08a3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arrow-array",
|
||||
@@ -7847,7 +7815,7 @@ version = "0.8.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
||||
dependencies = [
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
"spki 0.8.1",
|
||||
]
|
||||
|
||||
@@ -7890,7 +7858,7 @@ dependencies = [
|
||||
"aes 0.9.3",
|
||||
"aes-gcm",
|
||||
"cbc 0.2.1",
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
"pbkdf2 0.13.0",
|
||||
"rand_core 0.10.1",
|
||||
"scrypt 0.12.0",
|
||||
@@ -7914,7 +7882,7 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||
dependencies = [
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
"pkcs5 0.8.1",
|
||||
"rand_core 0.10.1",
|
||||
"spki 0.8.1",
|
||||
@@ -8612,7 +8580,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9176,9 +9144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "russh"
|
||||
version = "0.65.0"
|
||||
version = "0.64.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bb77a4b259312b2abbc541bca1c13cb4e9b61c560a7fd894068c3b92e0c9dc3"
|
||||
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"aws-lc-rs",
|
||||
@@ -9193,7 +9161,7 @@ dependencies = [
|
||||
"curve25519-dalek 5.0.0",
|
||||
"data-encoding",
|
||||
"delegate",
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
"digest 0.11.3",
|
||||
"ecdsa",
|
||||
"ed25519-dalek 3.0.0",
|
||||
@@ -10157,18 +10125,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-mimalloc"
|
||||
version = "0.7.2"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8301a578772fc9823d68e083e42c3b414613d588a0eac594c1ca6bc0bbe0653f"
|
||||
checksum = "91c0ac4b4c6f8246bb843c985b47f7f8ea3e654e02d965a5d6c19fc006813584"
|
||||
dependencies = [
|
||||
"rustfs-mimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-mimalloc-sys"
|
||||
version = "0.7.2"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6eb4082853043cb29556b4b776b05f28c3146706f43c53869dbf1ef262e00e91"
|
||||
checksum = "265332babe807f64b800b1a47d6d39e6897f249c153b962a6b1050f073816342"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -10884,7 +10852,7 @@ version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"blake2",
|
||||
"brotli 9.1.0",
|
||||
"brotli 9.0.0",
|
||||
"bytes",
|
||||
"convert_case 0.12.0",
|
||||
"crc-fast",
|
||||
@@ -10992,7 +10960,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11075,7 +11043,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11345,7 +11313,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ctutils",
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
"hybrid-array",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -11511,15 +11479,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -11799,9 +11758,9 @@ checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce"
|
||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
@@ -11845,9 +11804,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.4"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
|
||||
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -11886,7 +11845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11930,7 +11889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der 0.8.3",
|
||||
"der 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12037,7 +11996,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12343,10 +12302,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12679,30 +12638,6 @@ dependencies = [
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -12719,9 +12654,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"toml_datetime 1.1.2+spec-1.1.0",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12730,15 +12665,9 @@ version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
@@ -13049,9 +12978,9 @@ checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
@@ -13470,7 +13399,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13765,12 +13694,6 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
@@ -14037,9 +13960,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.2"
|
||||
version = "9.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6"
|
||||
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"bzip2",
|
||||
@@ -14058,7 +13981,6 @@ dependencies = [
|
||||
"sha1 0.11.0",
|
||||
"time",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd 0.14.3",
|
||||
@@ -14091,18 +14013,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"apache-avro",
|
||||
"arrow-array",
|
||||
"arrow-ipc",
|
||||
"arrow-schema",
|
||||
"bytes",
|
||||
"kafka-protocol",
|
||||
"parquet",
|
||||
"semver",
|
||||
"toml",
|
||||
"zstd 0.14.3",
|
||||
"zstd-safe",
|
||||
"zstd-safe 7.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14111,7 +14025,16 @@ version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
"zstd-safe 8.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14129,7 +14052,6 @@ version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
+6
-9
@@ -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"
|
||||
|
||||
@@ -261,7 +260,7 @@ aws-smithy-http-client = { default-features = false, version = "1.5.1" }
|
||||
aws-smithy-runtime-api = { version = "1.19.1" }
|
||||
aws-smithy-types = { version = "1.9.0" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "9.1.0"
|
||||
brotli = "9.0.0"
|
||||
clap = { version = "4.6.7" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.12.0"
|
||||
@@ -303,7 +302,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"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
@@ -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" }
|
||||
@@ -356,7 +354,7 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "9.1.2"
|
||||
zip = "9.0.5"
|
||||
zstd = "0.14.3"
|
||||
|
||||
# Observability and Metrics
|
||||
@@ -377,16 +375,16 @@ libunftp = { version = "0.23.1" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.2.1" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.65.0" }
|
||||
russh = { version = "0.64.1" }
|
||||
russh-sftp = "3.0.1"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.7.2" }
|
||||
rustfs-mimalloc = { version = "0.7.1" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.29.2", default-features = false }
|
||||
hotpath = { version = "0.29.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
@@ -426,7 +424,6 @@ 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" }
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,7 +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
|
||||
|
||||
@@ -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
-111
@@ -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,
|
||||
@@ -2499,7 +2438,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 +2455,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 +2468,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 +2555,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));
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,18 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
|
||||
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
||||
wait_for_ready, wait_until,
|
||||
};
|
||||
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||
use crate::common::{build_test_s3_config, init_logging};
|
||||
use crate::fault_proxy::FaultMode;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Barrier, mpsc};
|
||||
use tokio::time::{Instant, timeout};
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||
@@ -46,133 +44,6 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start_with_env(
|
||||
DistLayout::FourByFour,
|
||||
&[
|
||||
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
|
||||
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||
let storage: serde_json::Value = serde_json::from_str(&storage)?;
|
||||
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
|
||||
assert_eq!(storage.disks.len(), 16);
|
||||
assert_eq!(storage.backend.total_sets, vec![1]);
|
||||
assert_eq!(storage.backend.drives_per_set, vec![16]);
|
||||
assert_eq!(storage.backend.standard_sc_parity, Some(4));
|
||||
|
||||
let bucket = unique_bucket("partial-ec4");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = s3_probe_client(&dist.client(0)?);
|
||||
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
|
||||
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
for node in &dist.cluster.nodes {
|
||||
for directory in &node.data_dirs {
|
||||
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
|
||||
if !census.is_complete() {
|
||||
return Ok(false);
|
||||
}
|
||||
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
|
||||
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
|
||||
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
"the baseline is complete on all sixteen EC:4 drives",
|
||||
)
|
||||
.await?;
|
||||
|
||||
dist.cluster.stop_node(2)?;
|
||||
dist.cluster.stop_node(3)?;
|
||||
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
|
||||
.await?
|
||||
.expect_err("eight unavailable drives must prevent the baseline GET");
|
||||
assert!(
|
||||
matches!(
|
||||
s3_probe_error_code(read_error.as_ref()),
|
||||
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
|
||||
),
|
||||
"quorum loss must produce an availability error: {read_error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_error
|
||||
.downcast_ref::<SdkError<GetObjectError>>()
|
||||
.and_then(SdkError::raw_response)
|
||||
.map(|response| response.status().as_u16()),
|
||||
Some(503),
|
||||
"read quorum loss must remain a service availability failure"
|
||||
);
|
||||
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
|
||||
.await?
|
||||
.expect_err("eight unavailable drives must prevent a successful PUT");
|
||||
assert!(
|
||||
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
|
||||
"quorum loss must produce an availability error: {write_error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
write_error
|
||||
.downcast_ref::<SdkError<PutObjectError>>()
|
||||
.and_then(SdkError::raw_response)
|
||||
.map(|response| response.status().as_u16()),
|
||||
Some(503),
|
||||
"write quorum loss must remain a service availability failure"
|
||||
);
|
||||
|
||||
// Recover only four drives. The remaining node must stay stopped while
|
||||
// every live coordinator serves old data and acknowledges new writes.
|
||||
dist.cluster.start_node_process(2).await?;
|
||||
let recovery_deadline = Instant::now() + Duration::from_secs(90);
|
||||
let mut recovered_objects = Vec::new();
|
||||
for node in 0..3 {
|
||||
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
|
||||
let client = s3_probe_client(&dist.client(node)?);
|
||||
retrying_get_equals(
|
||||
&client,
|
||||
&bucket,
|
||||
"baseline.bin",
|
||||
&baseline,
|
||||
recovery_deadline.saturating_duration_since(Instant::now()),
|
||||
)
|
||||
.await?;
|
||||
let key = format!("partial-{node}.bin");
|
||||
let body = payload_for(&key, 1024 * 1024);
|
||||
retrying_put(
|
||||
&client,
|
||||
&bucket,
|
||||
&key,
|
||||
body.clone(),
|
||||
recovery_deadline.saturating_duration_since(Instant::now()),
|
||||
)
|
||||
.await?;
|
||||
recovered_objects.push((key, body));
|
||||
}
|
||||
for node in 0..3 {
|
||||
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
|
||||
let client = s3_probe_client(&dist.client(node)?);
|
||||
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
|
||||
for (key, body) in &recovered_objects {
|
||||
assert_object_bytes(&client, &bucket, key, body).await?;
|
||||
}
|
||||
}
|
||||
|
||||
dist.cluster.start_node(3).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
let client = s3_probe_client(&dist.client(3)?);
|
||||
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
|
||||
for (key, body) in &recovered_objects {
|
||||
assert_object_bytes(&client, &bucket, key, body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -34,7 +34,7 @@ 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;
|
||||
@@ -185,12 +185,13 @@ impl DistCluster {
|
||||
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(),
|
||||
))
|
||||
Ok(Client::from_conf(build_test_s3_config(
|
||||
&self.cluster.nodes[node_idx].url,
|
||||
access_key,
|
||||
secret_key,
|
||||
None,
|
||||
"cluster-iam-test",
|
||||
)))
|
||||
}
|
||||
|
||||
/// Stop the fleet, drop retired pools from the command line, and restart
|
||||
@@ -346,10 +347,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);
|
||||
}
|
||||
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||
if let Some(directory) = capture_dir {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
for node_idx in 0..cluster.nodes.len() {
|
||||
@@ -1181,16 +1179,11 @@ pub(crate) async fn retrying_get_equals(
|
||||
)
|
||||
.into()),
|
||||
Err(error) => {
|
||||
let retryable = match s3_probe_error_code(error.as_ref()) {
|
||||
Some("SlowDown" | "ServiceUnavailable") => true,
|
||||
// GET preserves this compatibility code for a lost data quorum.
|
||||
Some("SlowDownRead") => error
|
||||
.downcast_ref::<SdkError<GetObjectError>>()
|
||||
.and_then(SdkError::raw_response)
|
||||
.is_some_and(|response| response.status().as_u16() == 503),
|
||||
_ => false,
|
||||
};
|
||||
if retryable { Ok(false) } else { Err(error) }
|
||||
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1329,62 +1322,6 @@ mod retry_tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
|
||||
use aws_sdk_s3::config::Region;
|
||||
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
|
||||
use aws_smithy_types::body::SdkBody;
|
||||
|
||||
let endpoint = "http://read-quorum.example.invalid";
|
||||
let event = |status: u16, body: &'static str| {
|
||||
ReplayEvent::new(
|
||||
http::Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
|
||||
.body(SdkBody::empty())
|
||||
.expect("build replay request"),
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/xml")
|
||||
.header("content-length", body.len())
|
||||
.body(SdkBody::from(body))
|
||||
.expect("build replay response"),
|
||||
)
|
||||
};
|
||||
for status in [503, 403, 500] {
|
||||
let transport = StaticReplayClient::new(vec![
|
||||
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
|
||||
event(200, "expected bytes"),
|
||||
]);
|
||||
let client = Client::from_conf(
|
||||
aws_sdk_s3::Config::builder()
|
||||
.behavior_version_latest()
|
||||
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(endpoint)
|
||||
.force_path_style(true)
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.http_client(transport.clone())
|
||||
.build(),
|
||||
);
|
||||
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
|
||||
if status == 503 {
|
||||
result?;
|
||||
assert_eq!(
|
||||
transport.actual_requests().count(),
|
||||
2,
|
||||
"the probe must retry the read-quorum error exactly once"
|
||||
);
|
||||
transport.assert_requests_match(&[]);
|
||||
} else {
|
||||
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
|
||||
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
|
||||
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
|
||||
let target = FakeS3Target::start().await?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -26,19 +26,14 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -216,10 +216,6 @@ pub(crate) struct TopologyRun {
|
||||
|
||||
impl TopologyRun {
|
||||
pub(crate) async fn start(label: &str) -> TestResult<Self> {
|
||||
Self::start_with_env(label, &[]).await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_with_env(label: &str, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
||||
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
|
||||
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
|
||||
// and evidence there so a failed lane can be diagnosed.
|
||||
@@ -240,7 +236,6 @@ impl TopologyRun {
|
||||
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
|
||||
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
|
||||
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
|
||||
env.extend_from_slice(extra_env);
|
||||
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
|
||||
dist.capture_logs_under(&log_dir)?;
|
||||
dist.cluster.start().await?;
|
||||
@@ -334,11 +329,6 @@ impl FileBarrier {
|
||||
&self.nonce
|
||||
}
|
||||
|
||||
/// Keep the previous phase's release handle while arming a fresh nonce.
|
||||
pub(crate) fn next_phase(&self) -> Self {
|
||||
Self::new(self.dir.clone())
|
||||
}
|
||||
|
||||
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
|
||||
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
|
||||
std::fs::write(&tmp, body)?;
|
||||
@@ -370,10 +360,6 @@ impl FileBarrier {
|
||||
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
|
||||
}
|
||||
|
||||
pub(crate) fn release_movement(&self) -> TestResult {
|
||||
self.write_atomic(&format!("release.{}", self.nonce), b"continue")
|
||||
}
|
||||
|
||||
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
|
||||
let prefix = format!("reached.{}.", self.nonce);
|
||||
let mut observations = Vec::new();
|
||||
|
||||
@@ -528,7 +528,7 @@ async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_w
|
||||
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 outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::{DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk};
|
||||
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::{
|
||||
@@ -59,24 +55,5 @@ pub(crate) mod replication_extension {
|
||||
|
||||
#[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,
|
||||
};
|
||||
pub(crate) use super::{DiskOption, Endpoint, new_disk};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject,
|
||||
use crate::replication_extension_test::{
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
||||
};
|
||||
use crate::storage_api::upgrade_compatibility::RUSTFS_META_MULTIPART_BUCKET;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
@@ -53,8 +52,6 @@ const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
|
||||
const PENDING_MULTIPART_BUCKET: &str = "upgrade-mpu-continuity";
|
||||
const PENDING_MULTIPART_KEY: &str = "pending/legacy-multipart.bin";
|
||||
const MIXED_NODE_COUNT: usize = 4;
|
||||
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
|
||||
// Written by the "previous-seed" readiness probe through node 0 before any
|
||||
@@ -304,27 +301,18 @@ async fn read_object(
|
||||
Ok((encryption, body))
|
||||
}
|
||||
|
||||
struct PendingMultipart {
|
||||
upload_id: String,
|
||||
completed_parts: Vec<CompletedPart>,
|
||||
part_sizes: Vec<i64>,
|
||||
}
|
||||
|
||||
async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> Result<PendingMultipart, BoxError> {
|
||||
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = created
|
||||
.upload_id()
|
||||
.ok_or("CreateMultipartUpload omitted upload ID")?
|
||||
.to_owned();
|
||||
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||
let mut part_sizes = Vec::with_capacity(parts.len());
|
||||
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part_number = i32::try_from(index + 1)?;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.upload_id(upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(part.clone()))
|
||||
.send()
|
||||
@@ -335,38 +323,16 @@ async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
|
||||
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
|
||||
.build(),
|
||||
);
|
||||
part_sizes.push(i64::try_from(part.len())?);
|
||||
}
|
||||
Ok(PendingMultipart {
|
||||
upload_id,
|
||||
completed_parts,
|
||||
part_sizes,
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete_pending_multipart(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
pending: &PendingMultipart,
|
||||
) -> Result<aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput, BoxError> {
|
||||
Ok(client
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&pending.upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(pending.completed_parts.clone()))
|
||||
.build(),
|
||||
)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||
let pending = stage_multipart(client, bucket, key, parts).await?;
|
||||
complete_pending_multipart(client, bucket, key, &pending).await?;
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -413,330 +379,6 @@ async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<Str
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
async fn write_mixed_put_load(clients: &[Client], phase: &str) -> Result<Vec<String>, BoxError> {
|
||||
let mut tasks = JoinSet::new();
|
||||
for worker in 0..MULTIPART_WORKERS {
|
||||
let node = worker % clients.len();
|
||||
let client = clients[node].clone();
|
||||
let phase = phase.to_owned();
|
||||
tasks.spawn(async move {
|
||||
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
|
||||
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||
let key = format!("{phase}/put/{worker:02}/{upload:02}");
|
||||
tokio::time::timeout(
|
||||
MIXED_MULTIPART_UPLOAD_TIMEOUT,
|
||||
client
|
||||
.put_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(vec![u8::try_from(worker)?; 64 * 1024]))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("node {node}: PUT {key} acknowledgement deadline exceeded"))??;
|
||||
keys.push(key);
|
||||
}
|
||||
Ok::<_, BoxError>(keys)
|
||||
});
|
||||
}
|
||||
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
keys.extend(result??);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
async fn verify_load_bodies(clients: &[Client], phase: &str, operation: &str) -> TestResult {
|
||||
for worker in 0..MULTIPART_WORKERS {
|
||||
let reader = &clients[(worker + 1) % clients.len()];
|
||||
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||
let key = format!("{phase}/{operation}/{worker:02}/{upload:02}");
|
||||
assert_eq!(
|
||||
read_object(reader, MIXED_BUCKET, &key, None).await?.1,
|
||||
vec![u8::try_from(worker)?; 64 * 1024],
|
||||
"{phase}: acknowledged {key} body"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exercise_homogeneous_multipart(cluster: &RustFSTestClusterEnvironment, phase: &str) -> TestResult {
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_drive_readmission(&clients, MIXED_BUCKET, phase, DRIVE_READMISSION_TIMEOUT).await?;
|
||||
let keys = match write_multipart_load(&clients, phase).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => {
|
||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
||||
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
||||
}
|
||||
};
|
||||
assert_eq!(keys.len(), MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||
verify_load_bodies(&clients, phase, "multipart").await?;
|
||||
for (reader, client) in clients.iter().enumerate() {
|
||||
wait_for_phase_listing(client, phase, keys.len(), &format!("homogeneous reader {reader}")).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct PendingSourceBytes {
|
||||
root: PathBuf,
|
||||
files: Vec<(PathBuf, Vec<u8>)>,
|
||||
}
|
||||
|
||||
fn pending_source_bytes(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
pending: &PendingMultipart,
|
||||
) -> Result<Vec<PendingSourceBytes>, BoxError> {
|
||||
let source_hash =
|
||||
rustfs_utils::hex_sha256(format!("{PENDING_MULTIPART_BUCKET}/{PENDING_MULTIPART_KEY}").as_bytes(), str::to_owned);
|
||||
let mut sources = Vec::new();
|
||||
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
let hash_root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET).join(&source_hash);
|
||||
let roots = std::fs::read_dir(&hash_root)?
|
||||
.map(|entry| {
|
||||
let entry = entry?;
|
||||
Ok(entry.file_type()?.is_dir().then(|| entry.path()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if roots.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "pending upload source has not settled").into());
|
||||
}
|
||||
assert_eq!(roots.len(), 1, "the dedicated key has exactly one real pending upload");
|
||||
let root = roots.into_iter().next().ok_or("pending upload source missing")?;
|
||||
let mut files = vec![(PathBuf::from("xl.meta"), std::fs::read(root.join("xl.meta"))?)];
|
||||
let data_dirs = std::fs::read_dir(&root)?
|
||||
.map(|entry| {
|
||||
let entry = entry?;
|
||||
Ok((entry.file_type()?.is_dir() && entry.path().join("part.1").is_file()).then(|| entry.path()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if data_dirs.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "selected part publication has not settled").into());
|
||||
}
|
||||
assert_eq!(data_dirs.len(), 1, "the upload has one actual selected-part directory");
|
||||
let data_dir = data_dirs.into_iter().next().ok_or("pending upload parts missing")?;
|
||||
for part in &pending.completed_parts {
|
||||
let number = part.part_number().ok_or("acknowledged part omitted its number")?;
|
||||
for name in [format!("part.{number}"), format!("part.{number}.meta")] {
|
||||
let path = data_dir.join(name);
|
||||
files.push((path.strip_prefix(&root)?.to_owned(), std::fs::read(path)?));
|
||||
}
|
||||
}
|
||||
sources.push(PendingSourceBytes { root, files });
|
||||
}
|
||||
assert_eq!(
|
||||
sources.len(),
|
||||
MIXED_NODE_COUNT,
|
||||
"the controlled legacy fixture requires every physical source"
|
||||
);
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
async fn wait_for_pending_source_bytes(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
pending: &PendingMultipart,
|
||||
) -> Result<Vec<PendingSourceBytes>, BoxError> {
|
||||
let deadline = Instant::now() + DRIVE_READMISSION_TIMEOUT;
|
||||
loop {
|
||||
match pending_source_bytes(cluster, pending) {
|
||||
Ok(sources) => return Ok(sources),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
|
||||
&& Instant::now() < deadline =>
|
||||
{
|
||||
sleep(Duration::from_millis(500)).await
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_pending_parts(client: &Client, pending: &PendingMultipart) -> TestResult {
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.upload_id(&pending.upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(listed.upload_id(), Some(pending.upload_id.as_str()));
|
||||
assert_ne!(listed.is_truncated(), Some(true));
|
||||
assert_eq!(listed.parts().len(), pending.completed_parts.len());
|
||||
for ((actual, expected), expected_size) in listed.parts().iter().zip(&pending.completed_parts).zip(&pending.part_sizes) {
|
||||
assert_eq!(actual.part_number(), expected.part_number());
|
||||
assert_eq!(actual.e_tag(), expected.e_tag());
|
||||
assert_eq!(actual.size(), Some(*expected_size));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_mixed_pending_refusal(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
pending: &PendingMultipart,
|
||||
sources: &[PendingSourceBytes],
|
||||
) -> TestResult {
|
||||
let clients = cluster.create_all_clients()?;
|
||||
assert_pending_parts(&clients[0], pending).await?;
|
||||
let error = upgrade_probe_client(&clients[0])
|
||||
.complete_multipart_upload()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.upload_id(&pending.upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(pending.completed_parts.clone()))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("one native participant cannot authorize a legacy MPU completion");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503), "{error:?}");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("ServiceUnavailable"));
|
||||
for client in &clients {
|
||||
assert_pending_parts(client, pending).await?;
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the rejected Complete must not publish a target");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
for source in sources {
|
||||
for entry in std::fs::read_dir(&source.root)? {
|
||||
assert!(
|
||||
!entry?.file_name().to_string_lossy().starts_with(".mpu-"),
|
||||
"the failed preflight must not upgrade one physical legacy source"
|
||||
);
|
||||
}
|
||||
for (relative, expected) in &source.files {
|
||||
let actual = std::fs::read(source.root.join(relative))?;
|
||||
assert!(actual == *expected, "rejected Complete changed actual source {}", relative.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CompletedPendingMultipart {
|
||||
version_id: String,
|
||||
etag: String,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn assert_resumed_multipart(clients: &[Client], completed: &CompletedPendingMultipart) -> TestResult {
|
||||
for (node, client) in clients.iter().enumerate() {
|
||||
let response = client
|
||||
.get_object()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.version_id(&completed.version_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.version_id(), Some(completed.version_id.as_str()), "reader {node}");
|
||||
assert_eq!(response.e_tag(), Some(completed.etag.as_str()), "reader {node}");
|
||||
assert_eq!(
|
||||
response.body.collect().await?.into_bytes().as_ref(),
|
||||
completed.body.as_slice(),
|
||||
"reader {node}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_object(client, PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, None)
|
||||
.await?
|
||||
.1,
|
||||
completed.body
|
||||
);
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.prefix(PENDING_MULTIPART_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(versions.versions().len(), 1, "same-upload replay must not add a version");
|
||||
assert_eq!(versions.versions()[0].version_id(), Some(completed.version_id.as_str()));
|
||||
assert!(versions.delete_markers().is_empty());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_pending_multipart(
|
||||
clients: &[Client],
|
||||
pending: &PendingMultipart,
|
||||
body: Vec<u8>,
|
||||
) -> Result<CompletedPendingMultipart, BoxError> {
|
||||
let complete = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
|
||||
let version_id = complete
|
||||
.version_id()
|
||||
.filter(|version| !version.is_empty() && *version != "null")
|
||||
.ok_or("versioned legacy upload completion omitted a concrete version")?
|
||||
.to_owned();
|
||||
let etag = complete.e_tag().ok_or("CompleteMultipartUpload omitted ETag")?.to_owned();
|
||||
let replay = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
|
||||
assert_eq!(replay.version_id(), Some(version_id.as_str()));
|
||||
assert_eq!(replay.e_tag(), Some(etag.as_str()));
|
||||
let completed = CompletedPendingMultipart { version_id, etag, body };
|
||||
assert_resumed_multipart(clients, &completed).await?;
|
||||
Ok(completed)
|
||||
}
|
||||
|
||||
async fn wait_for_multipart_quiescence(cluster: &RustFSTestClusterEnvironment, sources: &[PendingSourceBytes]) -> TestResult {
|
||||
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
|
||||
loop {
|
||||
let pending_roots = sources.iter().filter(|source| source.root.exists()).count();
|
||||
let mut native_controls = 0;
|
||||
let mut remaining_upload_roots = 0;
|
||||
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
let root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET);
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(root) {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error)
|
||||
if error
|
||||
.io_error()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
|
||||
{
|
||||
native_controls += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
// Every fixture upload lives at MPI/<SHA>/<upload>. A born-only
|
||||
// source can contain just xl.meta and its data directory, with
|
||||
// no .mpu-* record. Only empty SHA parents may survive rollback.
|
||||
if entry.depth() == 2 || (entry.depth() == 1 && !entry.file_type().is_dir()) {
|
||||
remaining_upload_roots += 1;
|
||||
}
|
||||
if entry.file_name().to_string_lossy().starts_with(".mpu-") {
|
||||
native_controls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pending_roots == 0 && remaining_upload_roots == 0 && native_controls == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"downgrade requires settled native MPU state: {pending_roots} fixed pending roots, {remaining_upload_roots} inventoried upload roots, {native_controls} native controls"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn mixed_multipart_before(
|
||||
deadline: Instant,
|
||||
node: usize,
|
||||
@@ -983,11 +625,14 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
||||
|
||||
// Native MPU mutations are operationally paused while RPC generations
|
||||
// differ. Keep the same mixed traffic volume with ordinary PUT instead.
|
||||
let written_keys = write_mixed_put_load(&clients, phase).await?;
|
||||
let expected_count = written_keys.len() + 2;
|
||||
verify_load_bodies(&clients, phase, "put").await?;
|
||||
let multipart_keys = match write_multipart_load(&clients, phase).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => {
|
||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
||||
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
||||
}
|
||||
};
|
||||
let expected_count = multipart_keys.len() + 2;
|
||||
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
@@ -1003,9 +648,9 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let last_put_key = format!("{phase}/put/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
assert_eq!(
|
||||
read_object(previous_client, MIXED_BUCKET, &last_put_key, None).await?.1,
|
||||
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
|
||||
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
|
||||
);
|
||||
|
||||
@@ -1145,20 +790,12 @@ fn previous_baseline_writers(clients: &[Client], restarted_node: usize) -> &[Cli
|
||||
}
|
||||
|
||||
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
|
||||
prepare_release_baseline(cluster, previous_binary, "previous").await
|
||||
}
|
||||
|
||||
async fn prepare_release_baseline(
|
||||
cluster: &mut RustFSTestClusterEnvironment,
|
||||
previous_binary: &Path,
|
||||
prefix: &str,
|
||||
) -> TestResult {
|
||||
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
|
||||
// rc.5 can latch a non-elected node's write fence while its first startup
|
||||
// waits for pool metadata (#7473). First prove the elected writer can
|
||||
// persist data, then restart each old process once with three peers still
|
||||
// readable. The same preparation is reused only after a quiescent homogeneous rollback.
|
||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, &format!("{prefix}-seed"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
// readable. This preparation ends before any current binary is started.
|
||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
// A node that just came back is still replaying its startup traffic against
|
||||
// every peer. Killing the next node during that burst gives the fresh node
|
||||
// two failures in a row and it drops that peer's drive to Offline, where
|
||||
@@ -1168,7 +805,7 @@ async fn prepare_release_baseline(
|
||||
for node in [1, 2, 3, 0] {
|
||||
cluster.stop_node(node)?;
|
||||
cluster.start_node_from_binary(node, previous_binary).await?;
|
||||
let context = format!("{prefix}-restart-{node}");
|
||||
let context = format!("previous-restart-{node}");
|
||||
wait_for_upgrade_write_readiness(
|
||||
std::slice::from_ref(&clients[node]),
|
||||
MIXED_BUCKET,
|
||||
@@ -1185,17 +822,15 @@ async fn prepare_release_baseline(
|
||||
.await?;
|
||||
}
|
||||
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, &format!("{prefix}-baseline"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
for (reader, client) in clients.iter().enumerate() {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, &format!(".upgrade-readiness/{prefix}-seed/node-0"), None)
|
||||
.await?
|
||||
.1,
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
"previous-release node {reader} must retain the seed across its rolling restart"
|
||||
);
|
||||
for writer in 0..clients.len() {
|
||||
let key = format!(".upgrade-readiness/{prefix}-baseline/node-{writer}");
|
||||
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, &key, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
@@ -2248,21 +1883,10 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
cluster.start_with_binary(&previous_binary).await?;
|
||||
cluster.create_test_bucket(MIXED_BUCKET).await?;
|
||||
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
|
||||
exercise_homogeneous_multipart(&cluster, "homogeneous-previous").await?;
|
||||
cluster.create_test_bucket(PENDING_MULTIPART_BUCKET).await?;
|
||||
let previous_clients = cluster.create_all_clients()?;
|
||||
enable_versioning(&previous_clients[0], PENDING_MULTIPART_BUCKET).await?;
|
||||
let pending_parts = vec![vec![0x63; 5 * 1024 * 1024], vec![0x7d; 4096]];
|
||||
let pending = stage_multipart(&previous_clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, &pending_parts).await?;
|
||||
let sources = wait_for_pending_source_bytes(&cluster, &pending).await?;
|
||||
assert_pending_parts(&previous_clients[0], &pending).await?;
|
||||
|
||||
cluster.stop_node(0)?;
|
||||
cluster.start_node_from_binary(0, ¤t_binary).await?;
|
||||
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
|
||||
// One controlled out-of-policy attempt proves fail-closed behavior below
|
||||
// native write quorum; positive MPU writers remain paused while mixed.
|
||||
assert_mixed_pending_refusal(&cluster, &pending, &sources).await?;
|
||||
|
||||
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
|
||||
cluster.stop_node(node_idx)?;
|
||||
@@ -2274,9 +1898,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
cluster.start_node_from_binary(3, ¤t_binary).await?;
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_drive_readmission(&clients, MIXED_BUCKET, "homogeneous-current", DRIVE_READMISSION_TIMEOUT).await?;
|
||||
let completed_pending = finish_pending_multipart(&clients, &pending, pending_parts.concat()).await?;
|
||||
exercise_homogeneous_multipart(&cluster, "homogeneous-current").await?;
|
||||
verify_load_bodies(&clients, "homogeneous-previous", "multipart").await?;
|
||||
|
||||
for (node_idx, client) in clients.iter().enumerate() {
|
||||
assert_eq!(
|
||||
@@ -2295,6 +1916,47 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(context) = evidence_context.as_ref() {
|
||||
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
|
||||
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_reader_evidence",
|
||||
"mixed-version-reader",
|
||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"assertions": [
|
||||
"current node reads objects written through previous-release client",
|
||||
"previous-release node reads objects written through current client",
|
||||
"all nodes list every mixed-version object after homogeneous-current convergence",
|
||||
"the previous-release seed survives the baseline restart and every upgrade phase"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_writer_evidence",
|
||||
"mixed-version-writer",
|
||||
&["old-reader-new-writer", "new-reader-old-writer"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"assertions": [
|
||||
"current writer publishes objects readable by previous-release node",
|
||||
"previous-release writer publishes objects readable by current node",
|
||||
"multipart writers continue at every individual node upgrade",
|
||||
"modern DELETE fails closed and preserves every byte while old peers remain"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
clients[0]
|
||||
@@ -2318,115 +1980,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
);
|
||||
}
|
||||
|
||||
wait_for_multipart_quiescence(&cluster, &sources).await?;
|
||||
// No MPU mutation runs during a heterogeneous downgrade. All current
|
||||
// callbacks and source controls settle before old binaries reopen volumes.
|
||||
cluster.stop();
|
||||
cluster.start_with_binary(&previous_binary).await?;
|
||||
prepare_release_baseline(&mut cluster, &previous_binary, "rollback").await?;
|
||||
let old_clients = cluster.create_all_clients()?;
|
||||
assert_resumed_multipart(&old_clients, &completed_pending).await?;
|
||||
for phase in ["homogeneous-previous", "homogeneous-current"] {
|
||||
verify_load_bodies(&old_clients, phase, "multipart").await?;
|
||||
for client in &old_clients {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||
"previous reader after rollback",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
for phase in ["one-current-node", "two-current-nodes", "one-previous-node"] {
|
||||
verify_load_bodies(&old_clients, phase, "put").await?;
|
||||
for client in &old_clients {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
|
||||
"previous reader after rollback",
|
||||
)
|
||||
.await?;
|
||||
for (key, body) in [
|
||||
("written-by-current", format!("{phase}: current RustFS build")),
|
||||
("written-by-previous", format!("{phase}: previous RustFS release")),
|
||||
] {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, &format!("{phase}/{key}"), None).await?.1,
|
||||
body.as_bytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (node, client) in old_clients.iter().enumerate() {
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.key(PREVIOUS_RELEASE_SEED_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("current DELETE must remain visible to previous readers after rollback");
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NoSuchKey"),
|
||||
"reader {node}"
|
||||
);
|
||||
}
|
||||
exercise_homogeneous_multipart(&cluster, "homogeneous-rollback").await?;
|
||||
|
||||
if let Some(context) = evidence_context.as_ref() {
|
||||
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
|
||||
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_reader_evidence",
|
||||
"mixed-version-reader",
|
||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"mixed_load": "plain-put",
|
||||
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
|
||||
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||
"assertions": [
|
||||
"current node reads objects written through previous-release client",
|
||||
"previous-release node reads objects written through current client",
|
||||
"all nodes list every mixed-version object after homogeneous-current convergence",
|
||||
"the previous-release seed survives the baseline restart and every upgrade phase",
|
||||
"previous readers read every current-native MPU body after quiescent rollback",
|
||||
"same legacy upload resumes with identical body, ETag and version after full upgrade"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_writer_evidence",
|
||||
"mixed-version-writer",
|
||||
&["old-reader-new-writer", "new-reader-old-writer"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"mixed_load": "plain-put",
|
||||
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
|
||||
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||
"assertions": [
|
||||
"current writer publishes objects readable by previous-release node",
|
||||
"previous-release writer publishes objects readable by current node",
|
||||
"mixed phases retain 16x16 plain PUT writers while positive multipart writers are paused",
|
||||
"16x16 multipart load runs in homogeneous previous, current and rollback phases",
|
||||
"one-current-node Complete returns typed 503 and preserves actual source authority and parts",
|
||||
"the same legacy upload completes and replays once fully upgraded without adding a version",
|
||||
"modern DELETE fails closed and preserves every byte while old peers remain"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ Applies to `crates/ecstore/`.
|
||||
|
||||
- Be careful with allocations and locking in hot paths.
|
||||
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
|
||||
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single `blocking_queue` or `run_blocking_namespace_operation` closure per disk rather than chaining multiple `tokio::fs` or `spawn_blocking` dispatches.
|
||||
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned guard across sibling metadata reads (`read_optional_in_pinned`, `part.N.meta` loops) instead of re-walking ancestor components per file or re-opening when `source_parent == destination_parent`.
|
||||
- After `file.metadata()`, pre-size read buffers (`Vec::with_capacity(len + 1)`) before `read_to_end` to avoid `std::io::read_to_end`'s 32-byte exponential growth (`6–7` `read()` syscalls per small metadata file).
|
||||
- Benchmark-sensitive changes should include measurable rationale.
|
||||
|
||||
## Allocation Discipline in Hot Paths
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -526,16 +513,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::{
|
||||
|
||||
@@ -11314,7 +11314,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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,13 +244,18 @@ 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 unavailable memory usage is reported as "unknown" and 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;
|
||||
@@ -264,7 +267,7 @@ impl QuotaChecker {
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
"quota usage report degraded"
|
||||
"durable quota usage report degraded"
|
||||
);
|
||||
}
|
||||
usage
|
||||
@@ -739,12 +742,8 @@ mod tests {
|
||||
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!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
|
||||
0,
|
||||
|
||||
@@ -245,86 +245,3 @@ async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -2528,70 +2535,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,9 +699,6 @@ 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();
|
||||
@@ -733,7 +710,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 +760,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 +833,7 @@ async fn list_path_raw_inner(
|
||||
*r = MetacacheReader::new(detached_rd);
|
||||
continue;
|
||||
}
|
||||
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
PeekOutcome::Cancelled => return Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -916,7 +892,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 +949,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 +969,8 @@ async fn list_path_raw_inner(
|
||||
// warn!("list_path_raw: agreed_fn start, current: {:?}", ¤t.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");
|
||||
}
|
||||
@@ -1014,58 +990,61 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
}
|
||||
|
||||
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 +1113,7 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
|
||||
// warn!("list_path_raw: done");
|
||||
Ok(outcome)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1153,7 +1132,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 +1252,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 +1711,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 +1811,6 @@ mod tests {
|
||||
Box::pin(async move {
|
||||
callback_started.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -2033,7 +1962,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 +2004,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 +2076,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, _| {
|
||||
@@ -2315,7 +2241,6 @@ mod tests {
|
||||
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, _| {
|
||||
@@ -2472,7 +2397,6 @@ mod tests {
|
||||
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, _| {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -22,17 +22,13 @@ use crate::cluster::rpc::internode_data_transport::{
|
||||
WriteStreamRequest,
|
||||
};
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartSourceOperation, MultipartSourceRequest, MultipartSourceResponse,
|
||||
};
|
||||
use crate::disk::{
|
||||
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection,
|
||||
DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
|
||||
DiskOption, FileInfoVersions, FileReader, FileWriter, GuardedDeleteVersionsPayload, GuardedFileInfoVersions,
|
||||
PartTransactionAction, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse, ReadMultipleReq, ReadMultipleResp,
|
||||
ReadOptions, RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
batch_read_version_one_by_one,
|
||||
PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
|
||||
disk_store::{
|
||||
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
|
||||
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
|
||||
@@ -66,14 +62,11 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
BatchReadVersionRequest, BatchReadVersionResponse, CheckPartsRequest, CompareAndUpdateFileOutcome,
|
||||
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
|
||||
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, GuardedDeleteVersionsRequest, ListDirRequest,
|
||||
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PoolMetaNativeRecoveryRequest as PoolMetaNativeRecoveryRpcRequest,
|
||||
PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse,
|
||||
ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RecoverDeleteTransactionRequest,
|
||||
RecoverMultipartSourceRequest as RecoverMultipartSourceRpcRequest,
|
||||
RecoverMultipartSourceResponse as RecoverMultipartSourceRpcResponse, RenameDataRequest, RenameFileRequest,
|
||||
SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
|
||||
SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest,
|
||||
node_service_client::NodeServiceClient,
|
||||
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest,
|
||||
ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest,
|
||||
RecoverDeleteTransactionRequest, RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest,
|
||||
SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest,
|
||||
UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest, node_service_client::NodeServiceClient,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
@@ -244,36 +237,6 @@ fn attach_quota_mutation_body_digest<T>(
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(request, &body).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn decode_multipart_source_recovery_response(
|
||||
request: &MultipartSourceRequest,
|
||||
response: RecoverMultipartSourceRpcResponse,
|
||||
) -> Result<MultipartSourceResponse> {
|
||||
if response.protocol_version != u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION) {
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
if response.result.len() > MultipartSourceOperation::max_wire_payload_bytes() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if !response.success {
|
||||
if !response.result.is_empty() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let error = response.error.ok_or(DiskError::FileCorrupt)?;
|
||||
if DiskError::from_u32(error.code).is_none() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let result: MultipartSourceResponse =
|
||||
rustfs_protos::decode_multipart_source_recovery_payload(&response.result).map_err(|_| DiskError::FileCorrupt)?;
|
||||
result.validate_wire()?;
|
||||
result.validate_for(request)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decode_volume_infos(volume_infos: Vec<String>) -> Result<Vec<VolumeInfo>> {
|
||||
volume_infos
|
||||
.into_iter()
|
||||
@@ -2165,7 +2128,7 @@ impl RemoteDisk {
|
||||
.map_err(|err| Error::RemoteClientUnavailable(err.to_string()))
|
||||
}
|
||||
|
||||
/// Client for large `bytes`-carrying RPCs, including native pool metadata recovery inventories.
|
||||
/// Client for large `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion).
|
||||
/// Routes onto the isolated bulk channel pool so large transfers cannot head-of-line block
|
||||
/// lock/health RPCs (grpc-optimization P1). Falls back to the control channel when isolation
|
||||
/// is disabled.
|
||||
@@ -2261,38 +2224,6 @@ fn decode_msgpack_or_json<T: DeserializeOwned>(binary: &[u8], json: &str, value_
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_multipart_publication_response(binary: &[u8]) -> Result<RenameDataResp> {
|
||||
let result = if binary.len() > MultipartSourceOperation::max_wire_payload_bytes() {
|
||||
Err(DiskError::FileCorrupt)
|
||||
} else {
|
||||
rustfs_protos::decode_multipart_source_recovery_payload(binary).map_err(|_| DiskError::FileCorrupt)
|
||||
};
|
||||
if result.is_ok() {
|
||||
crate::cluster::rpc::runtime_sources::record_response_msgpack_decode("RenameDataResp");
|
||||
} else {
|
||||
crate::cluster::rpc::runtime_sources::record_response_msgpack_decode_error("RenameDataResp");
|
||||
}
|
||||
// A codec error must never echo a malformed opaque capability.
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_multipart_publication_envelope(response: &rustfs_protos::proto_gen::node_service::RenameDataResponse) -> Result<()> {
|
||||
if !response.success {
|
||||
if !response.rename_data_resp.is_empty() || !response.rename_data_resp_bin.is_empty() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let error = response.error.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
if DiskError::from_u32(error.code).is_none() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
return Err(error.clone().into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_version_stage_timer(attribution_enabled: bool) -> Option<Instant> {
|
||||
attribution_enabled.then(Instant::now)
|
||||
}
|
||||
@@ -2468,18 +2399,6 @@ impl RemoteDisk {
|
||||
dst_path: &str,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<RenameDataResp> {
|
||||
let multipart_rename = src_volume == crate::disk::RUSTFS_META_MULTIPART_BUCKET;
|
||||
let pool_meta_cas = rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
|
||||
);
|
||||
if pool_meta_cas
|
||||
&& (dst_volume != crate::disk::RUSTFS_META_BUCKET
|
||||
|| dst_path != "pool.bin"
|
||||
|| scanner_publication_lease_token.is_some())
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -2522,12 +2441,9 @@ impl RemoteDisk {
|
||||
&fi.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
);
|
||||
if pool_meta_cas {
|
||||
let body = canonical_body.map_err(|_| Error::other("pool metadata CAS body length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
} else if quota_fenced {
|
||||
if quota_fenced {
|
||||
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
|
||||
} else if multipart_rename || scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
} else if scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
@@ -2542,12 +2458,7 @@ impl RemoteDisk {
|
||||
let rpc_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = rustfs_io_metrics::observe_put_stage_future(
|
||||
async {
|
||||
if multipart_rename {
|
||||
// Old peers must reject before an upload source can be changed.
|
||||
client.rename_multipart_data(request).await
|
||||
} else if pool_meta_cas {
|
||||
client.rename_data_with_pool_meta_cas(request).await
|
||||
} else if quota_fenced {
|
||||
if quota_fenced {
|
||||
// The method itself proves quota-token enforcement; an old peer must fail closed.
|
||||
client.rename_data_with_quota_fence(request).await
|
||||
} else if incarnation_bound {
|
||||
@@ -2561,14 +2472,7 @@ impl RemoteDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_POLL_PENDING_COUNT,
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_RPC_AWAIT_FIRST_PENDING_TO_READY,
|
||||
)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if (multipart_rename || pool_meta_cas) && status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?;
|
||||
.await?;
|
||||
let response_process_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response_headers_started = rustfs_io_metrics::put_stage_timer();
|
||||
let _ = response.metadata().len();
|
||||
@@ -2588,9 +2492,7 @@ impl RemoteDisk {
|
||||
);
|
||||
|
||||
let success_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if multipart_rename {
|
||||
validate_multipart_publication_envelope(&response)?;
|
||||
} else if !response.success {
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
@@ -2599,15 +2501,11 @@ impl RemoteDisk {
|
||||
);
|
||||
|
||||
let decode_started = rustfs_io_metrics::put_stage_timer();
|
||||
let rename_data_resp = if multipart_rename {
|
||||
decode_multipart_publication_response(&response.rename_data_resp_bin)?
|
||||
} else {
|
||||
decode_msgpack_or_json::<RenameDataResp>(
|
||||
&response.rename_data_resp_bin,
|
||||
&response.rename_data_resp,
|
||||
"RenameDataResp",
|
||||
)?
|
||||
};
|
||||
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
|
||||
&response.rename_data_resp_bin,
|
||||
&response.rename_data_resp,
|
||||
"RenameDataResp",
|
||||
)?;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_DECODE,
|
||||
decode_started,
|
||||
@@ -2617,14 +2515,6 @@ impl RemoteDisk {
|
||||
response_process_started,
|
||||
);
|
||||
|
||||
if multipart_rename {
|
||||
let publication = rename_data_resp.multipart_source.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
publication.validate_wire()?;
|
||||
let expected = crate::disk::multipart_source::publication_identity(fi)?.ok_or(DiskError::FileCorrupt)?;
|
||||
if publication.publication != expected {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
Ok(rename_data_resp)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
@@ -3017,20 +2907,6 @@ impl DiskAPI for RemoteDisk {
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_delete_options_msgpack(&opts)?;
|
||||
let conditional_marker = opts.expected_delete_marker.is_some();
|
||||
let pool_meta_undo = rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
|
||||
);
|
||||
if pool_meta_undo
|
||||
&& (volume != crate::disk::RUSTFS_META_BUCKET
|
||||
|| path != "pool.bin"
|
||||
|| !opts.undo_write
|
||||
|| opts.undo_delete
|
||||
|| force_del_marker
|
||||
|| conditional_marker)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
let file_info = serde_json::to_string(&fi)?;
|
||||
let opts = serde_json::to_string(&opts)?;
|
||||
|
||||
@@ -3050,7 +2926,7 @@ impl DiskAPI for RemoteDisk {
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_delete_version_request_body(request.get_ref());
|
||||
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
|
||||
if incarnation_bound || pool_meta_undo {
|
||||
if incarnation_bound {
|
||||
let body = canonical_body.map_err(|_| Error::other("delete-version body length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
} else {
|
||||
@@ -3059,15 +2935,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
// The marker-specific method rejects older peers; its body digest
|
||||
// also binds any incarnation, so neither precondition can be lost.
|
||||
let response = if pool_meta_undo {
|
||||
client.undo_pool_meta_write(request).await.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
} else if conditional_marker {
|
||||
let response = if conditional_marker {
|
||||
client.delete_retired_marker(request).await?
|
||||
} else if incarnation_bound {
|
||||
client.delete_version_at_incarnation(request).await?
|
||||
@@ -3749,107 +3617,6 @@ impl DiskAPI for RemoteDisk {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
if !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn undo_pool_meta_write(&self, volume: &str, path: &str, fi: FileInfo, opts: DeleteOptions) -> Result<()> {
|
||||
if !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.delete_version(volume, path, fi, false, opts).await
|
||||
}
|
||||
|
||||
async fn pool_meta_native_recovery(
|
||||
&self,
|
||||
operation: PoolMetaNativeRecoveryRequest,
|
||||
) -> Result<PoolMetaNativeRecoveryResponse> {
|
||||
self.execute_with_timeout_for_op(
|
||||
"pool_meta_native_recovery",
|
||||
|| async {
|
||||
let payload = rmp_serde::to_vec_named(&operation).map_err(Error::other)?;
|
||||
if payload.len() > rustfs_protos::POOL_META_NATIVE_RECOVERY_MAX_PAYLOAD_BYTES {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let mut client = self.get_bulk_client().await?;
|
||||
let mut request = Request::new(PoolMetaNativeRecoveryRpcRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
payload: payload.into(),
|
||||
});
|
||||
let body = rustfs_protos::canonical_pool_meta_native_recovery_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("pool metadata recovery body length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
let response = client
|
||||
.pool_meta_native_recovery(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_protos::decode_pool_meta_native_recovery_payload(&response.payload).map_err(Error::other)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(&self, mut source: MultipartSourceRequest) -> Result<MultipartSourceResponse> {
|
||||
source.prepare_wire()?;
|
||||
let payload = rmp_serde::to_vec_named(&source.operation).map_err(Error::other)?;
|
||||
if payload.len() > MultipartSourceOperation::max_wire_payload_bytes() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
self.execute_with_timeout_for_op(
|
||||
"recover_multipart_source",
|
||||
|| async {
|
||||
let mut client = self.get_bulk_client().await?;
|
||||
let mut request = Request::new(RecoverMultipartSourceRpcRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
path: source.path.clone(),
|
||||
protocol_version: u32::from(source.protocol_version),
|
||||
operation: payload.into(),
|
||||
});
|
||||
let body = rustfs_protos::canonical_recover_multipart_source_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("multipart source recovery request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
let response = client
|
||||
.recover_multipart_source(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
decode_multipart_source_recovery_response(&source, response)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn list_dir(&self, _origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
|
||||
trace!(volume, dir_path, "Remote disk list_dir RPC started");
|
||||
@@ -4786,322 +4553,6 @@ mod tests {
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
fn multipart_source_claim_request() -> MultipartSourceRequest {
|
||||
use crate::disk::multipart_source::{
|
||||
MultipartRecoveryRound, MultipartSourcePhase, MultipartSourceSnapshot, SourceGeneration, SourceGeometry,
|
||||
SourceLineage,
|
||||
};
|
||||
let original_data_dir = Uuid::new_v4();
|
||||
let lineage = SourceLineage {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
path: "upload".to_string(),
|
||||
original_data_dir,
|
||||
original_version_id: None,
|
||||
created_unix_nanos: 1,
|
||||
original_identity: [1; 32],
|
||||
geometry: SourceGeometry {
|
||||
data_blocks: 2,
|
||||
parity_blocks: 2,
|
||||
block_size: 1024,
|
||||
distribution: vec![1, 2, 3, 4],
|
||||
},
|
||||
};
|
||||
let generation = SourceGeneration {
|
||||
epoch: 0,
|
||||
nonce: original_data_dir,
|
||||
};
|
||||
MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: lineage.path.clone(),
|
||||
operation: MultipartSourceOperation::Claim {
|
||||
expected: MultipartSourceSnapshot {
|
||||
disk_id: Uuid::new_v4(),
|
||||
physical_slot: 0,
|
||||
lineage: lineage.clone(),
|
||||
generation,
|
||||
revision: 0,
|
||||
phase: MultipartSourcePhase::Active,
|
||||
state_sha256: [2; 32],
|
||||
head_sha256: Some([3; 32]),
|
||||
inventory_sha256: [4; 32],
|
||||
capability_sha256: None,
|
||||
publication: None,
|
||||
},
|
||||
round: MultipartRecoveryRound {
|
||||
lineage,
|
||||
from_generation: generation,
|
||||
old_attempt: Uuid::new_v4(),
|
||||
generation: SourceGeneration {
|
||||
epoch: 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_source_recovery_old_peer_rejects_without_generic_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
for request in [
|
||||
MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: "upload".to_string(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
},
|
||||
multipart_source_claim_request(),
|
||||
] {
|
||||
assert_eq!(
|
||||
remote
|
||||
.recover_multipart_source(request)
|
||||
.await
|
||||
.expect_err("old peer must reject before recovery work"),
|
||||
DiskError::UnsupportedDisk
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec!["/node_service.NodeService/RecoverMultipartSource"; 2],
|
||||
"read and mutation requests must never fall back to generic reads, writes or legacy recovery",
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_publication_response_is_strict_and_redacts_codec_errors() {
|
||||
let encoded = rmp_serde::to_vec_named(&RenameDataResp::default()).expect("publication response");
|
||||
assert!(decode_multipart_publication_response(&encoded).is_ok());
|
||||
let mut trailing = encoded;
|
||||
trailing.push(0xc0);
|
||||
let secret = "private-undo-capability";
|
||||
let malformed =
|
||||
rmp_serde::to_vec_named(&serde_json::json!({ "multipart_source": secret })).expect("malformed capability response");
|
||||
for binary in [Vec::new(), trailing, malformed] {
|
||||
let error = decode_multipart_publication_response(&binary).expect_err("invalid publication response");
|
||||
assert!(matches!(error, DiskError::FileCorrupt));
|
||||
assert!(!error.to_string().contains(secret));
|
||||
}
|
||||
use rustfs_protos::proto_gen::node_service::RenameDataResponse;
|
||||
for response in [
|
||||
RenameDataResponse {
|
||||
success: true,
|
||||
error: Some(DiskError::FileNotFound.into()),
|
||||
..Default::default()
|
||||
},
|
||||
RenameDataResponse::default(),
|
||||
RenameDataResponse {
|
||||
success: false,
|
||||
error: Some(rustfs_protos::proto_gen::node_service::Error {
|
||||
code: 0,
|
||||
error_info: secret.to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(matches!(validate_multipart_publication_envelope(&response), Err(DiskError::FileCorrupt)));
|
||||
}
|
||||
let response = RenameDataResponse {
|
||||
success: false,
|
||||
error: Some(DiskError::ConditionalDeleteConflict.into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_multipart_publication_envelope(&response),
|
||||
Err(DiskError::ConditionalDeleteConflict)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_source_recovery_rejects_malformed_and_unrelated_replies() {
|
||||
use crate::disk::multipart_source::MultipartSourceInspection;
|
||||
let source = MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: "upload".to_string(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
};
|
||||
let result = MultipartSourceResponse::Inspected(MultipartSourceInspection {
|
||||
disk_id: Uuid::new_v4(),
|
||||
snapshot: None,
|
||||
original: None,
|
||||
recovery: None,
|
||||
negative_certificate: None,
|
||||
ready_certificate: None,
|
||||
terminal_certificate: Vec::new(),
|
||||
root_missing: false,
|
||||
abort_round: None,
|
||||
abort_certificate: None,
|
||||
});
|
||||
let valid = RecoverMultipartSourceRpcResponse {
|
||||
success: true,
|
||||
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
|
||||
result: rmp_serde::to_vec_named(&result).expect("inspection reply").into(),
|
||||
error: None,
|
||||
};
|
||||
assert!(decode_multipart_source_recovery_response(&source, valid.clone()).is_ok());
|
||||
let claim = multipart_source_claim_request();
|
||||
let MultipartSourceOperation::Claim { mut expected, .. } = claim.operation else { unreachable!() };
|
||||
expected.lineage.path = "other-upload".to_string();
|
||||
let wrong_scope = MultipartSourceResponse::Inspected(MultipartSourceInspection {
|
||||
disk_id: expected.disk_id,
|
||||
snapshot: Some(expected),
|
||||
original: None,
|
||||
recovery: None,
|
||||
negative_certificate: None,
|
||||
ready_certificate: None,
|
||||
terminal_certificate: Vec::new(),
|
||||
root_missing: false,
|
||||
abort_round: None,
|
||||
abort_certificate: None,
|
||||
});
|
||||
let wrong_scope = RecoverMultipartSourceRpcResponse {
|
||||
result: rmp_serde::to_vec_named(&wrong_scope).expect("unrelated inspection").into(),
|
||||
..valid.clone()
|
||||
};
|
||||
assert!(decode_multipart_source_recovery_response(&source, wrong_scope).is_err());
|
||||
let mut wrong_version = valid.clone();
|
||||
wrong_version.protocol_version += 1;
|
||||
assert!(matches!(
|
||||
decode_multipart_source_recovery_response(&source, wrong_version),
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
));
|
||||
for invalid in [
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
error: Some(DiskError::FileNotFound.into()),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
error: None,
|
||||
result: Bytes::new(),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
error: Some(rustfs_protos::proto_gen::node_service::Error {
|
||||
code: 0,
|
||||
error_info: "invalid code".to_string(),
|
||||
}),
|
||||
result: Bytes::new(),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
error: Some(DiskError::FileNotFound.into()),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
result: Bytes::from_static(b"invalid-msgpack"),
|
||||
..valid.clone()
|
||||
},
|
||||
{
|
||||
let mut trailing = valid;
|
||||
let mut bytes = trailing.result.to_vec();
|
||||
bytes.push(0xc0);
|
||||
trailing.result = bytes.into();
|
||||
trailing
|
||||
},
|
||||
] {
|
||||
assert!(decode_multipart_source_recovery_response(&source, invalid).is_err());
|
||||
}
|
||||
let native_error = RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
|
||||
result: Bytes::new(),
|
||||
error: Some(DiskError::ConditionalDeleteConflict.into()),
|
||||
};
|
||||
assert!(matches!(
|
||||
decode_multipart_source_recovery_response(&source, native_error),
|
||||
Err(DiskError::ConditionalDeleteConflict)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_source_recovery_does_not_accept_caller_constructed_claim_proofs() {
|
||||
use crate::disk::multipart_source::{MultipartProofKind, MultipartSourcePhase, MultipartSourceProof};
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let request = multipart_source_claim_request();
|
||||
let MultipartSourceOperation::Claim { expected, round } = &request.operation else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut snapshot = expected.clone();
|
||||
snapshot.generation = round.generation;
|
||||
snapshot.phase = MultipartSourcePhase::Recovering;
|
||||
snapshot.revision += 1;
|
||||
for authentication in [
|
||||
serde_json::json!({ "class": "cluster_hmac", "mac": vec![0_u8; 32] }),
|
||||
serde_json::json!({ "class": "native_local" }),
|
||||
] {
|
||||
let proof: MultipartSourceProof = serde_json::from_value(serde_json::json!({
|
||||
"kind": MultipartProofKind::Claimed,
|
||||
"challenge": round.round,
|
||||
"recovery": round,
|
||||
"snapshot": snapshot,
|
||||
"authentication": authentication,
|
||||
}))
|
||||
.expect("untrusted wire fields can decode without gaining native authority");
|
||||
let response = RecoverMultipartSourceRpcResponse {
|
||||
success: true,
|
||||
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
|
||||
result: rmp_serde::to_vec_named(&MultipartSourceResponse::Claimed(proof))
|
||||
.expect("forged claim fields")
|
||||
.into(),
|
||||
error: None,
|
||||
};
|
||||
assert!(
|
||||
decode_multipart_source_recovery_response(&request, response).is_err(),
|
||||
"matching caller fields cannot mint a native H vote"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_rename_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let file_info = sample_rename_data_file_info();
|
||||
for scanner_lease in [None, Some(Uuid::new_v4())] {
|
||||
assert_eq!(
|
||||
remote
|
||||
.rename_data_borrowed_with_fence(
|
||||
crate::disk::RUSTFS_META_MULTIPART_BUCKET,
|
||||
"upload",
|
||||
&file_info,
|
||||
"bucket",
|
||||
"object",
|
||||
scanner_lease,
|
||||
)
|
||||
.await
|
||||
.expect_err("old peer cannot preserve multipart source data"),
|
||||
DiskError::UnsupportedDisk,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
remote
|
||||
.rename_data("tmp", "source", file_info, "bucket", "object")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec![
|
||||
"/node_service.NodeService/RenameMultipartData",
|
||||
"/node_service.NodeService/RenameMultipartData",
|
||||
"/node_service.NodeService/RenameData",
|
||||
],
|
||||
"each dispatch must use exactly one method; multipart publication must never downgrade",
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
@@ -5156,153 +4607,6 @@ mod tests {
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_recovery_uses_bulk_channel_without_replacing_control() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_INTERNODE_CHANNEL_ISOLATION, Some("true")),
|
||||
(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, Some("1")),
|
||||
],
|
||||
async {
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("route test peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let bulk_key = format!("{}\u{0}bulk\u{0}0", peer.addr);
|
||||
drop(remote.get_client().await.expect("control channel must connect"));
|
||||
assert!(rustfs_common::has_cached_connection(&peer.addr).await, "control channel must be warm");
|
||||
assert!(
|
||||
!rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"the unique peer must have no bulk channel yet"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
remote
|
||||
.pool_meta_native_recovery(PoolMetaNativeRecoveryRequest::Probe {})
|
||||
.await,
|
||||
Err(DiskError::UnsupportedDisk),
|
||||
"the old-peer response must still traverse the real recovery RPC route",
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&peer.addr).await,
|
||||
"recovery must retain the control cache"
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"recovery must establish its isolated bulk channel"
|
||||
);
|
||||
for request in [
|
||||
MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: "upload".to_owned(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
},
|
||||
multipart_source_claim_request(),
|
||||
] {
|
||||
rustfs_common::evict_connection(&bulk_key).await;
|
||||
assert!(
|
||||
!rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"each multipart recovery must establish its own observed bulk route"
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.recover_multipart_source(request)
|
||||
.await
|
||||
.expect_err("old peer must reject before recovery work"),
|
||||
DiskError::UnsupportedDisk,
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&peer.addr).await,
|
||||
"multipart recovery must retain the control channel"
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"multipart inspection and mutation must both establish an isolated bulk channel"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec![
|
||||
"/node_service.NodeService/PoolMetaNativeRecovery",
|
||||
"/node_service.NodeService/RecoverMultipartSource",
|
||||
"/node_service.NodeService/RecoverMultipartSource",
|
||||
],
|
||||
);
|
||||
|
||||
remote.cancel_token.cancel();
|
||||
drop(remote);
|
||||
rustfs_common::evict_connection(&peer.addr).await;
|
||||
rustfs_common::evict_connection(&bulk_key).await;
|
||||
peer.stop().await;
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let mut file_info = sample_rename_data_file_info();
|
||||
crate::disk::PoolMetaNativeCasCondition {
|
||||
attempt_id: Uuid::new_v4(),
|
||||
parent: None,
|
||||
parent_raw_sha256: None,
|
||||
}
|
||||
.insert_into(&mut file_info.metadata)
|
||||
.expect("native condition must encode");
|
||||
assert_eq!(
|
||||
remote
|
||||
.rename_data_borrowed_with_fence(
|
||||
crate::disk::RUSTFS_META_TMP_BUCKET,
|
||||
"staged",
|
||||
&file_info,
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
"pool.bin",
|
||||
None
|
||||
)
|
||||
.await
|
||||
.expect_err("old peer must reject native CAS"),
|
||||
DiskError::UnsupportedDisk,
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.delete_version(
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
"pool.bin",
|
||||
file_info,
|
||||
false,
|
||||
DeleteOptions {
|
||||
undo_write: true,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(DiskError::UnsupportedDisk),
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.pool_meta_native_recovery(PoolMetaNativeRecoveryRequest::Probe {})
|
||||
.await,
|
||||
Err(DiskError::UnsupportedDisk),
|
||||
);
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec![
|
||||
"/node_service.NodeService/RenameDataWithPoolMetaCas",
|
||||
"/node_service.NodeService/UndoPoolMetaWrite",
|
||||
"/node_service.NodeService/PoolMetaNativeRecovery"
|
||||
],
|
||||
"protected mutations and capability probes must never fall back to legacy RPCs",
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guarded_delete_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
@@ -6190,7 +5494,6 @@ mod tests {
|
||||
fn rename_data_response_accepts_legacy_json_without_decode_error() {
|
||||
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
|
||||
let response = RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
@@ -6595,7 +5898,6 @@ mod tests {
|
||||
#[test]
|
||||
fn rename_data_resp_named_msgpack_is_smaller_than_json() {
|
||||
let response = RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
@@ -8748,89 +8050,6 @@ mod tests {
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(remote_disk_recovery_probe)]
|
||||
async fn endpoint_reconnect_admits_ready_drive_while_another_format_probe_is_blocked() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let reference = crate::layout::format::FormatV3::new(1, 2);
|
||||
let mut peers = Vec::new();
|
||||
let mut endpoints = Vec::new();
|
||||
for index in 0..2 {
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = reference.erasure.sets[0][index];
|
||||
let peer = TestGrpcPeer::spawn(
|
||||
Bytes::from(format.to_json().expect("format should serialize")),
|
||||
Bytes::from_static(b"recovered-peer-data"),
|
||||
)
|
||||
.await
|
||||
.expect("authenticated regression peer is required");
|
||||
endpoints.push(Endpoint {
|
||||
url: url::Url::parse(&format!("{}/data/rustfs{index}", peer.addr)).expect("endpoint should parse"),
|
||||
is_local: false,
|
||||
pool_idx: 0,
|
||||
set_idx: 0,
|
||||
disk_idx: i32::try_from(index).expect("fixture index fits"),
|
||||
});
|
||||
peers.push(peer);
|
||||
}
|
||||
let set = crate::set_disk::SetDisks::new(
|
||||
"independent-reconnect-test".to_owned(),
|
||||
Arc::new(tokio::sync::RwLock::new(vec![None; 2])),
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
endpoints,
|
||||
reference.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
let gate = peers[0].peer.pause_next_format_read();
|
||||
let pending_set = Arc::clone(&set);
|
||||
let pending = tokio::spawn(async move { pending_set.connect_disks().await });
|
||||
time::timeout(Duration::from_secs(10), gate.entered.notified())
|
||||
.await
|
||||
.expect("the first endpoint must reach the format barrier");
|
||||
|
||||
let ready = time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Some(disk) = set.disks.read().await[1].clone() {
|
||||
return disk;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Ok(disk) = &ready {
|
||||
assert!(set.disks.read().await[0].is_none(), "the blocked endpoint must remain unpublished");
|
||||
assert!(!pending.is_finished(), "the sweep still owns its blocked endpoint probe");
|
||||
assert_eq!(
|
||||
disk.read_all("bucket", "object")
|
||||
.await
|
||||
.expect("the ready endpoint must serve real I/O"),
|
||||
Bytes::from_static(b"recovered-peer-data")
|
||||
);
|
||||
assert_eq!(
|
||||
peers[1].peer.object_read_all_disks(),
|
||||
vec![reference.erasure.sets[0][1].to_string()],
|
||||
"the usable handle must carry its validated slot identity"
|
||||
);
|
||||
}
|
||||
gate.release.cancel();
|
||||
pending.await.expect("all endpoint probes must finish after release");
|
||||
for disk in set.disks.read().await.iter().flatten() {
|
||||
disk.close().await.expect("stop each published handle's monitoring");
|
||||
}
|
||||
for peer in peers {
|
||||
peer.stop().await;
|
||||
}
|
||||
ready.expect("a blocked earlier endpoint must not delay a ready drive in the same set");
|
||||
assert!(
|
||||
set.disks.read().await.iter().all(Option::is_some),
|
||||
"both endpoints must eventually be admitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(remote_disk_recovery_probe)]
|
||||
async fn missing_read_disks_preserve_concurrently_published_handles() {
|
||||
|
||||
@@ -41,10 +41,6 @@ use tonic::service::interceptor::InterceptedService;
|
||||
use tonic::{Request, Response};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
|
||||
|
||||
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
||||
set_tonic_rolling_mutation_body_digest(request)
|
||||
}
|
||||
@@ -237,20 +233,6 @@ pub struct RemoteClient {
|
||||
impl RemoteClient {
|
||||
const ONLINE_CHECK_RESOURCE: &'static str = "health-lock-online";
|
||||
|
||||
fn record_release_ack(&self, lock_id: &LockId) {
|
||||
tracing::trace!(
|
||||
target: "rustfs_ecstore::object_lock_diag",
|
||||
event = EVENT_NAMESPACE_LOCK_RELEASE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
|
||||
state = "acknowledged",
|
||||
peer_kind = "remote",
|
||||
lock_id = %lock_id,
|
||||
peer = %self.addr,
|
||||
"Remote namespace lock release acknowledged"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn new(endpoint: String) -> Self {
|
||||
Self { addr: endpoint }
|
||||
}
|
||||
@@ -1222,6 +1204,8 @@ impl LockClient for RemoteClient {
|
||||
}
|
||||
|
||||
async fn release(&self, lock_id: &LockId) -> Result<bool> {
|
||||
info!("remote release for {}", lock_id);
|
||||
|
||||
let unlock_request = Self::create_unlock_request(lock_id);
|
||||
let request_string = serde_json::to_string(&unlock_request)
|
||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
||||
@@ -1242,9 +1226,6 @@ impl LockClient for RemoteClient {
|
||||
if let Some(error_info) = resp.error_info {
|
||||
return Err(LockError::internal(error_info));
|
||||
}
|
||||
if resp.success {
|
||||
self.record_release_ack(lock_id);
|
||||
}
|
||||
Ok(resp.success)
|
||||
}
|
||||
|
||||
@@ -1280,13 +1261,7 @@ impl LockClient for RemoteClient {
|
||||
Ok(lock_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, lock_id)| {
|
||||
let result = resp.results.get(idx);
|
||||
if result.is_some_and(|result| result.success && result.error_info.is_none()) {
|
||||
self.record_release_ack(lock_id);
|
||||
}
|
||||
result.map(|result| result.success).unwrap_or(false)
|
||||
})
|
||||
.map(|(idx, _)| resp.results.get(idx).map(|result| result.success).unwrap_or(false))
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t(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)));
|
||||
}
|
||||
}
|
||||
+128
-989
File diff suppressed because it is too large
Load Diff
@@ -473,6 +473,7 @@ mod decommission_lock_order_tests {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
|
||||
.await;
|
||||
*ctx.local_disk_map().write().await = other_store.ctx.local_disk_map().read().await.clone();
|
||||
let endpoints = other_store
|
||||
.ctx
|
||||
.endpoints()
|
||||
@@ -481,43 +482,9 @@ mod decommission_lock_order_tests {
|
||||
if let Some(deployment_id) = other_store.ctx.deployment_id() {
|
||||
ctx.set_deployment_id(deployment_id);
|
||||
}
|
||||
let authority = ctx
|
||||
.bind_multipart_proof_topology(&endpoints)
|
||||
.expect("lease-loss fixture must own its complete native proof topology");
|
||||
let mut local_disk_map = HashMap::new();
|
||||
for pool in &mut pools {
|
||||
let pool = Arc::make_mut(pool);
|
||||
pool.set_instance_ctx_for_test(Arc::clone(&ctx));
|
||||
for set in &mut pool.disk_set {
|
||||
let set = Arc::make_mut(set);
|
||||
let source_disks = set.disks.read().await.clone();
|
||||
let mut disks = Vec::with_capacity(set.set_endpoints.len());
|
||||
for (slot, endpoint) in set.set_endpoints.iter().enumerate() {
|
||||
// The logical node clone shares durable contents, never a
|
||||
// native handle already owned by another instance scope.
|
||||
let disk = crate::disk::new_disk(
|
||||
endpoint,
|
||||
&crate::disk::DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("open the lease-loss node's independently owned native handle");
|
||||
assert!(!Arc::ptr_eq(&disk, source_disks[slot].as_ref().expect("source fixture disk")));
|
||||
assert_eq!(
|
||||
crate::disk::DiskAPI::get_disk_id(disk.as_ref()).await.unwrap(),
|
||||
Some(pool.format.erasure.sets[set.set_index][slot])
|
||||
);
|
||||
local_disk_map.insert(endpoint.to_string(), Some(disk.clone()));
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
|
||||
.expect("lease-loss native handles must bind only to this fixture instance");
|
||||
set.disks = Arc::new(tokio::sync::RwLock::new(disks));
|
||||
}
|
||||
Arc::make_mut(pool).set_instance_ctx_for_test(Arc::clone(&ctx));
|
||||
}
|
||||
*ctx.local_disk_map().write().await = local_disk_map;
|
||||
let store = Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: other_store.disk_map.clone(),
|
||||
@@ -6919,6 +6886,7 @@ mod decommission_lock_order_tests {
|
||||
};
|
||||
owner.apply_to(&mut abort_opts);
|
||||
let barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::AbortAfterDelete);
|
||||
tokio::time::pause();
|
||||
let abort = tokio::spawn({
|
||||
let abort_store = Arc::clone(&lossy_store);
|
||||
let abort_bucket = bucket.clone();
|
||||
@@ -6938,9 +6906,6 @@ mod decommission_lock_order_tests {
|
||||
}
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
// Native cleanup performs real filesystem I/O. Pause time only after
|
||||
// durable deletion, when the abort is blocked before its release save.
|
||||
tokio::time::pause();
|
||||
tokio::task::yield_now().await;
|
||||
refresh_calls.arm();
|
||||
tokio::time::advance(Duration::from_secs(11)).await;
|
||||
|
||||
+164
-11
@@ -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);
|
||||
|
||||
@@ -958,26 +958,6 @@ fn is_data_movement_rewritten_transition_metadata(object_info: &ObjectInfo, key:
|
||||
})
|
||||
}
|
||||
|
||||
fn data_movement_publication_marker_valid(object_info: &ObjectInfo) -> bool {
|
||||
use crate::disk::multipart_source::{SourceConditionV1, publication_identity_from_metadata};
|
||||
|
||||
let publication = match publication_identity_from_metadata(&object_info.user_defined) {
|
||||
Ok(Some(publication)) => publication,
|
||||
Ok(None) => return true,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if publication.final_data_dir.is_nil() {
|
||||
return false;
|
||||
}
|
||||
SourceConditionV1 {
|
||||
lineage: publication.lineage,
|
||||
generation: publication.source_generation,
|
||||
attempt: publication.attempt,
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, normalize_compression_size: bool) -> bool {
|
||||
[
|
||||
SUFFIX_DATA_MOVED,
|
||||
@@ -992,7 +972,6 @@ fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, norm
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| is_data_movement_internal_metadata(key, suffix))
|
||||
|| crate::disk::multipart_source::is_publication_metadata_key(key)
|
||||
|| is_data_movement_rewritten_transition_metadata(object_info, key)
|
||||
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM
|
||||
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE
|
||||
@@ -1015,14 +994,6 @@ pub(crate) fn is_equivalent_data_movement_metadata(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy can retain a native publication after changing the current key,
|
||||
// version or placement. Validate that historical record's structure, then
|
||||
// exclude only this per-copy provenance from content equivalence. Actual
|
||||
// current scope/content remain independently compared below; this grants
|
||||
// no native write-quorum proof or Complete replay authority.
|
||||
if !data_movement_publication_marker_valid(source) || !data_movement_publication_marker_valid(target) {
|
||||
return false;
|
||||
}
|
||||
let normalize_compression_size = source.is_compressed() && target.is_compressed();
|
||||
if normalize_compression_size
|
||||
&& !is_compatible_data_movement_marker_presence(
|
||||
@@ -1062,25 +1033,6 @@ pub(crate) fn is_equivalent_data_movement_object_identity(
|
||||
target: &ObjectInfo,
|
||||
compare_mod_time: bool,
|
||||
compare_part_checksums: bool,
|
||||
) -> bool {
|
||||
source.bucket == target.bucket
|
||||
&& source.name == target.name
|
||||
&& is_equivalent_data_movement_object_fields(source, target, compare_mod_time, compare_part_checksums)
|
||||
}
|
||||
|
||||
// Exact-delete capacity reconciliation reads a target at a fixed physical key.
|
||||
// Keep its checked encoded-name contract separate from logical-key equality.
|
||||
pub(crate) fn is_equivalent_data_movement_capacity_target(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
source.bucket == target.bucket
|
||||
&& source.name == rustfs_utils::path::decode_dir_object(&target.name)
|
||||
&& is_equivalent_data_movement_object_fields(source, target, true, false)
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_object_fields(
|
||||
source: &ObjectInfo,
|
||||
target: &ObjectInfo,
|
||||
compare_mod_time: bool,
|
||||
compare_part_checksums: bool,
|
||||
) -> bool {
|
||||
let (Some(source_actual_size), Some(target_actual_size)) = (effective_actual_size(source), effective_actual_size(target))
|
||||
else {
|
||||
@@ -3597,268 +3549,6 @@ mod tests {
|
||||
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_identity_keeps_logical_directory_and_literal_suffix_distinct() {
|
||||
let fi = FileInfo {
|
||||
version_id: Some(Uuid::from_u128(1)),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
size: 10,
|
||||
metadata: HashMap::from([("etag".to_owned(), "directory-etag".to_owned())]),
|
||||
..Default::default()
|
||||
};
|
||||
let source = ObjectInfo::from_file_info(&fi, "bucket", "directory/", true);
|
||||
let target = ObjectInfo::from_file_info(&fi, "bucket", &encode_dir_object("directory/"), true);
|
||||
assert_eq!(target.name, "directory/", "real metadata construction returns the logical name");
|
||||
assert!(is_equivalent_data_movement_object_identity(&source, &target, true, false));
|
||||
assert!(is_equivalent_data_movement_capacity_target(&source, &target));
|
||||
|
||||
let literal = ObjectInfo {
|
||||
name: "directory__XLDIR__".to_owned(),
|
||||
..target
|
||||
};
|
||||
assert!(crate::bucket::utils::is_valid_object_name(&literal.name));
|
||||
assert!(crate::bucket::utils::is_valid_object_name(&source.name));
|
||||
assert!(!is_equivalent_data_movement_object_identity(&source, &literal, true, false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_movement_equivalence_validates_each_real_native_multipart_publication() {
|
||||
use crate::disk::DiskAPI as _;
|
||||
use crate::disk::multipart_source::{is_publication_metadata_key, publication_identity_from_metadata};
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
|
||||
let (_source_dirs, source_disks, source_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
|
||||
let (_target_dirs, target_disks, target_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
|
||||
let bucket = "native-multipart-migration-equivalence";
|
||||
let object = "native-multipart-migration-equivalence-object";
|
||||
for disk in source_disks.iter().chain(&target_disks) {
|
||||
disk.make_volume(bucket).await.expect("create both real bucket volumes");
|
||||
}
|
||||
let body = vec![0x39; 1 << 20];
|
||||
let upload = source_set
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("real source upload");
|
||||
let part = source_set
|
||||
.put_object_part(
|
||||
bucket,
|
||||
object,
|
||||
&upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(body.clone()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("real source part");
|
||||
source_set
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
bucket,
|
||||
object,
|
||||
&upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("real native source publication");
|
||||
let source = source_set
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("actual committed source metadata");
|
||||
let create_opts = data_movement_new_multipart_opts(&source, 0);
|
||||
let moved_upload = target_set
|
||||
.new_multipart_upload(bucket, object, &create_opts)
|
||||
.await
|
||||
.expect("real temporary transfer upload");
|
||||
let part_opts = ObjectOptions {
|
||||
part_number: Some(source.parts[0].number),
|
||||
preserve_etag: Some(source.parts[0].etag.clone()),
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
};
|
||||
let part = target_set
|
||||
.put_object_part(
|
||||
bucket,
|
||||
object,
|
||||
&moved_upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(body.clone()),
|
||||
&part_opts,
|
||||
)
|
||||
.await
|
||||
.expect("real transfer part");
|
||||
let complete_opts = data_movement_complete_multipart_opts(&source, 0, false).expect("compatible transfer options");
|
||||
target_set
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
bucket,
|
||||
object,
|
||||
&moved_upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&complete_opts,
|
||||
)
|
||||
.await
|
||||
.expect("real native migration publication");
|
||||
let target = target_set
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("actual committed target metadata");
|
||||
let source_publication = publication_identity_from_metadata(&source.user_defined)
|
||||
.expect("source tuple decodes")
|
||||
.expect("source producer retained its native tuple");
|
||||
let target_publication = publication_identity_from_metadata(&target.user_defined)
|
||||
.expect("target tuple decodes")
|
||||
.expect("target producer retained its own native tuple");
|
||||
assert_ne!(
|
||||
source_publication, target_publication,
|
||||
"physical publications have distinct attempts and directories"
|
||||
);
|
||||
assert_ne!(source.data_dir, target.data_dir);
|
||||
assert!(
|
||||
are_equivalent_data_movement_parts(&source.parts, &target.parts),
|
||||
"the real transport preserves each part ETag"
|
||||
);
|
||||
assert!(is_equivalent_data_movement_object(&source, &target));
|
||||
assert_eq!(
|
||||
resolve_data_movement_overwrite_resume_copy(
|
||||
&Error::PreconditionFailed,
|
||||
Ok(Some(target.clone())),
|
||||
&source,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.expect("actual equivalent target resolves the failed create-only condition"),
|
||||
Some(DataMovementCopy::Committed)
|
||||
);
|
||||
for set in [&source_set, &target_set] {
|
||||
let mut reader = set
|
||||
.get_object_reader(bucket, object, None, http::HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("actual source and target bodies");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read full actual body");
|
||||
assert_eq!(actual, body);
|
||||
}
|
||||
for changed in [
|
||||
"data-dir",
|
||||
"version",
|
||||
"mtime",
|
||||
"bucket",
|
||||
"key",
|
||||
"user-metadata",
|
||||
"etag",
|
||||
"checksum",
|
||||
"parts",
|
||||
"condition",
|
||||
"status",
|
||||
"unknown",
|
||||
] {
|
||||
let mut invalid = target.clone();
|
||||
match changed {
|
||||
"data-dir" => {
|
||||
let mut historical = publication_identity_from_metadata(&invalid.user_defined)
|
||||
.expect("real tuple decodes")
|
||||
.expect("real tuple exists");
|
||||
historical.final_data_dir = Uuid::nil();
|
||||
let encoded = hex_simd::encode_to_string(
|
||||
rmp_serde::to_vec_named(&historical).expect("encode structurally invalid historical tuple"),
|
||||
hex_simd::AsciiCase::Lower,
|
||||
);
|
||||
for (key, value) in Arc::make_mut(&mut invalid.user_defined).iter_mut() {
|
||||
if is_publication_metadata_key(key) {
|
||||
value.clone_from(&encoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
"version" => invalid.version_id = Some(Uuid::new_v4()),
|
||||
"mtime" => invalid.mod_time = invalid.mod_time.map(|time| time + time::Duration::seconds(1)),
|
||||
"bucket" => invalid.bucket.push_str("-other"),
|
||||
"key" => invalid.name.push_str("-other"),
|
||||
"etag" => invalid.etag = Some("different-current-etag".to_owned()),
|
||||
"checksum" => invalid.checksum = Some(Bytes::from_static(b"different-current-checksum")),
|
||||
"parts" => Arc::make_mut(&mut invalid.parts)[0].etag.push_str("-other"),
|
||||
"user-metadata" => {
|
||||
Arc::make_mut(&mut invalid.user_defined).insert("x-amz-meta-extra".to_owned(), "changed".to_owned());
|
||||
}
|
||||
suffix => rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut invalid.user_defined),
|
||||
&format!("multipart-source-{suffix}"),
|
||||
"unexpected".to_owned(),
|
||||
),
|
||||
}
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&source, &invalid),
|
||||
"{changed} cannot bypass metadata equivalence"
|
||||
);
|
||||
}
|
||||
let mut malformed = target.clone();
|
||||
for (key, value) in Arc::make_mut(&mut malformed.user_defined).iter_mut() {
|
||||
if is_publication_metadata_key(key) {
|
||||
*value = "malformed".to_owned();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&source, &malformed),
|
||||
"malformed publication cannot be ignored"
|
||||
);
|
||||
let mut conflicting = target.clone();
|
||||
let aliases = conflicting
|
||||
.user_defined
|
||||
.keys()
|
||||
.filter(|key| is_publication_metadata_key(key))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(aliases.len(), 2, "actual writer stores both canonical publication aliases");
|
||||
Arc::make_mut(&mut conflicting.user_defined).insert(aliases[0].clone(), "conflicting".to_owned());
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&source, &conflicting),
|
||||
"conflicting aliases cannot be ignored"
|
||||
);
|
||||
let mut malformed_source = source.clone();
|
||||
for (key, value) in Arc::make_mut(&mut malformed_source.user_defined).iter_mut() {
|
||||
if is_publication_metadata_key(key) {
|
||||
*value = "malformed-source".to_owned();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&malformed_source, &target),
|
||||
"a malformed present source publication cannot be projected away"
|
||||
);
|
||||
let mut takeover = target.clone();
|
||||
takeover.mod_time = source.mod_time.map(|time| time + time::Duration::seconds(1));
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut takeover.user_defined),
|
||||
SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
data_movement_upload_identity(&source),
|
||||
);
|
||||
assert!(is_data_movement_upload_takeover_target(&source, &takeover, true));
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut takeover.user_defined),
|
||||
SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
"different-transfer-identity".to_owned(),
|
||||
);
|
||||
assert!(
|
||||
!is_data_movement_upload_takeover_target(&source, &takeover, true),
|
||||
"historical publication cannot bypass the exact current transfer identity"
|
||||
);
|
||||
let mut legacy = target.clone();
|
||||
Arc::make_mut(&mut legacy.user_defined).retain(|key, _| !is_publication_metadata_key(key));
|
||||
assert!(
|
||||
is_equivalent_data_movement_object(&source, &legacy),
|
||||
"a legacy destination may lack per-copy provenance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_equivalent_data_movement_object_accepts_matching_metadata() {
|
||||
let version_id = Uuid::nil();
|
||||
|
||||
@@ -216,7 +216,7 @@ pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket:
|
||||
// Stale or empty cache: refresh through the TTL-bounded loader. A failed
|
||||
// refresh carries the previous baseline forward, so quota admission keeps
|
||||
// its last grounded values through a backend read outage.
|
||||
let _ = project_data_usage_from_backend_cached(store, |_| ()).await;
|
||||
let _ = load_data_usage_from_backend_cached(store).await;
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
cache
|
||||
.as_ref()
|
||||
@@ -237,34 +237,31 @@ struct CachedDataUsageSnapshot {
|
||||
}
|
||||
|
||||
impl CachedDataUsageSnapshot {
|
||||
fn result<T>(&self, project: impl FnOnce(&DataUsageInfo) -> T) -> Result<T, Error> {
|
||||
fn result(&self) -> Result<DataUsageInfo, Error> {
|
||||
self.info
|
||||
.as_ref()
|
||||
.map(project)
|
||||
.clone()
|
||||
.ok_or_else(|| Error::other("data usage snapshot load recently failed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_cached_data_usage_snapshot<T>(
|
||||
fn fresh_cached_data_usage_snapshot(
|
||||
cache: &Option<CachedDataUsageSnapshot>,
|
||||
now: tokio::time::Instant,
|
||||
ttl: Duration,
|
||||
project: impl FnOnce(&DataUsageInfo) -> T,
|
||||
) -> Option<Result<T, Error>> {
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
cache
|
||||
.as_ref()
|
||||
.filter(|cached| now.duration_since(cached.loaded_at) < ttl)
|
||||
.map(|cached| cached.result(project))
|
||||
.map(CachedDataUsageSnapshot::result)
|
||||
}
|
||||
|
||||
fn cache_data_usage_snapshot_result<T>(
|
||||
fn cache_data_usage_snapshot_result(
|
||||
cache: &mut Option<CachedDataUsageSnapshot>,
|
||||
result: Result<LoadedUsageBaseline, Error>,
|
||||
loaded_at: tokio::time::Instant,
|
||||
refresh_generation: u64,
|
||||
current_generation: u64,
|
||||
project: impl FnOnce(&DataUsageInfo) -> T,
|
||||
) -> Option<Result<T, Error>> {
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
if current_generation != refresh_generation {
|
||||
return None;
|
||||
}
|
||||
@@ -283,13 +280,12 @@ fn cache_data_usage_snapshot_result<T>(
|
||||
degraded_baseline.entry(bucket.clone()).or_insert(*size);
|
||||
}
|
||||
}
|
||||
let projected = project(&info);
|
||||
*cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info),
|
||||
info: Some(info.clone()),
|
||||
loaded_at,
|
||||
degraded_baseline,
|
||||
});
|
||||
Ok(projected)
|
||||
Ok(info)
|
||||
}
|
||||
Err(e) => {
|
||||
// Keep the previous baseline through a failed refresh: quota
|
||||
@@ -1490,21 +1486,12 @@ async fn normalize_loaded_data_usage(
|
||||
/// `save_data_usage_in_backend` invalidates the cache so a fresh scanner save
|
||||
/// is visible immediately in the saving process.
|
||||
pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
|
||||
project_data_usage_from_backend_cached(store, DataUsageInfo::clone).await
|
||||
}
|
||||
|
||||
// Project under the cache guard so scalar readers never copy the snapshot's
|
||||
// bucket maps. The owned API shares the same TTL, refresh and invalidation path.
|
||||
async fn project_data_usage_from_backend_cached<T>(
|
||||
store: Arc<ECStore>,
|
||||
project: impl Fn(&DataUsageInfo) -> T,
|
||||
) -> Result<T, Error> {
|
||||
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
|
||||
|
||||
loop {
|
||||
{
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1516,7 +1503,7 @@ async fn project_data_usage_from_backend_cached<T>(
|
||||
.await;
|
||||
{
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1525,14 +1512,9 @@ async fn project_data_usage_from_backend_cached<T>(
|
||||
let result = load_data_usage_from_backend_with_baseline(store.clone()).await;
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let mut cache = data_usage_snapshot_cache().write().await;
|
||||
if let Some(result) = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
result,
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
&project,
|
||||
) {
|
||||
if let Some(result) =
|
||||
cache_data_usage_snapshot_result(&mut cache, result, loaded_at, refresh_generation, data_usage_snapshot_generation())
|
||||
{
|
||||
return result;
|
||||
}
|
||||
drop(cache);
|
||||
@@ -1550,8 +1532,7 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
|
||||
loop {
|
||||
{
|
||||
let cache = admin_data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
|
||||
{
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1562,8 +1543,7 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
|
||||
.await;
|
||||
{
|
||||
let cache = admin_data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
|
||||
{
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1584,7 +1564,6 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
admin_data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
@@ -2298,10 +2277,8 @@ async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
|
||||
}
|
||||
|
||||
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
project_data_usage_from_backend_cached(store, |info| info.buckets_usage.get(bucket).map(|usage| usage.size))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
let info = load_data_usage_from_backend_cached(store).await.ok()?;
|
||||
info.buckets_usage.get(bucket).map(|usage| usage.size)
|
||||
}
|
||||
|
||||
async fn reload_persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
@@ -2398,7 +2375,6 @@ where
|
||||
tokio::time::Instant::now(),
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
|_| (),
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
@@ -2915,8 +2891,6 @@ pub async fn init_compression_total_memory_from_backend(store: Arc<ECStore>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod snapshot_projection;
|
||||
|
||||
use super::*;
|
||||
use crate::layout::endpoints::EndpointServerPools;
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
@@ -4598,29 +4572,15 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache");
|
||||
assert!(matches!(first, Err(Error::ErasureReadQuorum)));
|
||||
|
||||
let cached = fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(1),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("the failed load must remain cached within the TTL");
|
||||
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
|
||||
.expect("the failed load must remain cached within the TTL");
|
||||
assert!(cached.is_err());
|
||||
|
||||
assert!(
|
||||
fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(30), Duration::from_secs(30)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4641,20 +4601,14 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache")
|
||||
.expect("successful load must be returned");
|
||||
assert_snapshot_bucket(&first, "bucket");
|
||||
|
||||
let cached = fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(1),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("successful load must remain cached within the TTL")
|
||||
.expect("cached success must remain successful");
|
||||
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
|
||||
.expect("successful load must remain cached within the TTL")
|
||||
.expect("cached success must remain successful");
|
||||
assert_snapshot_bucket(&cached, "bucket");
|
||||
}
|
||||
|
||||
@@ -4679,7 +4633,6 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache")
|
||||
.expect("successful load must be returned");
|
||||
@@ -4713,7 +4666,6 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
);
|
||||
|
||||
assert!(stale_result.is_none());
|
||||
|
||||
@@ -1,191 +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::*;
|
||||
|
||||
#[test]
|
||||
fn scalar_projection_borrows_snapshot_and_preserves_unknown_and_zero() {
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
|
||||
let cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info),
|
||||
loaded_at,
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
let stored = cache
|
||||
.as_ref()
|
||||
.expect("cached snapshot")
|
||||
.info
|
||||
.as_ref()
|
||||
.expect("known snapshot");
|
||||
let ttl = Duration::from_secs(30);
|
||||
|
||||
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
|
||||
let value = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, |info| {
|
||||
assert!(std::ptr::eq(info, stored), "projection must borrow the cached snapshot");
|
||||
info.buckets_usage.get(bucket).map(|usage| usage.size)
|
||||
})
|
||||
.expect("fresh snapshot")
|
||||
.expect("successful snapshot");
|
||||
let owned = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, DataUsageInfo::clone)
|
||||
.expect("fresh snapshot")
|
||||
.expect("successful snapshot");
|
||||
assert_eq!(value, expected);
|
||||
assert_eq!(value, owned.buckets_usage.get(bucket).map(|usage| usage.size));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_is_skipped_for_missing_failed_expired_or_invalidated_snapshots() {
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let ttl = Duration::from_secs(30);
|
||||
let never_project = |_: &DataUsageInfo| -> u64 { panic!("unusable snapshots must not be projected") };
|
||||
assert!(fresh_cached_data_usage_snapshot(&None, loaded_at, ttl, never_project).is_none());
|
||||
|
||||
let mut cache = None;
|
||||
let failure = cache_data_usage_snapshot_result(&mut cache, Err(Error::ErasureReadQuorum), loaded_at, 1, 1, never_project)
|
||||
.expect("matching generation");
|
||||
assert!(matches!(failure, Err(Error::ErasureReadQuorum)));
|
||||
assert!(
|
||||
fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, never_project)
|
||||
.expect("negative cache remains fresh")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH)),
|
||||
loaded_at,
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + ttl, ttl, never_project).is_none());
|
||||
cache = None;
|
||||
let rejected = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok(LoadedUsageBaseline {
|
||||
info: data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH),
|
||||
degraded_baseline: HashMap::new(),
|
||||
observed_unavailable: false,
|
||||
}),
|
||||
loaded_at,
|
||||
1,
|
||||
2,
|
||||
never_project,
|
||||
);
|
||||
assert!(rejected.is_none());
|
||||
assert!(cache.is_none(), "rejected generation must not republish stale data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_publication_moves_bucket_maps_into_cache() {
|
||||
let info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
let original_key = info.buckets_usage.get_key_value("known").expect("source bucket").0.as_ptr();
|
||||
let mut cache = None;
|
||||
let usage = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok(LoadedUsageBaseline {
|
||||
info,
|
||||
degraded_baseline: HashMap::new(),
|
||||
observed_unavailable: false,
|
||||
}),
|
||||
tokio::time::Instant::now(),
|
||||
1,
|
||||
1,
|
||||
|info| {
|
||||
assert_eq!(
|
||||
info.buckets_usage
|
||||
.get_key_value("known")
|
||||
.expect("projected bucket")
|
||||
.0
|
||||
.as_ptr(),
|
||||
original_key
|
||||
);
|
||||
info.buckets_usage.get("known").map(|usage| usage.size)
|
||||
},
|
||||
)
|
||||
.expect("matching generation")
|
||||
.expect("successful publication");
|
||||
assert_eq!(usage, Some(42));
|
||||
let cached = cache
|
||||
.as_ref()
|
||||
.expect("published cache")
|
||||
.info
|
||||
.as_ref()
|
||||
.expect("published snapshot");
|
||||
assert_eq!(
|
||||
cached.buckets_usage.get_key_value("known").expect("cached bucket").0.as_ptr(),
|
||||
original_key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
|
||||
info.bucket_sizes.insert("empty".to_string(), 0);
|
||||
info.buckets_count = 2;
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&info).expect("encode snapshot"),
|
||||
)
|
||||
.await
|
||||
.expect("persist snapshot");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
|
||||
assert_eq!(persisted_bucket_usage(store.clone(), bucket).await, expected);
|
||||
let owned = load_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("owned cached snapshot");
|
||||
assert_eq!(expected, owned.buckets_usage.get(bucket).map(|usage| usage.size));
|
||||
}
|
||||
info.buckets_usage.get_mut("known").expect("known bucket").size = 84;
|
||||
info.bucket_sizes.insert("known".to_string(), 84);
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&info).expect("encode new snapshot"),
|
||||
)
|
||||
.await
|
||||
.expect("persist new snapshot without invalidating cache");
|
||||
assert_eq!(
|
||||
persisted_bucket_usage(store.clone(), "known").await,
|
||||
Some(42),
|
||||
"warm reads reuse the TTL cache"
|
||||
);
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
assert_eq!(
|
||||
persisted_bucket_usage(store.clone(), "known").await,
|
||||
Some(84),
|
||||
"invalidation exposes the new snapshot"
|
||||
);
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,10 +393,6 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_online_drive_state(state: &str) -> bool {
|
||||
state == DriveState::Ok.to_string() || state == DriveState::Unformatted.to_string()
|
||||
}
|
||||
|
||||
/// Classify every drive into online / offline / unknown buckets.
|
||||
///
|
||||
/// `unknown` holds drives synthesized for a member whose properties RPC could
|
||||
@@ -416,7 +412,6 @@ fn get_online_offline_disks_stats(disks_info: &[Disk]) -> (BackendDisks, Backend
|
||||
unknown_disks.entry(ep.clone()).or_insert(0);
|
||||
}
|
||||
|
||||
let mut online_root_disk_count = 0;
|
||||
for disk in disks_info {
|
||||
let ep = &disk.endpoint;
|
||||
let state = &disk.state;
|
||||
@@ -424,28 +419,30 @@ fn get_online_offline_disks_stats(disks_info: &[Disk]) -> (BackendDisks, Backend
|
||||
*unknown_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
continue;
|
||||
}
|
||||
if !is_online_drive_state(state) {
|
||||
if *state != DriveState::Ok.to_string() && *state != DriveState::Unformatted.to_string() {
|
||||
*offline_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
continue;
|
||||
}
|
||||
*online_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
if disk.root_disk {
|
||||
online_root_disk_count += 1;
|
||||
}
|
||||
|
||||
let mut root_disk_count = 0;
|
||||
for di in disks_info {
|
||||
if di.root_disk {
|
||||
root_disk_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// When every non-offline, non-unknown drive is a root mount, leave the
|
||||
// online tally as-is instead of demoting all of them (matches the prior
|
||||
// behavior; the unknown bucket is simply carried through untouched).
|
||||
if disks_info.len()
|
||||
== (online_root_disk_count + offline_disks.values().sum::<usize>() + unknown_disks.values().sum::<usize>())
|
||||
{
|
||||
if disks_info.len() == (root_disk_count + offline_disks.values().sum::<usize>() + unknown_disks.values().sum::<usize>()) {
|
||||
return (BackendDisks(online_disks), BackendDisks(offline_disks), BackendDisks(unknown_disks));
|
||||
}
|
||||
|
||||
for disk in disks_info {
|
||||
let ep = &disk.endpoint;
|
||||
if disk.root_disk && is_online_drive_state(&disk.state) {
|
||||
if disk.root_disk {
|
||||
*offline_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
*online_disks.get_mut(ep).expect("endpoint should be in disk map") -= 1;
|
||||
}
|
||||
@@ -838,35 +835,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_stats_only_demote_online_root_disks() {
|
||||
let mut offline_root = disk_with_state("http://n3:9000/data", ITEM_OFFLINE);
|
||||
offline_root.root_disk = true;
|
||||
let mut unknown_root = disk_with_state("http://n4:9000/data", ITEM_UNKNOWN);
|
||||
unknown_root.root_disk = true;
|
||||
let mut online_root = disk_with_state("http://n5:9000/data", "ok");
|
||||
online_root.root_disk = true;
|
||||
|
||||
let disks = vec![
|
||||
disk_with_state("http://n1:9000/data", "ok"),
|
||||
disk_with_state("http://n2:9000/data", "ok"),
|
||||
offline_root,
|
||||
unknown_root,
|
||||
online_root,
|
||||
];
|
||||
|
||||
let (online, offline, unknown) = get_online_offline_disks_stats(&disks);
|
||||
|
||||
assert_eq!(online.sum(), 2, "only the two non-root online drives remain online");
|
||||
assert_eq!(offline.sum(), 2, "the offline root drive and demoted online root drive are offline");
|
||||
assert_eq!(unknown.sum(), 1, "the unknown root drive stays in unknown without underflowing online");
|
||||
assert_eq!(
|
||||
online.sum() + offline.sum() + unknown.sum(),
|
||||
disks.len(),
|
||||
"online + offline + unknown must equal the total drive count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_reconcile_synthesizes_missing_member_as_unknown() {
|
||||
let endpoints = topology_with_hosts(&["rustfs-1", "rustfs-2", "rustfs-3", "rustfs-4"]);
|
||||
|
||||
@@ -19,7 +19,6 @@ use tracing::Span;
|
||||
use uuid::Uuid;
|
||||
|
||||
const EVENT_OBJECT_NAMESPACE_LOCK: &str = "object_namespace_lock";
|
||||
const EVENT_OBJECT_NAMESPACE_LOCK_PEER: &str = "object_namespace_lock_peer";
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_OBJECT_LOCK: &str = "object_lock";
|
||||
|
||||
@@ -57,7 +56,6 @@ struct AttemptContext {
|
||||
requested_version_id: Option<String>,
|
||||
namespace: String,
|
||||
owner: String,
|
||||
lock_id: Option<String>,
|
||||
mode: &'static str,
|
||||
timeout: Duration,
|
||||
started_at: Instant,
|
||||
@@ -87,7 +85,6 @@ impl ObjectLockAttempt {
|
||||
requested_version_id: requested_version_id.map(str::to_owned),
|
||||
namespace: lock.namespace().to_owned(),
|
||||
owner: lock.owner().to_owned(),
|
||||
lock_id: None,
|
||||
mode,
|
||||
timeout,
|
||||
started_at: Instant::now(),
|
||||
@@ -108,29 +105,9 @@ impl ObjectLockAttempt {
|
||||
context.finished = true;
|
||||
context.trace("disabled");
|
||||
}
|
||||
Ok(guard) => {
|
||||
context.lock_id = guard.lock_id().map(ToString::to_string);
|
||||
Ok(_) => {
|
||||
context.acquired_at = Some(Instant::now());
|
||||
context.trace("acquired");
|
||||
if let NamespaceLockGuard::Standard(guard) = guard {
|
||||
for (peer_index, peer_lock_id) in guard.granted_lock_ids().enumerate() {
|
||||
context.parent.in_scope(|| {
|
||||
tracing::trace!(
|
||||
target: "rustfs_ecstore::object_lock_diag",
|
||||
parent: &context.parent,
|
||||
event = EVENT_OBJECT_NAMESPACE_LOCK_PEER,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT_LOCK,
|
||||
state = "granted",
|
||||
lock_attempt_id = %context.id,
|
||||
lock_id = context.lock_id.as_deref(),
|
||||
peer_lock_id = %peer_lock_id,
|
||||
peer_index,
|
||||
"Object namespace peer owner granted"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
context.finished = true;
|
||||
@@ -194,7 +171,6 @@ impl AttemptContext {
|
||||
mode = self.mode,
|
||||
owner = %self.owner,
|
||||
lock_attempt_id = %self.id,
|
||||
lock_id = self.lock_id.as_deref(),
|
||||
acquire_ms = duration_ms(acquired_at.duration_since(self.started_at)),
|
||||
hold_ms = duration_ms(now.duration_since(acquired_at)),
|
||||
timeout_ms = duration_ms(self.timeout),
|
||||
@@ -259,238 +235,15 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
pub(crate) fn rows(&self) -> Vec<serde_json::Value> {
|
||||
self.all_rows()
|
||||
.into_iter()
|
||||
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn all_rows(&self) -> Vec<serde_json::Value> {
|
||||
let bytes = self.0.lock().expect("lock diagnostic capture mutex");
|
||||
let text = std::str::from_utf8(&bytes).expect("UTF-8 diagnostic events");
|
||||
text.lines()
|
||||
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("JSON diagnostic event"))
|
||||
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn object_lock_diagnostics_join_guard_identity_to_physical_release() {
|
||||
use rustfs_lock::{
|
||||
DisabledLockManager, FastObjectLockManager, GlobalLockManager, LocalClient, LockClient, LockRequest, LockType,
|
||||
NamespaceLock, ObjectKey,
|
||||
};
|
||||
|
||||
temp_env::with_vars([(rustfs_config::ENV_OBJECT_LOCK_DIAG_ENABLE, Some("true"))], || {
|
||||
let capture = CapturedLockEvents::default();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("diagnostic release runtime");
|
||||
tracing::subscriber::with_default(capture.subscriber(), || {
|
||||
runtime.block_on(async {
|
||||
let clients = (0..4)
|
||||
.map(|_| Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new()))) as Arc<dyn LockClient>)
|
||||
.collect::<Vec<_>>();
|
||||
let lock = NamespaceLockWrapper::new(
|
||||
NamespaceLock::with_clients_and_quorum("diagnostics".to_owned(), clients.clone(), 4),
|
||||
ObjectKey::new("bucket", "object"),
|
||||
"coordinator".to_owned(),
|
||||
);
|
||||
let request = tracing::info_span!("request", request_id = "original-multipart-request");
|
||||
let mut attempt = request.in_scope(|| {
|
||||
ObjectLockAttempt::start(
|
||||
"abort_multipart_upload",
|
||||
"bucket",
|
||||
"object",
|
||||
None,
|
||||
&lock,
|
||||
"write",
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
});
|
||||
let result = lock.get_write_lock(Duration::from_secs(5)).await;
|
||||
attempt.observe(&result);
|
||||
let guard = result.expect("four real namespace owners");
|
||||
let id = guard.lock_id().expect("distributed guard identity").clone();
|
||||
let peer_ids = match &guard {
|
||||
NamespaceLockGuard::Standard(guard) => {
|
||||
assert_eq!(guard.granted_lock_ids().count(), 4);
|
||||
guard.granted_lock_ids().cloned().collect::<std::collections::HashSet<_>>()
|
||||
}
|
||||
NamespaceLockGuard::Fast(_) => panic!("real distributed namespace required"),
|
||||
};
|
||||
let rows = capture.rows();
|
||||
assert_eq!(rows.last().expect("acquired event")["fields"]["lock_id"], id.to_string());
|
||||
let rows = capture.all_rows();
|
||||
let mappings = rows
|
||||
.iter()
|
||||
.filter(|row| row["fields"]["event"] == EVENT_OBJECT_NAMESPACE_LOCK_PEER)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(mappings.len(), 4, "every granted peer must map to its logical guard");
|
||||
assert!(mappings.iter().all(|row| {
|
||||
row["fields"]["lock_id"] == id.to_string()
|
||||
&& peer_ids
|
||||
.iter()
|
||||
.any(|peer_id| row["fields"]["peer_lock_id"] == peer_id.to_string())
|
||||
}));
|
||||
for client in &clients {
|
||||
let mut owned = 0;
|
||||
for peer_id in &peer_ids {
|
||||
owned += usize::from(
|
||||
client
|
||||
.check_status(peer_id)
|
||||
.await
|
||||
.expect("actual peer owner status")
|
||||
.is_some(),
|
||||
);
|
||||
}
|
||||
assert_eq!(owned, 1, "each client must hold exactly one actual granted identity");
|
||||
}
|
||||
drop((guard, attempt));
|
||||
assert!(
|
||||
capture.all_rows().iter().all(|row| row["fields"]["state"] != "released"),
|
||||
"enqueue is not release acknowledgement"
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while capture
|
||||
.all_rows()
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
|
||||
})
|
||||
.count()
|
||||
< 4
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all physical release callbacks finish");
|
||||
for client in &clients {
|
||||
for peer_id in &peer_ids {
|
||||
assert!(
|
||||
client
|
||||
.check_status(peer_id)
|
||||
.await
|
||||
.expect("released peer owner status")
|
||||
.is_none()
|
||||
);
|
||||
assert!(!client.release(peer_id).await.expect("duplicate release is absent"));
|
||||
}
|
||||
}
|
||||
let disabled = LocalClient::with_manager(Arc::new(GlobalLockManager::Disabled(DisabledLockManager::new())));
|
||||
let disabled_request =
|
||||
LockRequest::new(ObjectKey::new("bucket", "disabled"), LockType::Exclusive, "disabled-owner".to_owned());
|
||||
assert!(
|
||||
disabled
|
||||
.acquire_lock(&disabled_request)
|
||||
.await
|
||||
.expect("disabled acquire bypass")
|
||||
.success
|
||||
);
|
||||
assert!(
|
||||
disabled
|
||||
.release(&disabled_request.lock_id)
|
||||
.await
|
||||
.expect("disabled release bypass")
|
||||
);
|
||||
let rows = capture.all_rows();
|
||||
let releases = rows
|
||||
.iter()
|
||||
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
releases.len(),
|
||||
4,
|
||||
"duplicates and disabled guards cannot acknowledge another physical release"
|
||||
);
|
||||
let physical_mappings = rows
|
||||
.iter()
|
||||
.filter(|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "mapped")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(physical_mappings.len(), 4);
|
||||
assert!(releases.iter().all(|row| physical_mappings.iter().any(|mapped| {
|
||||
mapped["fields"]["physical_guard_id"] == row["fields"]["physical_guard_id"]
|
||||
&& mapped["fields"]["process_id"] == row["fields"]["process_id"]
|
||||
&& peer_ids
|
||||
.iter()
|
||||
.any(|peer_id| mapped["fields"]["lock_id"] == peer_id.to_string())
|
||||
})));
|
||||
let dropped = capture.rows().pop().expect("guard drop event");
|
||||
assert_eq!(dropped["fields"]["lock_id"], id.to_string());
|
||||
assert!(
|
||||
dropped["spans"]
|
||||
.as_array()
|
||||
.expect("retained request span")
|
||||
.iter()
|
||||
.any(|span| span["request_id"] == "original-multipart-request")
|
||||
);
|
||||
let manager = Arc::new(FastObjectLockManager::new());
|
||||
let stale_client = LocalClient::with_manager(Arc::new(GlobalLockManager::Enabled(manager.clone())));
|
||||
let stale_request = LockRequest::new(
|
||||
ObjectKey::new("bucket", "externally-released"),
|
||||
LockType::Exclusive,
|
||||
"stale-owner".to_owned(),
|
||||
);
|
||||
assert!(
|
||||
stale_client
|
||||
.acquire_lock(&stale_request)
|
||||
.await
|
||||
.expect("real stale owner")
|
||||
.success
|
||||
);
|
||||
assert_eq!(manager.force_unlock(&stale_request.resource), 1);
|
||||
let mut successor = manager
|
||||
.acquire_write_lock(stale_request.resource.clone(), "successor-owner")
|
||||
.await
|
||||
.expect("real successor guard");
|
||||
assert!(
|
||||
stale_client
|
||||
.release(&stale_request.lock_id)
|
||||
.await
|
||||
.expect("retire stale client record")
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.get_lock_info(&stale_request.resource)
|
||||
.expect("successor remains locked")
|
||||
.owner
|
||||
.as_ref(),
|
||||
"successor-owner"
|
||||
);
|
||||
assert_eq!(
|
||||
capture
|
||||
.all_rows()
|
||||
.iter()
|
||||
.filter(
|
||||
|row| row["fields"]["event"] == "namespace_lock_release" && row["fields"]["state"] == "released"
|
||||
)
|
||||
.count(),
|
||||
4,
|
||||
"a stale guard's failed physical release must not produce an ACK"
|
||||
);
|
||||
let successor_id = successor.guard_id();
|
||||
assert!(successor.release(), "the actual successor release succeeds");
|
||||
assert!(!successor.release(), "a duplicate explicit release cannot complete again");
|
||||
drop(successor);
|
||||
assert_eq!(
|
||||
capture
|
||||
.all_rows()
|
||||
.iter()
|
||||
.filter(|row| row["fields"]["event"] == "namespace_lock_release"
|
||||
&& row["fields"]["state"] == "released"
|
||||
&& row["fields"]["physical_guard_id"] == successor_id)
|
||||
.count(),
|
||||
1,
|
||||
"explicit release followed by Drop records one physical completion"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn namespace() -> NamespaceLockWrapper {
|
||||
let manager = rustfs_lock::GlobalLockManager::Enabled(Arc::new(rustfs_lock::fast_lock::FastObjectLockManager::new()));
|
||||
NamespaceLockWrapper::new(
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
//! PUT admission precedes staging; receiver and GC disk work share a separate
|
||||
//! budget so coordinators waiting for peers cannot exhaust receiver capacity.
|
||||
|
||||
use crate::disk::io_admission::{current_resource_unit, with_optional_resource_unit};
|
||||
use crate::disk::{LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_DISK};
|
||||
use std::collections::BTreeSet;
|
||||
use std::future::Future;
|
||||
@@ -423,7 +422,7 @@ impl CleanupRuntime {
|
||||
// the result receiver's lifetime to discard work cancelled during
|
||||
// runtime scheduling, before its first poll can start filesystem I/O.
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
let task = runtime.spawn(with_optional_resource_unit(current_resource_unit(), async move {
|
||||
let task = runtime.spawn(async move {
|
||||
if result_tx.is_closed() {
|
||||
return;
|
||||
}
|
||||
@@ -431,7 +430,7 @@ impl CleanupRuntime {
|
||||
// through I/O even if the waiter cancels and closes result_rx.
|
||||
let result = execute_disk(pending, Some(active), execution, started, future).await;
|
||||
let _ = result_tx.send(result);
|
||||
}));
|
||||
});
|
||||
match result_rx.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
@@ -470,11 +469,11 @@ impl CleanupRuntime {
|
||||
gates: Some((self.disk_admission.clone(), self.disk_execution.clone())),
|
||||
};
|
||||
handle
|
||||
.spawn(with_optional_resource_unit(current_resource_unit(), async move {
|
||||
.spawn(async move {
|
||||
let active = execution.acquire_owned().await.map_err(super::error::DiskError::other)?;
|
||||
let _active = Admission::new(active, "rustfs_cleanup_gc_active");
|
||||
scan(budget).await
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(super::error::DiskError::other)?
|
||||
}
|
||||
@@ -583,7 +582,6 @@ where
|
||||
F::Output: Send + 'static,
|
||||
{
|
||||
let started = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
|
||||
let future = with_optional_resource_unit(current_resource_unit(), future);
|
||||
match reservation {
|
||||
Some(reservation) => reservation.handle.clone().spawn(async move {
|
||||
// This semaphore is never closed. Both queued work and overflow
|
||||
@@ -622,62 +620,6 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_dispatch_retains_resource_units_without_inheriting_disk_policy() {
|
||||
use crate::disk::io_admission::{current_disk_io_context, with_background_disk_io, with_background_resource_unit};
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use std::num::NonZeroU16;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
for mode in 0..4 {
|
||||
let runtime = Arc::new(CleanupRuntime::new(config(1, 1, 1)).expect("cleanup runtime"));
|
||||
let worker_runtime = runtime.clone();
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let work = async move {
|
||||
started_tx
|
||||
.send((current_resource_unit().is_some(), current_disk_io_context().is_none()))
|
||||
.expect("report worker context");
|
||||
release_rx.await.expect("release owned worker");
|
||||
Ok::<_, super::super::error::DiskError>(())
|
||||
};
|
||||
let task = tokio::spawn(with_background_disk_io(
|
||||
CancellationToken::new(),
|
||||
with_background_resource_unit(gate.acquire().await, async move {
|
||||
match mode {
|
||||
0 => worker_runtime
|
||||
.disk_operation(work, DiskAdmission::Request)
|
||||
.await
|
||||
.expect("disk operation"),
|
||||
1 => worker_runtime.gc_operation(move |_| work).await.expect("GC operation"),
|
||||
2 => {
|
||||
let reservation = worker_runtime.reserve().expect("cleanup reservation");
|
||||
run(Some(reservation), async { work.await.expect("cleanup operation") }).await;
|
||||
}
|
||||
_ => spawn(None, work)
|
||||
.await
|
||||
.expect("unisolated worker")
|
||||
.expect("unisolated operation"),
|
||||
}
|
||||
}),
|
||||
));
|
||||
let (inherited, original_policy) = deadline(started_rx).await.expect("owned work starts");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("waiter cancelled").is_cancelled());
|
||||
let held = gate.snapshot().active;
|
||||
release_tx.send(()).expect("owned work survives cancellation");
|
||||
deadline(async {
|
||||
while gate.snapshot().active != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(inherited, "mode {mode} lost resource ownership");
|
||||
assert!(original_policy, "mode {mode} changed the detached worker's disk policy");
|
||||
assert_eq!(held, 1, "mode {mode} released a live worker's unit");
|
||||
}
|
||||
}
|
||||
fn config(pending: usize, workers: usize, blocking_threads: usize) -> Config {
|
||||
Config {
|
||||
pending,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use crate::disk::cleanup_runtime;
|
||||
use crate::disk::io_admission::{
|
||||
AdmissionReader, BoxedChunkReader, DiskIoAdmission, DiskIoClass, DiskIoContext, current_disk_io_context,
|
||||
current_resource_unit, with_disk_io_context, with_disk_io_permit, with_optional_resource_unit,
|
||||
with_disk_io_context, with_disk_io_permit,
|
||||
};
|
||||
use crate::disk::{
|
||||
CheckPartsResp, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskError, DiskInfo, DiskInfoOptions, DiskLocation, Endpoint,
|
||||
@@ -440,7 +440,7 @@ pub(crate) trait DiskStoreRenameDataExt {
|
||||
/// Run a mutation in an owned task when a caller supplied publication guard.
|
||||
/// RPC cancellation drops only the waiter; the mutation owner keeps the guard
|
||||
/// until its operation has returned, including any detached blocking syscall.
|
||||
pub(crate) async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
|
||||
async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
@@ -449,10 +449,10 @@ where
|
||||
if external_guard.is_none() {
|
||||
return operation().await;
|
||||
}
|
||||
tokio::spawn(with_optional_resource_unit(current_resource_unit(), async move {
|
||||
tokio::spawn(async move {
|
||||
let _external_guard = external_guard;
|
||||
operation().await
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::other("owned mutation task failed"))?
|
||||
}
|
||||
@@ -803,45 +803,12 @@ fn unpack_health_state(snapshot: u64) -> (RuntimeDriveHealthState, u32) {
|
||||
(RuntimeDriveHealthState::from_u32((snapshot >> 32) as u32), snapshot as u32)
|
||||
}
|
||||
|
||||
/// One upload-scoped observer; it is never part of production disk state.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScopedMultipartCleanupCompletions {
|
||||
path: String,
|
||||
delete_paths_returns: AtomicU64,
|
||||
delete_returns: AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ScopedMultipartCleanupCompletions {
|
||||
fn observes(&self, operation: &str, volume: &str, path: &str) -> bool {
|
||||
volume == super::RUSTFS_META_MULTIPART_BUCKET
|
||||
&& match operation {
|
||||
"delete" => path == self.path,
|
||||
"delete_paths" => path
|
||||
.strip_prefix(self.path.as_str())
|
||||
.is_some_and(|suffix| suffix.starts_with('/') && suffix.len() > 1),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn completed(&self, operation: &str) -> u64 {
|
||||
match operation {
|
||||
"delete_paths" => self.delete_paths_returns.load(Ordering::Acquire),
|
||||
"delete" => self.delete_returns.load(Ordering::Acquire),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DiskHealthMetricEpoch {
|
||||
/// Preallocated per-operation metrics for built-in disk operation names.
|
||||
operation_metrics: Box<[DiskOperationMetricEntry]>,
|
||||
/// Fallback for tests or future extension operations outside DISK_OPERATION_NAMES.
|
||||
fallback_operation_metrics: StdRwLock<HashMap<&'static str, Arc<DiskOperationMetrics>>>,
|
||||
#[cfg(test)]
|
||||
multipart_cleanup_observer: std::sync::Mutex<Option<std::sync::Weak<ScopedMultipartCleanupCompletions>>>,
|
||||
/// Caller API operations currently executing through the disk health wrapper.
|
||||
api_waiting: AtomicU32,
|
||||
/// Operations rejected because the disk was unavailable for the caller.
|
||||
@@ -867,8 +834,6 @@ impl Default for DiskHealthMetricEpoch {
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice(),
|
||||
fallback_operation_metrics: StdRwLock::new(HashMap::new()),
|
||||
#[cfg(test)]
|
||||
multipart_cleanup_observer: std::sync::Mutex::new(None),
|
||||
api_waiting: AtomicU32::new(0),
|
||||
total_errors_availability: AtomicU64::new(0),
|
||||
total_errors_timeout: AtomicU64::new(0),
|
||||
@@ -928,46 +893,6 @@ impl DiskHealthMetricEpoch {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn observe_multipart_cleanup(&self, path: &str) -> Arc<ScopedMultipartCleanupCompletions> {
|
||||
assert!(!path.is_empty() && !path.ends_with('/'), "observe one exact canonical upload root");
|
||||
let scope = Arc::new(ScopedMultipartCleanupCompletions {
|
||||
path: path.to_owned(),
|
||||
delete_paths_returns: AtomicU64::new(0),
|
||||
delete_returns: AtomicU64::new(0),
|
||||
});
|
||||
let mut observer = self.multipart_cleanup_observer.lock().expect("test cleanup observer lock");
|
||||
assert!(
|
||||
observer.as_ref().and_then(std::sync::Weak::upgrade).is_none(),
|
||||
"one observer per physical wrapper"
|
||||
);
|
||||
*observer = Some(Arc::downgrade(&scope));
|
||||
scope
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn matching_multipart_cleanup<'a>(
|
||||
&self,
|
||||
operation: &str,
|
||||
volume: &str,
|
||||
paths: impl IntoIterator<Item = &'a str>,
|
||||
) -> Option<Arc<ScopedMultipartCleanupCompletions>> {
|
||||
let scope = self
|
||||
.multipart_cleanup_observer
|
||||
.lock()
|
||||
.expect("test cleanup observer lock")
|
||||
.as_ref()
|
||||
.and_then(std::sync::Weak::upgrade)?;
|
||||
let mut nonempty = false;
|
||||
for path in paths {
|
||||
if !scope.observes(operation, volume, path) {
|
||||
return None;
|
||||
}
|
||||
nonempty = true;
|
||||
}
|
||||
nonempty.then_some(scope)
|
||||
}
|
||||
|
||||
fn record_availability_error(&self) {
|
||||
self.total_errors_availability.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1235,13 +1160,7 @@ impl DiskHealthTracker {
|
||||
}
|
||||
|
||||
pub(crate) fn metric_epoch_for_reconnect(&self) -> Self {
|
||||
let health = Self::new();
|
||||
// Explicit test faults stay attached to the drive across handle renewal.
|
||||
#[cfg(test)]
|
||||
if self.test_forced_offline.load(Ordering::Acquire) {
|
||||
health.force_offline_for_test();
|
||||
}
|
||||
health
|
||||
Self::new()
|
||||
}
|
||||
|
||||
pub fn last_capacity_snapshot(&self) -> Option<(u64, u64, u64, u64)> {
|
||||
@@ -1651,52 +1570,6 @@ impl LocalDiskWrapper {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Bound the waiter while the physical recovery task retains its instance owner.
|
||||
pub(crate) async fn recover_multipart_source_with_namespace_owner(
|
||||
&self,
|
||||
request: super::multipart_source::MultipartSourceRequest,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<super::multipart_source::MultipartSourceResponse> {
|
||||
self.recover_multipart_source_owned(request, namespace_owner, get_max_timeout_duration())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_multipart_source_owned(
|
||||
&self,
|
||||
request: super::multipart_source::MultipartSourceRequest,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
wait_timeout: Duration,
|
||||
) -> Result<super::multipart_source::MultipartSourceResponse> {
|
||||
request.validate()?;
|
||||
let mutation = request.operation.is_mutation();
|
||||
let operation = self.clone();
|
||||
let owner = if mutation {
|
||||
Some(namespace_owner.unwrap_or_else(|| Arc::new(()) as Arc<dyn Send + Sync>))
|
||||
} else {
|
||||
namespace_owner
|
||||
};
|
||||
let owned = run_owned_mutation(owner, move || async move {
|
||||
operation
|
||||
.track_disk_health_mutation(
|
||||
"recover_multipart_source",
|
||||
if mutation {
|
||||
DiskMetricMutation::Write
|
||||
} else {
|
||||
DiskMetricMutation::None
|
||||
},
|
||||
|| async { operation.disk.recover_multipart_source(request).await },
|
||||
if mutation { Duration::ZERO } else { wait_timeout },
|
||||
)
|
||||
.await
|
||||
});
|
||||
if wait_timeout.is_zero() {
|
||||
return owned.await;
|
||||
}
|
||||
// Dropping this waiter detaches the owned JoinHandle. The task keeps
|
||||
// its namespace owner and resource unit until the native tail drains.
|
||||
time::timeout(wait_timeout, owned).await.map_err(|_| DiskError::Timeout)?
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_reconnect_state(
|
||||
disk: Arc<LocalDisk>,
|
||||
health_check: bool,
|
||||
@@ -1775,7 +1648,6 @@ impl LocalDiskWrapper {
|
||||
cancellation: CancellationToken::new(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: current_resource_unit(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2161,11 +2033,6 @@ impl LocalDiskWrapper {
|
||||
self.metrics.metrics_snapshot()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn observe_multipart_cleanup_for_test(&self, path: &str) -> Arc<ScopedMultipartCleanupCompletions> {
|
||||
self.metrics.observe_multipart_cleanup(path)
|
||||
}
|
||||
|
||||
fn record_result_error_metrics<T>(&self, result: &Result<T>) {
|
||||
match result {
|
||||
Err(DiskError::Timeout) => self.metrics.record_timeout_error(),
|
||||
@@ -2670,11 +2537,6 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
// A matching format does not make a handle usable while its I/O gate is faulty.
|
||||
// Reconnect must renew it even if its old recovery monitor has stopped.
|
||||
if self.health.is_faulty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(Some(disk_id)) = self.disk.get_disk_id().await else {
|
||||
return false;
|
||||
};
|
||||
@@ -2875,23 +2737,13 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
let observed = self
|
||||
.metrics
|
||||
.matching_multipart_cleanup("delete_paths", volume, paths.iter().map(String::as_str));
|
||||
let result = self
|
||||
.track_disk_health_mutation(
|
||||
"delete_paths",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.disk.delete_paths(volume, paths).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
if let Some(scope) = observed {
|
||||
scope.delete_paths_returns.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
result
|
||||
self.track_disk_health_mutation(
|
||||
"delete_paths",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.disk.delete_paths(volume, paths).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
|
||||
@@ -3065,70 +2917,6 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
if dst_volume != super::RUSTFS_META_BUCKET
|
||||
|| dst_path != "pool.bin"
|
||||
|| !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, super::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn undo_pool_meta_write(&self, volume: &str, path: &str, fi: FileInfo, opts: DeleteOptions) -> Result<()> {
|
||||
if volume != super::RUSTFS_META_BUCKET
|
||||
|| path != "pool.bin"
|
||||
|| !opts.undo_write
|
||||
|| !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, super::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.undo_write_with_namespace_owner(volume, path, fi, opts, None).await
|
||||
}
|
||||
|
||||
async fn pool_meta_native_recovery(
|
||||
&self,
|
||||
request: super::PoolMetaNativeRecoveryRequest,
|
||||
) -> Result<super::PoolMetaNativeRecoveryResponse> {
|
||||
let mutation = matches!(&request, super::PoolMetaNativeRecoveryRequest::Apply { .. });
|
||||
let operation = self.clone();
|
||||
let owner = mutation.then(|| Arc::new(()) as Arc<dyn Send + Sync>);
|
||||
run_owned_mutation(owner, move || async move {
|
||||
operation
|
||||
.track_disk_health_mutation(
|
||||
"pool_meta_native_recovery",
|
||||
if mutation {
|
||||
DiskMetricMutation::Write
|
||||
} else {
|
||||
DiskMetricMutation::None
|
||||
},
|
||||
|| async { operation.disk.pool_meta_native_recovery(request).await },
|
||||
if mutation {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
get_max_timeout_duration()
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(
|
||||
&self,
|
||||
request: super::multipart_source::MultipartSourceRequest,
|
||||
) -> Result<super::multipart_source::MultipartSourceResponse> {
|
||||
self.recover_multipart_source_with_namespace_owner(request, None).await
|
||||
}
|
||||
|
||||
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
|
||||
self.track_disk_health_with_op_and_timeout_action(
|
||||
"list_dir",
|
||||
@@ -3298,23 +3086,13 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
let observed = self
|
||||
.metrics
|
||||
.matching_multipart_cleanup("delete", volume, std::iter::once(path));
|
||||
let result = self
|
||||
.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.cleanup_delete(volume, path, opt, None).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
if let Some(scope) = observed {
|
||||
scope.delete_returns.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
result
|
||||
self.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.cleanup_delete(volume, path, opt, None).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn verify_file(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp> {
|
||||
@@ -3440,100 +3218,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_cleanup_completion_receipts_are_path_volume_operation_and_return_scoped() {
|
||||
let dir = tempfile::tempdir().expect("cleanup receipt disk root");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 disk root")).expect("local endpoint");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("actual local disk"));
|
||||
let wrapper = LocalDiskWrapper::new(disk.clone(), false);
|
||||
let path = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
|
||||
let scope = wrapper.observe_multipart_cleanup_for_test(&path);
|
||||
let source = disk
|
||||
.get_object_path_for_io(super::super::RUSTFS_META_MULTIPART_BUCKET, &path)
|
||||
.expect("exact source root");
|
||||
std::fs::create_dir_all(&source).expect("actual orphan upload root");
|
||||
std::fs::write(source.join("part.1.meta"), b"selected part record").expect("actual selected cleanup child");
|
||||
std::fs::write(source.join("sentinel"), b"retained root child").expect("actual root child");
|
||||
let recursive = DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tmp = disk
|
||||
.get_object_path_for_io(super::super::RUSTFS_META_TMP_BUCKET, &path)
|
||||
.expect("same spelling in TMP");
|
||||
std::fs::create_dir_all(&tmp).expect("actual old TMP cleanup");
|
||||
wrapper
|
||||
.delete(super::super::RUSTFS_META_TMP_BUCKET, &path, recursive.clone())
|
||||
.await
|
||||
.expect("actual unrelated TMP deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 0, "old TMP completion cannot credit the MPI phase");
|
||||
let sibling = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
|
||||
let sibling_root = disk
|
||||
.get_object_path_for_io(super::super::RUSTFS_META_MULTIPART_BUCKET, &sibling)
|
||||
.expect("different upload");
|
||||
std::fs::create_dir_all(&sibling_root).expect("actual different upload root");
|
||||
wrapper
|
||||
.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &sibling, recursive.clone())
|
||||
.await
|
||||
.expect("actual unrelated upload deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 0, "another upload cannot credit the observed phase");
|
||||
assert_eq!(
|
||||
wrapper
|
||||
.read_all(super::super::RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/part.1.meta"))
|
||||
.await
|
||||
.expect("actual same-scope read"),
|
||||
Bytes::from_static(b"selected part record")
|
||||
);
|
||||
assert_eq!(scope.completed("read_all"), 0);
|
||||
assert_eq!(scope.completed("delete_paths"), 0, "other operations do not credit cleanup");
|
||||
wrapper
|
||||
.delete_paths(super::super::RUSTFS_META_MULTIPART_BUCKET, &[])
|
||||
.await
|
||||
.expect("empty actual cleanup returns");
|
||||
assert_eq!(scope.completed("delete_paths"), 0, "empty cleanup has no scoped part observation");
|
||||
std::fs::write(source.join("obsolete"), b"old child").expect("actual obsolete source child");
|
||||
wrapper
|
||||
.delete(
|
||||
super::super::RUSTFS_META_MULTIPART_BUCKET,
|
||||
&format!("{path}/obsolete"),
|
||||
DeleteOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual same-upload child deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 0, "a descendant delete cannot credit upload-root cleanup");
|
||||
wrapper
|
||||
.delete_paths(super::super::RUSTFS_META_MULTIPART_BUCKET, &[format!("{path}/part.1.meta")])
|
||||
.await
|
||||
.expect("actual selected source child deletion returns");
|
||||
assert_eq!(scope.completed("delete_paths"), 1);
|
||||
assert_eq!(scope.completed("delete"), 0, "part cleanup cannot credit root cleanup");
|
||||
let admission = wrapper.disk_id.write().await;
|
||||
{
|
||||
let cancelled = wrapper.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &path, recursive.clone());
|
||||
tokio::pin!(cancelled);
|
||||
assert!(futures::poll!(&mut cancelled).is_pending());
|
||||
assert_eq!(
|
||||
wrapper.metrics_snapshot().total_waiting,
|
||||
0,
|
||||
"the real wrapper is still in stale-check admission"
|
||||
);
|
||||
assert_eq!(scope.completed("delete"), 0);
|
||||
}
|
||||
drop(admission);
|
||||
assert_eq!(scope.completed("delete"), 0, "cancelled wrappers cannot fabricate a return receipt");
|
||||
assert!(source.exists());
|
||||
let admission = wrapper.disk_id.write().await;
|
||||
let deletion = wrapper.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &path, recursive);
|
||||
tokio::pin!(deletion);
|
||||
assert!(futures::poll!(&mut deletion).is_pending());
|
||||
assert_eq!(scope.completed("delete"), 0, "a pending real delete has not returned");
|
||||
drop(admission);
|
||||
deletion.await.expect("actual exact-source root deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 1);
|
||||
assert_eq!(scope.completed("delete_paths"), 1);
|
||||
assert!(!source.exists(), "the receipt follows the real destructive operation");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn slow_disk_operation_warns_once_while_pending_and_preserves_result() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
@@ -4101,209 +3785,32 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_mutation_keeps_publication_guard_after_waiter_cancellation() {
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one resource unit"), 1);
|
||||
let resource_unit = resources.acquire().await;
|
||||
let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let guard: Arc<dyn Send + Sync> = Arc::new(DropProbe(Arc::clone(&drops)));
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
let waiter = tokio::spawn(crate::disk::with_background_resource_unit(
|
||||
resource_unit,
|
||||
run_owned_mutation(Some(guard), move || async move {
|
||||
let inherited = current_resource_unit().is_some();
|
||||
assert!(current_disk_io_context().is_none(), "preserve the owned task's original disk policy");
|
||||
started_tx.send(inherited).expect("mutation should signal start");
|
||||
release_rx.await.expect("mutation should be released");
|
||||
finished_tx.send(()).expect("mutation should signal completion");
|
||||
Ok::<_, Error>(())
|
||||
}),
|
||||
));
|
||||
let waiter = tokio::spawn(run_owned_mutation(Some(guard), move || async move {
|
||||
started_tx.send(()).expect("mutation should signal start");
|
||||
release_rx.await.expect("mutation should be released");
|
||||
finished_tx.send(()).expect("mutation should signal completion");
|
||||
Ok::<_, Error>(())
|
||||
}));
|
||||
|
||||
let inherited = started_rx.await.expect("mutation owner should start");
|
||||
started_rx.await.expect("mutation owner should start");
|
||||
waiter.abort();
|
||||
assert!(waiter.await.expect_err("caller was cancelled").is_cancelled());
|
||||
let held_after_cancel = resources.snapshot().active;
|
||||
assert_eq!(drops.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
|
||||
release_tx.send(()).expect("mutation owner should still be alive");
|
||||
finished_rx.await.expect("mutation owner should finish");
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while drops.load(std::sync::atomic::Ordering::SeqCst) == 0 || resources.snapshot().active != 0 {
|
||||
while drops.load(std::sync::atomic::Ordering::SeqCst) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("publication guard should be released after mutation completion");
|
||||
assert!(inherited, "owned mutation must retain its resource unit");
|
||||
assert_eq!(
|
||||
held_after_cancel, 1,
|
||||
"cancelling the waiter must not release a live mutation's resource unit"
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_multipart_recovery_waiter_preserves_physical_owner(wait_timeout: Duration) {
|
||||
use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartRecoveryRound, MultipartSourceOperation, MultipartSourcePhase,
|
||||
MultipartSourceRequest, MultipartSourceResponse, SourceGeneration,
|
||||
};
|
||||
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, os};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let root = tempfile::tempdir().expect("formatted MPU disk root");
|
||||
let meta_dir = root.path().join(RUSTFS_META_BUCKET);
|
||||
std::fs::create_dir_all(&meta_dir).expect("format directory");
|
||||
let mut format = FormatV3::new(1, 4);
|
||||
format.erasure.this = format.erasure.sets[0][0];
|
||||
std::fs::write(meta_dir.join(FORMAT_CONFIG_FILE), format.to_json().expect("native format encoding"))
|
||||
.expect("native format file");
|
||||
let mut endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk root")).expect("native endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
let path = format!("upload/{}", Uuid::new_v4());
|
||||
let directory = Uuid::new_v4();
|
||||
let upload = root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path);
|
||||
std::fs::create_dir_all(upload.join(directory.to_string())).expect("real original shard directory");
|
||||
std::fs::write(upload.join(directory.to_string()).join("part.1"), b"original shard").expect("real shard");
|
||||
let mut original = FileInfo::new("object", 2, 2);
|
||||
original.erasure.distribution = vec![1, 2, 3, 4];
|
||||
original.erasure.index = 1;
|
||||
original.data_dir = Some(directory);
|
||||
original.mod_time = Some(::time::OffsetDateTime::now_utc());
|
||||
rustfs_utils::http::metadata_compat::insert_str(&mut original.metadata, "multipart-bucket", "bucket".to_owned());
|
||||
rustfs_utils::http::metadata_compat::insert_str(&mut original.metadata, "multipart-object", "object".to_owned());
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(original).expect("original native FI");
|
||||
let head = upload.join("xl.meta");
|
||||
let before = meta.marshal_msg().expect("original head bytes");
|
||||
std::fs::write(&head, &before).expect("actual original native head");
|
||||
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("formatted native disk"));
|
||||
let wrapper = Arc::new(LocalDiskWrapper::new(Arc::clone(&disk), false));
|
||||
let inspect = || MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: path.clone(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
};
|
||||
let MultipartSourceResponse::Inspected(initial) =
|
||||
wrapper.recover_multipart_source(inspect()).await.expect("initial source")
|
||||
else {
|
||||
panic!("native inspection response");
|
||||
};
|
||||
let expected = initial.snapshot.expect("initial active head");
|
||||
assert_eq!(expected.phase, MultipartSourcePhase::Active);
|
||||
let generation = SourceGeneration {
|
||||
epoch: expected.generation.epoch + 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
};
|
||||
let request = MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: path.clone(),
|
||||
operation: MultipartSourceOperation::Claim {
|
||||
round: MultipartRecoveryRound {
|
||||
lineage: expected.lineage.clone(),
|
||||
from_generation: expected.generation,
|
||||
old_attempt: Uuid::new_v4(),
|
||||
generation,
|
||||
round: Uuid::new_v4(),
|
||||
},
|
||||
expected,
|
||||
},
|
||||
};
|
||||
let held_namespace = os::acquire_namespace_mutation_lease_with_owner(
|
||||
&disk
|
||||
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &path)
|
||||
.expect("canonical native lease path"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let owner: Arc<dyn Send + Sync> = ctx.begin_namespace_commit();
|
||||
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one resource unit"), 1);
|
||||
let unit = resources.acquire().await;
|
||||
let operation = wrapper.clone();
|
||||
time::pause();
|
||||
let mut waiter = tokio::spawn(crate::disk::with_background_resource_unit(unit, async move {
|
||||
operation
|
||||
.recover_multipart_source_owned(request, Some(owner), wait_timeout)
|
||||
.await
|
||||
}));
|
||||
while wrapper.health.waiting_count() == 0 {
|
||||
assert!(!waiter.is_finished(), "recovery must enter the real physical operation");
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(futures::poll!(&mut waiter).is_pending());
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert_eq!(resources.snapshot().active, 1);
|
||||
assert_eq!(std::fs::read(&head).expect("head before timeout"), before);
|
||||
assert!(!upload.join(".mpu-source-state").exists());
|
||||
time::advance(if wait_timeout.is_zero() {
|
||||
Duration::from_secs(31)
|
||||
} else {
|
||||
wait_timeout
|
||||
})
|
||||
.await;
|
||||
if wait_timeout.is_zero() {
|
||||
assert!(futures::poll!(&mut waiter).is_pending(), "zero keeps the configured no-timeout policy");
|
||||
} else {
|
||||
assert!(matches!((&mut waiter).await.expect("logical waiter"), Err(DiskError::Timeout)));
|
||||
}
|
||||
assert!(ctx.namespace_commits_pending(), "a logical timeout cannot drain the instance owner");
|
||||
assert_eq!(resources.snapshot().active, 1, "the physical task must retain its resource unit");
|
||||
assert_eq!(wrapper.health.waiting_count(), 1, "the native operation must remain active");
|
||||
assert_eq!(std::fs::read(&head).expect("head at logical deadline"), before);
|
||||
assert!(!upload.join(".mpu-source-state").exists());
|
||||
|
||||
drop(held_namespace);
|
||||
time::resume();
|
||||
if wait_timeout.is_zero() {
|
||||
assert!(matches!(
|
||||
waiter.await.expect("unbounded configured waiter"),
|
||||
Ok(MultipartSourceResponse::Claimed(_))
|
||||
));
|
||||
}
|
||||
time::timeout(Duration::from_secs(10), async {
|
||||
while ctx.namespace_commits_pending() || resources.snapshot().active != 0 || wrapper.health.waiting_count() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("actual recovery tail should drain after the real namespace lease is released");
|
||||
let MultipartSourceResponse::Inspected(completed) = wrapper
|
||||
.recover_multipart_source(inspect())
|
||||
.await
|
||||
.expect("completed native source")
|
||||
else {
|
||||
panic!("native inspection response");
|
||||
};
|
||||
let completed = completed.snapshot.expect("durable recovered head");
|
||||
assert_eq!(completed.phase, MultipartSourcePhase::Recovering);
|
||||
assert_eq!(completed.generation, generation);
|
||||
assert_ne!(std::fs::read(&head).expect("completed source head"), before);
|
||||
assert!(upload.join(".mpu-source-state").is_file());
|
||||
assert_eq!(
|
||||
std::fs::read(upload.join(directory.to_string()).join("part.1")).expect("preserved shard"),
|
||||
b"original shard"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_recovery_timeout_keeps_native_claim_and_instance_owner_alive() {
|
||||
assert_multipart_recovery_waiter_preserves_physical_owner(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_recovery_zero_wait_budget_keeps_the_existing_no_timeout_policy() {
|
||||
assert_multipart_recovery_waiter_preserves_physical_owner(Duration::ZERO).await;
|
||||
}
|
||||
|
||||
struct PendingWriter;
|
||||
@@ -4706,38 +4213,6 @@ mod tests {
|
||||
assert_eq!(snapshot.total_errors_availability, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_online_status_matches_io_health_and_disk_identity() {
|
||||
let dir = tempfile::tempdir().expect("create a drive root");
|
||||
let mut endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 root")).expect("local endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
let meta_dir = dir.path().join(RUSTFS_META_BUCKET);
|
||||
tokio::fs::create_dir_all(&meta_dir).await.expect("create metadata directory");
|
||||
let mut format = FormatV3::new(1, 1);
|
||||
format.erasure.this = format.erasure.sets[0][0];
|
||||
tokio::fs::write(meta_dir.join(FORMAT_CONFIG_FILE), format.to_json().expect("serialize format"))
|
||||
.await
|
||||
.expect("persist original identity");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("open formatted drive"));
|
||||
let wrapper = LocalDiskWrapper::new(disk, false);
|
||||
assert!(wrapper.is_online().await, "healthy formatted drive is online");
|
||||
wrapper.health.force_runtime_state_for_test(RuntimeDriveHealthState::Suspect);
|
||||
assert!(wrapper.is_online().await, "suspect drives still admit foreground I/O");
|
||||
wrapper.health.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
|
||||
assert!(!wrapper.is_online().await, "a readable format cannot make a faulty handle usable");
|
||||
assert!(!wrapper.health.mark_recovery_success(&endpoint, "test_probe"));
|
||||
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Returning);
|
||||
assert!(
|
||||
!wrapper.is_online().await,
|
||||
"returning drives retain the faulty I/O gate until recovery completes"
|
||||
);
|
||||
wrapper.reset_health_for_store_init_retry();
|
||||
wrapper.set_disk_id_state(Some(Uuid::new_v4())).await;
|
||||
assert!(!wrapper.is_online().await, "healthy state must not bypass a mismatched disk identity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_capacity_keeps_disk_identity_precheck_without_requiring_id_in_snapshot() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
|
||||
@@ -159,18 +159,8 @@ pub fn lstat_std(path: impl AsRef<Path>) -> io::Result<Metadata> {
|
||||
std::fs::symlink_metadata(path)
|
||||
}
|
||||
|
||||
pub fn create_dir_all_std(path: &Path) -> io::Result<()> {
|
||||
match std::fs::create_dir(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => std::fs::create_dir_all(path),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || create_dir_all_std(&path)).await?
|
||||
fs::create_dir_all(path.as_ref()).await
|
||||
}
|
||||
|
||||
fn is_dir_error(e: &io::Error) -> bool {
|
||||
@@ -220,7 +210,6 @@ pub fn remove_all_std(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn mkdir(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
fs::create_dir(path.as_ref()).await
|
||||
}
|
||||
@@ -250,7 +239,7 @@ impl BucketExistenceCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_exists(&self, path: &Path) -> Option<bool> {
|
||||
fn check_exists(&self, path: &PathBuf) -> Option<bool> {
|
||||
let mut cache = self.cache.lock().ok()?;
|
||||
if let Some((timestamp, exists)) = cache.get(path) {
|
||||
if timestamp.elapsed() < self.ttl {
|
||||
@@ -267,7 +256,7 @@ impl BucketExistenceCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate(&self, path: &Path) {
|
||||
fn invalidate(&self, path: &PathBuf) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.remove(path);
|
||||
}
|
||||
@@ -279,16 +268,15 @@ static BUCKET_EXISTENCE_CACHE: std::sync::LazyLock<BucketExistenceCache> =
|
||||
|
||||
/// Cached access check - reduces statx syscalls
|
||||
pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
let path = path.as_ref();
|
||||
let path_buf = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(path) {
|
||||
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(&path_buf) {
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "bucket not found (cached)"));
|
||||
}
|
||||
|
||||
let path_buf = path.to_path_buf();
|
||||
let result = fs::metadata(&path_buf).await;
|
||||
|
||||
match &result {
|
||||
@@ -304,7 +292,7 @@ pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
}
|
||||
|
||||
pub fn invalidate_bucket_cache(path: impl AsRef<Path>) {
|
||||
BUCKET_EXISTENCE_CACHE.invalidate(path.as_ref());
|
||||
BUCKET_EXISTENCE_CACHE.invalidate(&path.as_ref().to_path_buf());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -24,7 +24,6 @@ use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use rustfs_concurrency::adaptive::AdaptivePermit;
|
||||
use rustfs_rio::ChunkReader;
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
use tokio::sync::Notify;
|
||||
@@ -36,7 +35,6 @@ const BACKGROUND_MAX_WAIT: Duration = Duration::from_millis(100);
|
||||
|
||||
tokio::task_local! {
|
||||
static DISK_IO_CONTEXT: DiskIoContext;
|
||||
static RESOURCE_IO_UNIT: AdaptivePermit;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -51,7 +49,6 @@ pub(crate) struct DiskIoContext {
|
||||
pub(crate) cancellation: CancellationToken,
|
||||
pub(crate) admission: Option<Arc<DiskIoAdmission>>,
|
||||
pub(crate) permit: Option<DiskIoPermit>,
|
||||
pub(crate) resource_unit: Option<AdaptivePermit>,
|
||||
}
|
||||
|
||||
/// Mark disk I/O issued by `future` as background work for local-disk admission.
|
||||
@@ -63,43 +60,12 @@ pub async fn with_background_disk_io<F: Future>(cancellation: CancellationToken,
|
||||
cancellation,
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: current_resource_unit(),
|
||||
},
|
||||
future,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Retain an already admitted resource unit without changing disk classification,
|
||||
/// cancellation or admission. Acquire it before namespace, cache or commit locks.
|
||||
/// Existing background scopes and disk leases propagate it into native I/O.
|
||||
/// Use this for physical I/O ownership, not node-wide scheduling capacity that
|
||||
/// must return with a cancelled waiter so healthy disks can progress independently
|
||||
/// of a stalled native operation. Escaping buffers need their own memory leases.
|
||||
#[doc(hidden)]
|
||||
pub async fn with_background_resource_unit<F: Future>(unit: AdaptivePermit, future: F) -> F::Output {
|
||||
RESOURCE_IO_UNIT.scope(unit, future).await
|
||||
}
|
||||
|
||||
pub(crate) fn current_resource_unit() -> Option<AdaptivePermit> {
|
||||
RESOURCE_IO_UNIT.try_with(Clone::clone).ok().or_else(|| {
|
||||
DISK_IO_CONTEXT
|
||||
.try_with(|context| context.resource_unit.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Transfer only resource ownership into an existing spawned task. Disk
|
||||
/// classification/permits are not inherited: changing those can introduce a
|
||||
/// wait behind a disk permit still held by the parent.
|
||||
pub(crate) async fn with_optional_resource_unit<F: Future>(unit: Option<AdaptivePermit>, future: F) -> F::Output {
|
||||
match unit {
|
||||
Some(unit) => with_background_resource_unit(unit, future).await,
|
||||
None => future.await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn with_disk_io_context<F: Future>(context: DiskIoContext, future: F) -> F::Output {
|
||||
DISK_IO_CONTEXT.scope(context, future).await
|
||||
}
|
||||
@@ -113,11 +79,7 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
|
||||
cancellation: CancellationToken::new(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: None,
|
||||
});
|
||||
if context.resource_unit.is_none() {
|
||||
context.resource_unit = permit.lease.resource_unit.clone();
|
||||
}
|
||||
context.admission = Some(permit.admission());
|
||||
with_disk_io_context(
|
||||
DiskIoContext {
|
||||
@@ -129,32 +91,15 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn with_optional_disk_io_context<F: Future>(
|
||||
context: Option<DiskIoContext>,
|
||||
future: F,
|
||||
) -> impl Future<Output = F::Output> {
|
||||
// Capture at future construction, before JoinSet/spawn moves polling to a
|
||||
// different task. A resource-only scope has no DiskIoContext to carry it.
|
||||
let unit = current_resource_unit();
|
||||
async move {
|
||||
let future = with_optional_resource_unit(unit, future);
|
||||
match context {
|
||||
Some(context) => with_disk_io_context(context, future).await,
|
||||
None => future.await,
|
||||
}
|
||||
pub(crate) async fn with_optional_disk_io_context<F: Future>(context: Option<DiskIoContext>, future: F) -> F::Output {
|
||||
match context {
|
||||
Some(context) => with_disk_io_context(context, future).await,
|
||||
None => future.await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_disk_io_context() -> Option<DiskIoContext> {
|
||||
DISK_IO_CONTEXT
|
||||
.try_with(|context| {
|
||||
let mut context = context.clone();
|
||||
if let Ok(unit) = RESOURCE_IO_UNIT.try_with(Clone::clone) {
|
||||
context.resource_unit = Some(unit);
|
||||
}
|
||||
context
|
||||
})
|
||||
.ok()
|
||||
DISK_IO_CONTEXT.try_with(Clone::clone).ok()
|
||||
}
|
||||
|
||||
/// Acquire one low-level permit only when the current walker explicitly owns a gate.
|
||||
@@ -230,10 +175,6 @@ impl DiskIoAdmission {
|
||||
}
|
||||
|
||||
pub(crate) fn foreground(self: &Arc<Self>) -> DiskIoPermit {
|
||||
self.foreground_with_resource(current_resource_unit())
|
||||
}
|
||||
|
||||
fn foreground_with_resource(self: &Arc<Self>, resource_unit: Option<AdaptivePermit>) -> DiskIoPermit {
|
||||
self.foreground_active.fetch_add(1, Ordering::AcqRel);
|
||||
if self.background_waiters.load(Ordering::Acquire) > 0 {
|
||||
self.record_state();
|
||||
@@ -242,20 +183,11 @@ impl DiskIoAdmission {
|
||||
lease: Arc::new(DiskIoPermitLease {
|
||||
admission: self.clone(),
|
||||
class: DiskIoClass::Foreground,
|
||||
resource_unit,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn background(self: &Arc<Self>, cancellation: &CancellationToken) -> std::io::Result<DiskIoPermit> {
|
||||
self.background_with_resource(cancellation, current_resource_unit()).await
|
||||
}
|
||||
|
||||
async fn background_with_resource(
|
||||
self: &Arc<Self>,
|
||||
cancellation: &CancellationToken,
|
||||
resource_unit: Option<AdaptivePermit>,
|
||||
) -> std::io::Result<DiskIoPermit> {
|
||||
let started_at = Instant::now();
|
||||
let ticket = {
|
||||
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
|
||||
@@ -316,7 +248,6 @@ impl DiskIoAdmission {
|
||||
lease: Arc::new(DiskIoPermitLease {
|
||||
admission: self.clone(),
|
||||
class: DiskIoClass::Background,
|
||||
resource_unit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -429,7 +360,6 @@ impl DiskIoPermit {
|
||||
struct DiskIoPermitLease {
|
||||
admission: Arc<DiskIoAdmission>,
|
||||
class: DiskIoClass,
|
||||
resource_unit: Option<AdaptivePermit>,
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_blocking_with_disk_io_permit<T, F>(
|
||||
@@ -440,12 +370,8 @@ where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
{
|
||||
// A supplied disk permit can predate this resource scope or carry another
|
||||
// owner's unit. Preserve the current unit independently until native I/O ends.
|
||||
let resource_unit = current_resource_unit();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _io_permit = io_permit;
|
||||
let _resource_unit = resource_unit;
|
||||
operation()
|
||||
})
|
||||
}
|
||||
@@ -510,7 +436,7 @@ impl<R> AdmissionReader<R> {
|
||||
}
|
||||
|
||||
if self.context.class == DiskIoClass::Foreground {
|
||||
self.permit = Some(self.admission.foreground_with_resource(self.context.resource_unit.clone()));
|
||||
self.permit = Some(self.admission.foreground());
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
@@ -518,10 +444,7 @@ impl<R> AdmissionReader<R> {
|
||||
if wait.is_none() {
|
||||
let admission = self.admission.clone();
|
||||
let cancellation = self.context.cancellation.clone();
|
||||
let resource_unit = self.context.resource_unit.clone();
|
||||
*wait = Some(Box::pin(
|
||||
async move { admission.background_with_resource(&cancellation, resource_unit).await },
|
||||
));
|
||||
*wait = Some(Box::pin(async move { admission.background(&cancellation).await }));
|
||||
}
|
||||
let Some(wait_future) = wait.as_mut() else {
|
||||
return Poll::Ready(Err(io::Error::other("background disk I/O admission waiter is missing")));
|
||||
@@ -591,166 +514,8 @@ impl ChunkReader for BoxedChunkReader {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use std::num::NonZeroU16;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_unit_outlives_cancelled_scope_until_native_io_finishes() {
|
||||
for permit_mode in 0..4 {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let previous_gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("previous owner"), 1);
|
||||
let unit = gate.acquire().await;
|
||||
let disk = DiskIoAdmission::new("resource-unit-native");
|
||||
let existing_permit = match permit_mode {
|
||||
2 => Some(disk.foreground()),
|
||||
3 => Some(with_background_resource_unit(previous_gate.acquire().await, async { disk.foreground() }).await),
|
||||
_ => None,
|
||||
};
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(with_background_resource_unit(unit, async move {
|
||||
with_background_disk_io(CancellationToken::new(), async {
|
||||
let permit = if existing_permit.is_some() {
|
||||
existing_permit
|
||||
} else if permit_mode == 1 {
|
||||
Some(disk.background(&CancellationToken::new()).await.expect("disk permit"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
spawn_blocking_with_disk_io_permit(permit, move || {
|
||||
started_tx.send(()).expect("native I/O started");
|
||||
release_rx.blocking_recv().expect("native I/O release");
|
||||
})
|
||||
.await
|
||||
.expect("native I/O completed");
|
||||
})
|
||||
.await;
|
||||
}));
|
||||
started_rx.await.expect("native worker owns unit");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("scope was cancelled").is_cancelled());
|
||||
let held = gate.snapshot().active;
|
||||
let previous_held = previous_gate.snapshot().active;
|
||||
let rejected = gate.try_acquire().is_none();
|
||||
gate.set_limit(0);
|
||||
release_tx.send(()).expect("release native I/O");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while gate.snapshot().active != 0 || previous_gate.snapshot().active != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("native completion releases the unit");
|
||||
gate.set_limit(1);
|
||||
assert!(gate.try_acquire().is_some());
|
||||
assert_eq!(held, 1, "mode {permit_mode}: native I/O still owns the current unit");
|
||||
assert!(rejected, "mode {permit_mode}: a live native worker must retain admission");
|
||||
assert_eq!(previous_held, u32::from(permit_mode == 3), "preserve any older disk lease's unit");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn escaped_disk_permit_restores_resource_context_without_a_new_unit() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let disk = DiskIoAdmission::new("resource-unit-escape");
|
||||
let permit = with_background_resource_unit(gate.acquire().await, async {
|
||||
disk.background(&CancellationToken::new()).await.expect("disk permit")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
with_background_disk_io(CancellationToken::new(), async {
|
||||
with_disk_io_permit(Some(permit), async {
|
||||
let unit = current_resource_unit().expect("escaped disk lease restores unit into an existing context");
|
||||
assert_eq!(gate.snapshot().active, 1, "cloning retains one unit rather than charging twice");
|
||||
drop(unit);
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn escaped_stream_retains_resource_unit_outside_task_local_scope() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let disk = DiskIoAdmission::new("resource-unit-stream");
|
||||
let (mut writer, inner) = tokio::io::duplex(8);
|
||||
let mut reader = with_background_resource_unit(gate.acquire().await, async {
|
||||
with_background_disk_io(CancellationToken::new(), async {
|
||||
AdmissionReader::new(inner, disk, current_disk_io_context().expect("resource context"))
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
writer.write_all(b"rust").await.expect("write complete stream");
|
||||
drop(writer);
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).await.expect("read outside original scope");
|
||||
assert_eq!(bytes, b"rust");
|
||||
assert_eq!(gate.snapshot().active, 1, "reader still owns its context");
|
||||
drop(reader);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_scope_preserves_existing_disk_policy_and_cancellation() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
with_background_resource_unit(gate.acquire().await, async {
|
||||
assert!(current_disk_io_context().is_none(), "resource ownership does not classify disk I/O");
|
||||
assert!(current_resource_unit().is_some());
|
||||
})
|
||||
.await;
|
||||
let cancellation = CancellationToken::new();
|
||||
let existing = DiskIoContext {
|
||||
class: DiskIoClass::Foreground,
|
||||
cancellation: cancellation.clone(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: None,
|
||||
};
|
||||
with_disk_io_context(existing, async {
|
||||
with_background_resource_unit(gate.acquire().await, async {
|
||||
cancellation.cancel();
|
||||
let context = current_disk_io_context().expect("existing disk context");
|
||||
assert_eq!(context.class, DiskIoClass::Foreground);
|
||||
assert!(
|
||||
context.resource_unit.is_some(),
|
||||
"captured disk context must retain the current resource unit"
|
||||
);
|
||||
assert!(context.cancellation.is_cancelled(), "retain the original cancellation token");
|
||||
assert!(current_resource_unit().is_some());
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_only_scope_survives_existing_optional_context_spawn_boundary() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let mut worker = None;
|
||||
with_background_resource_unit(gate.acquire().await, async {
|
||||
worker = Some(tokio::spawn(with_optional_disk_io_context(current_disk_io_context(), async {
|
||||
release_rx.await.expect("release child after caller scope ends");
|
||||
assert!(current_disk_io_context().is_none());
|
||||
assert!(current_resource_unit().is_some());
|
||||
})));
|
||||
})
|
||||
.await;
|
||||
let held = gate.snapshot().active;
|
||||
release_tx.send(()).expect("child remains alive");
|
||||
worker
|
||||
.expect("scoped child was spawned")
|
||||
.await
|
||||
.expect("child preserves resource-only scope");
|
||||
assert_eq!(held, 1);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
struct BlockingWorker(tokio::task::JoinHandle<()>);
|
||||
|
||||
async fn wait_for_background_waiters(admission: &DiskIoAdmission, expected: usize) {
|
||||
@@ -871,7 +636,6 @@ mod tests {
|
||||
cancellation: CancellationToken::new(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: None,
|
||||
},
|
||||
);
|
||||
let read = tokio::spawn(async move {
|
||||
|
||||
+585
-1351
File diff suppressed because it is too large
Load Diff
@@ -281,12 +281,10 @@ type PreparedInlineMetadata = (
|
||||
Option<StagedRollbackBackup>,
|
||||
bool,
|
||||
os::PreparedRenameSource,
|
||||
Option<Arc<os::PreparedPoolMetaBackup>>,
|
||||
Option<super::pool_meta_native::DurableHeadReceipt>,
|
||||
);
|
||||
|
||||
enum InlinePreparationOutcome {
|
||||
Committed(RenameDataResp, Option<super::pool_meta_native::DurableHeadReceipt>),
|
||||
Committed(RenameDataResp),
|
||||
CommitErr(DiskError),
|
||||
Prepared(PreparedInlineMetadata),
|
||||
}
|
||||
@@ -402,14 +400,6 @@ pub(super) struct RenameDataState {
|
||||
preflight_rejection: Option<LocalRenamePreflightRejection>,
|
||||
}
|
||||
|
||||
// Keep optional native MPU state machines out of ordinary rename frames.
|
||||
// Construct the future here as well as pinning it, so its temporary stays off
|
||||
// the caller's poll stack. Call only from the existing native MPU branches.
|
||||
#[inline(never)]
|
||||
fn pin_multipart_future<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
|
||||
pub(super) async fn rename_data_inner(
|
||||
@@ -421,54 +411,12 @@ impl LocalDisk {
|
||||
dst_path: &str,
|
||||
state: &mut RenameDataState,
|
||||
) -> Result<RenameDataResp> {
|
||||
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), src_volume, src_path).inspect_err(
|
||||
|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
},
|
||||
)?;
|
||||
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), dst_volume, dst_path).inspect_err(
|
||||
|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
},
|
||||
)?;
|
||||
super::pool_meta_native::reject_raw_path(&self.root, src_volume, src_path).inspect_err(|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?;
|
||||
super::pool_meta_native::reject_noncanonical_object(&self.root, dst_volume, dst_path).inspect_err(|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?;
|
||||
crate::hp_guard!("LocalDisk::rename_data");
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if fi.is_healing() {
|
||||
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
|
||||
}
|
||||
let mut fi = fi;
|
||||
let pool_meta_condition = if super::pool_meta_native::is_object(&self.root, dst_volume, dst_path) {
|
||||
Some(super::pool_meta_native::PoolMetaNativeCasCondition::take(&mut fi).inspect_err(|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let native_timing = pool_meta_condition.is_some();
|
||||
if pool_meta_condition
|
||||
.as_ref()
|
||||
.is_some_and(|condition| fi.data_dir == Some(condition.attempt_id))
|
||||
{
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if let Some(condition) = &pool_meta_condition {
|
||||
let new_identity = super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?;
|
||||
if condition
|
||||
.parent
|
||||
.as_ref()
|
||||
.is_some_and(|parent| new_identity.mod_time_unix_nanos <= parent.mod_time_unix_nanos)
|
||||
{
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
}
|
||||
// A non-force DeleteBucket must not remove a directory while a local
|
||||
// object commit is publishing into it. The peer's empty scan remains
|
||||
// optimistic; this lease establishes the local commit/delete order and
|
||||
@@ -494,32 +442,8 @@ impl LocalDisk {
|
||||
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
|
||||
None => None,
|
||||
};
|
||||
// A failed fanout can undo the destination after this disk succeeded.
|
||||
// Keep the original upload authoritative until coordinator quorum: only
|
||||
// this private attempt's links and generated metadata may be consumed.
|
||||
let multipart_staging = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
Some(
|
||||
pin_multipart_future(|| {
|
||||
self.prepare_multipart_commit_staging(src_path, &fi, dst_volume, state.namespace_owner.clone())
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let multipart_publication = if let Some(staging) = multipart_staging.as_ref() {
|
||||
Some(pin_multipart_future(|| self.prepare_native_multipart_publication(src_path, &mut fi, staging)).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (src_volume, src_path) = match multipart_staging.as_ref() {
|
||||
Some(staging) => (super::super::RUSTFS_META_TMP_BUCKET, staging.path.as_str()),
|
||||
None => (src_volume, src_path),
|
||||
};
|
||||
// Quota admission -> metadata RMW -> namespace/volume publication.
|
||||
// MPU completion remains parts-backed even for a zero-byte part; its
|
||||
// version-owned retirement anchor is published with that same DD.
|
||||
let no_inline = fi.data.is_none() && (fi.size > 0 || multipart_publication.is_some());
|
||||
let no_inline = fi.data.is_none() && fi.size > 0;
|
||||
let fi_healing = fi.is_healing();
|
||||
let staging_prefix = if fi_healing && !no_inline && src_volume == super::super::RUSTFS_META_TMP_BUCKET {
|
||||
Some(tmp_staging_mutation_path(&self.root, src_path)?)
|
||||
@@ -531,46 +455,19 @@ impl LocalDisk {
|
||||
.strip_prefix(self.io_root())
|
||||
.map_err(|_| DiskError::InvalidPath)?,
|
||||
);
|
||||
let metadata_wait = os::StageTimer::new(native_timing, "pool_meta_native_metadata_lease_wait");
|
||||
let metadata_owner: Option<Arc<dyn Send + Sync>> = match multipart_staging.as_ref() {
|
||||
Some(staging) => Some(Arc::new((state.namespace_owner.take(), staging.lease.clone()))),
|
||||
None => state.namespace_owner.take(),
|
||||
};
|
||||
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, metadata_owner).await;
|
||||
drop(metadata_wait);
|
||||
#[cfg(unix)]
|
||||
let parent_receipt = if native_timing {
|
||||
self.pool_meta_durable_head.lock().take().filter(|receipt| {
|
||||
pool_meta_condition.as_ref().is_some_and(|condition| {
|
||||
condition.parent_raw_sha256.is_none() && condition.parent.as_ref() == Some(&receipt.identity)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let mut receipt_mutation = native_timing
|
||||
.then(|| super::pool_meta_native::ReceiptMutationGuard::new(&self.pool_meta_durable_head, metadata_lease.clone()));
|
||||
let namespace_wait = os::StageTimer::new(native_timing, "pool_meta_native_namespace_lease_wait");
|
||||
let metadata_lease =
|
||||
os::acquire_metadata_mutation_lease(&root_destination_object_path, state.namespace_owner.take()).await;
|
||||
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
|
||||
&self.root,
|
||||
dst_volume,
|
||||
&root_destination_object_path,
|
||||
&destination_object_path,
|
||||
staging_prefix.as_deref(),
|
||||
Some(metadata_lease),
|
||||
)
|
||||
.await;
|
||||
drop(namespace_wait);
|
||||
if let Some(claim) = quota_fence_claim {
|
||||
mutation_lease.attach_external_guard(claim);
|
||||
}
|
||||
if let Some(condition) = &pool_meta_condition {
|
||||
let _parent_check = os::StageTimer::new(native_timing, "pool_meta_native_parent_check");
|
||||
if let Err(err) = self.check_pool_meta_parent(condition, &destination_object_path).await {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
if fi.is_legacy_indexed_delete_marker() {
|
||||
fi.erasure.index = 0;
|
||||
}
|
||||
@@ -627,19 +524,8 @@ impl LocalDisk {
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
|
||||
// Native publication consumes its canonical head. The legacy path
|
||||
// keeps main's independent recovery authority and staging filename.
|
||||
let preserve_upload_metadata =
|
||||
multipart_publication.is_none() && src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
let source_metadata_name = if preserve_upload_metadata {
|
||||
super::super::MULTIPART_COMMIT_METADATA_FILE
|
||||
} else {
|
||||
STORAGE_FORMAT_FILE
|
||||
};
|
||||
let src_file_path = match multipart_publication.as_ref() {
|
||||
Some(publication) => publication.canonical_head(),
|
||||
None => self.io_get_object_path(src_volume, format!("{src_path}/{source_metadata_name}").as_str())?,
|
||||
};
|
||||
// xl.meta path
|
||||
let src_file_path = self.io_get_object_path(src_volume, format!("{}/{}", src_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
let dst_file_path = self.io_get_object_open_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
|
||||
// data_dir path (only used by the non-inline commit branch)
|
||||
@@ -665,22 +551,6 @@ impl LocalDisk {
|
||||
None
|
||||
};
|
||||
|
||||
if multipart_publication.is_none()
|
||||
&& let Some((source_directory, target_directory)) = has_data_dir_path.as_ref()
|
||||
{
|
||||
// Reject imported provenance before any DD purge or publication.
|
||||
// Same-disk heal copies only an already verified local anchor.
|
||||
self.retain_owned_multipart_anchor(
|
||||
&fi,
|
||||
dst_volume,
|
||||
dst_path,
|
||||
source_directory,
|
||||
target_directory,
|
||||
mutation_lease.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
check_path_length(src_file_path.to_string_lossy().to_string().as_str())?;
|
||||
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
|
||||
|
||||
@@ -700,10 +570,8 @@ impl LocalDisk {
|
||||
.ok_or_else(|| DiskError::other("missing object metadata parent"))?;
|
||||
// Acquire the common trees before reading destination metadata. On
|
||||
// Windows this pins the object directory identity across publication.
|
||||
// Multipart authority already created its source tree. Recreating it
|
||||
// here would resurrect staging retired before a delayed request arrived.
|
||||
let (rename_commit_guard, has_dst_buf) = prepare_rename_commit_and_destination_metadata(
|
||||
!no_inline && !preserve_upload_metadata,
|
||||
!no_inline,
|
||||
src_file_parent,
|
||||
dst_file_parent,
|
||||
&dst_file_path,
|
||||
@@ -722,39 +590,6 @@ impl LocalDisk {
|
||||
data_paths: has_data_dir_path.as_ref(),
|
||||
};
|
||||
|
||||
if preserve_upload_metadata {
|
||||
let source_object_path = self.get_object_path(src_volume, src_path)?;
|
||||
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == root_destination_object_path {
|
||||
commit.mutation_lease.clone()
|
||||
} else {
|
||||
os::acquire_metadata_mutation_lease(&source_object_path, Some(commit.mutation_lease.clone())).await
|
||||
};
|
||||
let authority = self.io_get_object_path(src_volume, &format!("{src_path}/{STORAGE_FORMAT_FILE}"))?;
|
||||
let recovery = self.io_get_object_path(
|
||||
src_volume,
|
||||
&format!("{src_path}/{}/{STORAGE_FORMAT_FILE}", super::super::MULTIPART_RECOVERY_DIR),
|
||||
)?;
|
||||
check_path_length(recovery.to_string_lossy().as_ref())?;
|
||||
os::rename_all_with_owner(
|
||||
&authority,
|
||||
&recovery,
|
||||
src_file_parent,
|
||||
commit.publication_root,
|
||||
Some(authority_owner.clone()),
|
||||
)
|
||||
.await?;
|
||||
// This is system metadata authority, so use its existing strict
|
||||
// durability policy even when the object payload is relaxed.
|
||||
if effective_durability(src_volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(recovery.parent().ok_or(DiskError::InvalidPath)?, Some(authority_owner.clone()))
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
os::fsync_dir_with_owner(src_file_parent, Some(authority_owner.clone()))
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
if no_inline {
|
||||
// Non-inline: read xl.meta, parse, write, rename data dir, rename xl.meta
|
||||
let mut xlmeta = FileMeta::new();
|
||||
@@ -784,17 +619,13 @@ impl LocalDisk {
|
||||
let (old_current_size, old_current_source_checked, old_current_source) =
|
||||
observe_old_current(has_dst_buf.is_some(), dst_meta_unparsable, &xlmeta, dst_volume, dst_path, version_id);
|
||||
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
|
||||
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
|
||||
condition.has_parent().then_some(condition.attempt_id)
|
||||
} else {
|
||||
has_old_data_dir.or_else(|| {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
let rollback_data_dir = has_old_data_dir.or_else(|| {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(old_data_dir) = has_old_data_dir.as_ref() {
|
||||
let _ = xlmeta.data.remove_two(version_id, *old_data_dir);
|
||||
}
|
||||
@@ -808,10 +639,7 @@ impl LocalDisk {
|
||||
// after the commit rename, and a crash before the rename means the
|
||||
// PUT was never acknowledged. A metadata commit: relaxed tiers
|
||||
// leave it to the page cache.
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.prepare_metadata(&new_dst_buf, has_dst_buf.as_deref())).await?;
|
||||
}
|
||||
let tmp_meta_sync = if durability.syncs_commit_metadata() || multipart_publication.is_some() {
|
||||
let tmp_meta_sync = if durability.syncs_commit_metadata() {
|
||||
SyncMode::FileOnly
|
||||
} else {
|
||||
SyncMode::None
|
||||
@@ -831,9 +659,7 @@ impl LocalDisk {
|
||||
// rename below to report through the existing rollback path. Payload
|
||||
// durability is kept by both strict and relaxed.
|
||||
let tmp_meta_write = {
|
||||
let src_file_path = multipart_publication
|
||||
.as_ref()
|
||||
.map_or_else(|| commit.source_metadata.to_path_buf(), |publication| publication.prepared_path.clone());
|
||||
let src_file_path = commit.source_metadata.to_path_buf();
|
||||
let dst_file_path = dst_file_path.clone();
|
||||
let owned_directory_guard = commit.directory_guard.clone();
|
||||
let owned_mutation_lease = commit.mutation_lease.clone();
|
||||
@@ -870,10 +696,6 @@ impl LocalDisk {
|
||||
// sequential version did.
|
||||
let prepared_metadata_source = tmp_meta_res?;
|
||||
shard_sync_res?;
|
||||
let prepared_metadata_source = match multipart_publication.as_ref() {
|
||||
Some(publication) => pin_multipart_future(|| publication.install_head(prepared_metadata_source)).await?,
|
||||
None => prepared_metadata_source,
|
||||
};
|
||||
commit.directory_guard = remove_dst_base_before_commit(
|
||||
dst_path,
|
||||
commit.directory_guard,
|
||||
@@ -912,49 +734,30 @@ impl LocalDisk {
|
||||
"Healing commit could not purge the stale destination data dir"
|
||||
);
|
||||
}
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
|
||||
#[cfg(target_os = "linux")]
|
||||
let payload_guard = if multipart_publication.is_some() {
|
||||
Some(
|
||||
os::multipart_payload_commit_guard(
|
||||
&commit.directory_guard,
|
||||
src_data_path.parent().ok_or(DiskError::InvalidPath)?,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(to_file_error)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let directory_guard = &commit.directory_guard;
|
||||
if let Err(err) = os::rename_all_with_commit_guard(
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
|
||||
&& let Err(err) = os::rename_all_with_commit_guard(
|
||||
src_data_path,
|
||||
dst_data_path,
|
||||
&skip_parent,
|
||||
commit.publication_root,
|
||||
directory_guard,
|
||||
&commit.directory_guard,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
target: "rustfs_ecstore::disk::local",
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "rename_all_data_path_failed",
|
||||
src_path = ?src_data_path,
|
||||
dst_path = ?dst_data_path,
|
||||
error = ?err,
|
||||
"Disk local rename flow failed"
|
||||
);
|
||||
commit.restore_published_data_source().await?;
|
||||
return Err(err);
|
||||
}
|
||||
{
|
||||
info!(
|
||||
target: "rustfs_ecstore::disk::local",
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "rename_all_data_path_failed",
|
||||
src_path = ?src_data_path,
|
||||
dst_path = ?dst_data_path,
|
||||
error = ?err,
|
||||
"Disk local rename flow failed"
|
||||
);
|
||||
commit.restore_published_data_source().await?;
|
||||
return Err(err);
|
||||
}
|
||||
#[cfg(test)]
|
||||
if has_data_dir_path.is_some() {
|
||||
@@ -992,9 +795,7 @@ impl LocalDisk {
|
||||
} else {
|
||||
SyncMode::None
|
||||
};
|
||||
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref())
|
||||
&& multipart_publication.as_ref().is_none_or(|publication| publication.legacy)
|
||||
{
|
||||
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
|
||||
let backup_parent = dst_file_parent.join(old_data_dir.to_string());
|
||||
#[cfg(not(windows))]
|
||||
if let Err(err) = os::make_dir_all(&backup_parent, &skip_parent).await {
|
||||
@@ -1086,11 +887,7 @@ impl LocalDisk {
|
||||
return Err(err);
|
||||
}
|
||||
if should_fail_after_metadata_commit(dst_path) {
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
|
||||
} else {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
}
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
return Err(DiskError::Unexpected);
|
||||
}
|
||||
|
||||
@@ -1117,11 +914,7 @@ impl LocalDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
|
||||
} else {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
}
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
// The commit rename changed the dst part inodes before this fsync
|
||||
// failed and rolled them back; drop any fd cached during that
|
||||
// window so readers re-open the restored inode (rustfs/backlog#1177).
|
||||
@@ -1158,12 +951,7 @@ impl LocalDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone()))
|
||||
.await?;
|
||||
} else {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
}
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
// Same post-commit rollback window as above — drop cached
|
||||
// dst part fds so readers re-open the restored inode
|
||||
// (rustfs/backlog#1177).
|
||||
@@ -1189,11 +977,14 @@ impl LocalDisk {
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
|
||||
if multipart_publication.is_none()
|
||||
&& !preserve_upload_metadata
|
||||
&& let Some(parent) = src_file_path.parent()
|
||||
{
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
if let Some(src_file_path_parent) = src_file_path.parent() {
|
||||
if src_volume != super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
let _ = std::fs::remove_dir(src_file_path_parent);
|
||||
} else {
|
||||
let _ = self
|
||||
.delete_file(&dst_volume_dir, &src_file_path_parent.to_path_buf(), true, false)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
|
||||
@@ -1211,24 +1002,9 @@ impl LocalDisk {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
|
||||
let multipart_source = match multipart_publication.as_ref() {
|
||||
Some(publication) => match pin_multipart_future(|| publication.finish(commit.mutation_lease.clone())).await {
|
||||
Ok(result) => Some(Box::new(result)),
|
||||
Err(error) => {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
|
||||
return Err(error);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok(RenameDataResp {
|
||||
multipart_source,
|
||||
old_data_dir: has_old_data_dir,
|
||||
rollback_data_dir: if multipart_publication.as_ref().is_some_and(|publication| !publication.legacy) {
|
||||
None
|
||||
} else {
|
||||
rollback_data_dir
|
||||
},
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: has_old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
@@ -1241,47 +1017,25 @@ impl LocalDisk {
|
||||
// allowing a retry to reuse the deterministic staging path too early.
|
||||
let src = src_file_path.clone();
|
||||
let dst = dst_file_path.clone();
|
||||
let cleanup_path = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
src_file_path.parent().map(|p| p.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let dst_path_for_failpoint = dst_path.to_string();
|
||||
let dst_volume_for_source = dst_volume.to_string();
|
||||
let dst_path_for_source = dst_path.to_string();
|
||||
let dst_volume_dir_for_commit = dst_volume_dir.clone();
|
||||
let publication_root_for_commit = self.publication_root.clone();
|
||||
let cleanup_src_parent = multipart_publication.is_none() && !preserve_upload_metadata;
|
||||
#[cfg(not(windows))]
|
||||
let cleanup_src_parent = cleanup_path.is_none();
|
||||
#[cfg(windows)]
|
||||
let source_parent = src_file_parent.to_path_buf();
|
||||
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
|
||||
let sync = durability.syncs_commit_metadata();
|
||||
#[cfg(unix)]
|
||||
let receipt_identity = if sync
|
||||
&& pool_meta_condition
|
||||
.as_ref()
|
||||
.is_some_and(|condition| condition.parent_raw_sha256.is_none())
|
||||
{
|
||||
Some(super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let owned_backup_path = pool_meta_condition
|
||||
.as_ref()
|
||||
.filter(|condition| condition.has_parent())
|
||||
.map(|condition| {
|
||||
destination_object_path
|
||||
.join(condition.attempt_id.to_string())
|
||||
.join(STORAGE_FORMAT_FILE_BACKUP)
|
||||
});
|
||||
#[cfg(unix)]
|
||||
if let Some(backup) = &owned_backup_path {
|
||||
let parent = backup
|
||||
.parent()
|
||||
.ok_or_else(|| DiskError::other("missing owned backup parent"))?;
|
||||
commit.mutation_lease =
|
||||
os::acquire_namespace_mutation_lease_with_owner(parent, Some(commit.mutation_lease.clone())).await;
|
||||
}
|
||||
#[cfg(test)]
|
||||
run_inline_before_file_sync_admission(dst_path);
|
||||
let mut file_sync_admission = if sync {
|
||||
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_admission");
|
||||
Some(
|
||||
os::acquire_file_sync_admission(self.file_sync_permits.clone())
|
||||
.await
|
||||
@@ -1291,10 +1045,7 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let preparation_dispatch = os::StageTimer::new(native_timing, "pool_meta_native_prepare_dispatch_wait");
|
||||
let prepare_inline_metadata = move |is_cancelled: &dyn Fn() -> bool| {
|
||||
drop(preparation_dispatch);
|
||||
let _preparation = os::StageTimer::new(native_timing, "pool_meta_native_prepare_execute");
|
||||
let mut prepared_metadata_source =
|
||||
os::create_prepared_rename_source_with_commit_guard(&src, &dst, &rename_commit_guard_for_preparation)?;
|
||||
#[cfg(windows)]
|
||||
@@ -1328,9 +1079,7 @@ impl LocalDisk {
|
||||
// Healing commits never run set-level post-return rollback; keep
|
||||
// their intra-commit rollback backup in the staging directory so
|
||||
// `<object>/<rollback_data_dir>/xl.meta.bkp` is never stranded.
|
||||
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
|
||||
condition.has_parent().then_some(condition.attempt_id)
|
||||
} else if fi_healing {
|
||||
let rollback_data_dir = if fi_healing {
|
||||
None
|
||||
} else {
|
||||
old_data_dir.or_else(|| {
|
||||
@@ -1353,61 +1102,13 @@ impl LocalDisk {
|
||||
// relaxed tiers do no per-object fsync here at all (aligned
|
||||
// with MinIO's default), trading a documented power-loss
|
||||
// window for latency.
|
||||
{
|
||||
let _new_file = os::StageTimer::new(native_timing, "pool_meta_native_staged_write_sync");
|
||||
prepared_metadata_source.write_all(&new_buf, sync)?;
|
||||
}
|
||||
prepared_metadata_source.write_all(&new_buf, sync)?;
|
||||
run_inline_preparation_before_backup(&dst_path_for_failpoint);
|
||||
#[cfg(unix)]
|
||||
let mut receipt_candidate = receipt_identity.and_then(|identity| {
|
||||
os::DurableFileReceipt::capture(&publication_root_for_commit, &src, &new_buf)
|
||||
.ok()
|
||||
.map(|physical| super::pool_meta_native::DurableHeadReceipt { physical, identity })
|
||||
});
|
||||
#[cfg(not(unix))]
|
||||
let receipt_candidate = None;
|
||||
#[cfg(unix)]
|
||||
let owned_backup = match (owned_backup_path.as_ref(), has_dst_buf.as_ref()) {
|
||||
(Some(backup_path), Some(old_metadata)) => {
|
||||
let backup = {
|
||||
let _create = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
|
||||
Arc::new(os::prepare_pool_meta_owned_backup(
|
||||
&dst,
|
||||
backup_path,
|
||||
&dst_volume_dir_for_commit,
|
||||
&publication_root_for_commit,
|
||||
old_metadata,
|
||||
should_fail_local_inline_rollback_hardlink(&dst),
|
||||
)?)
|
||||
};
|
||||
let reuses_durability = parent_receipt.as_ref().is_some_and(|receipt| {
|
||||
backup.can_reuse_durability(&receipt.physical, &publication_root_for_commit, old_metadata)
|
||||
});
|
||||
if !backup.is_linked() {
|
||||
receipt_candidate = None;
|
||||
}
|
||||
if sync && !reuses_durability {
|
||||
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
|
||||
backup.sync_data()?;
|
||||
} else if sync {
|
||||
rustfs_io_metrics::record_put_object_stage_count("pool_meta_native_backup_sync_reused", 1);
|
||||
}
|
||||
Some(backup)
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return Err(std::io::Error::new(ErrorKind::InvalidData, "pool metadata predecessor disappeared"));
|
||||
}
|
||||
(None, _) => None,
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let owned_backup = None;
|
||||
if let Some(ref old_metadata) = has_dst_buf
|
||||
&& owned_backup.is_none()
|
||||
&& (rollback_data_dir.is_some() || fi_healing || sync || cfg!(test))
|
||||
{
|
||||
#[cfg(windows)]
|
||||
let backup_path = {
|
||||
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create_sync");
|
||||
let backup_path = src
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "missing staging metadata parent"))?
|
||||
@@ -1416,13 +1117,9 @@ impl LocalDisk {
|
||||
backup_path
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let backup_path = {
|
||||
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
|
||||
create_local_inline_rollback_backup(&dst, &src, old_metadata)?
|
||||
};
|
||||
let backup_path = create_local_inline_rollback_backup(&dst, &src, old_metadata)?;
|
||||
#[cfg(not(windows))]
|
||||
if sync {
|
||||
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
|
||||
std::fs::File::open(&backup_path)?.sync_data()?;
|
||||
}
|
||||
staged_rollback_path = Some(StagedRollbackBackup(backup_path));
|
||||
@@ -1481,19 +1178,15 @@ impl LocalDisk {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(
|
||||
RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
},
|
||||
receipt_candidate,
|
||||
));
|
||||
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(RenameDataResp {
|
||||
old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok::<_, std::io::Error>(InlinePreparationOutcome::Prepared((
|
||||
@@ -1506,11 +1199,8 @@ impl LocalDisk {
|
||||
staged_rollback_path,
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
owned_backup,
|
||||
receipt_candidate,
|
||||
)))
|
||||
};
|
||||
let preparation_total = os::StageTimer::new(native_timing, "pool_meta_native_prepare_total");
|
||||
let inline_preparation = if let Some(admission) = file_sync_admission.as_ref() {
|
||||
os::run_cancellable_blocking_namespace_file_sync_operation(
|
||||
commit.mutation_lease.clone(),
|
||||
@@ -1523,7 +1213,6 @@ impl LocalDisk {
|
||||
}
|
||||
.map_err(to_file_error)
|
||||
.map_err(DiskError::from);
|
||||
drop(preparation_total);
|
||||
let (
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir,
|
||||
@@ -1534,29 +1223,22 @@ impl LocalDisk {
|
||||
mut local_rollback_path,
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
owned_backup,
|
||||
receipt_candidate,
|
||||
) = match inline_preparation {
|
||||
Ok(InlinePreparationOutcome::Committed(resp, receipt_candidate)) => {
|
||||
Ok(InlinePreparationOutcome::Committed(resp)) => {
|
||||
drop(file_sync_admission.take());
|
||||
#[cfg(unix)]
|
||||
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
|
||||
.await;
|
||||
#[cfg(not(unix))]
|
||||
let _ = receipt_candidate;
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
#[cfg(windows)]
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
if let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
for part_path in &invalidate_part_paths {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
|
||||
receipt_mutation.complete();
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Ok(InlinePreparationOutcome::CommitErr(err)) | Err(err) => {
|
||||
@@ -1590,42 +1272,6 @@ impl LocalDisk {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(backup) = &owned_backup {
|
||||
// The backup namespace lease owns the head transaction; retain
|
||||
// its directory and inode guards in every later detached job.
|
||||
commit.mutation_lease.attach_external_guard(backup.clone());
|
||||
#[cfg(test)]
|
||||
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
|
||||
let backup_base = dst_volume_dir.clone();
|
||||
let backup_root = commit.publication_root.clone();
|
||||
if let Some(admission) = file_sync_admission.as_ref() {
|
||||
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
let backup = backup.clone();
|
||||
let result =
|
||||
os::run_blocking_namespace_file_sync_operation(commit.mutation_lease.clone(), admission, move || {
|
||||
backup.verify_paths(&backup_base, &backup_root)?;
|
||||
backup.sync_directory()
|
||||
})
|
||||
.await;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_BACKUP_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
result.map_err(to_file_error)?;
|
||||
} else {
|
||||
let backup = backup.clone();
|
||||
os::run_blocking_namespace_operation(commit.mutation_lease.clone(), move || {
|
||||
backup.verify_paths(&backup_base, &backup_root)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = owned_backup;
|
||||
|
||||
if let (Some(rollback_data_dir), Some(staged_backup)) =
|
||||
(rollback_data_dir, local_rollback_path.as_ref().map(StagedRollbackBackup::path))
|
||||
{
|
||||
@@ -1638,24 +1284,21 @@ impl LocalDisk {
|
||||
// rename_all acquires the backup path's namespace lease. Do not
|
||||
// hold a disk admission while acquiring another namespace lock.
|
||||
drop(file_sync_admission.take());
|
||||
let backup_publish = os::StageTimer::new(native_timing, "pool_meta_native_backup_publish");
|
||||
let backup_result = os::rename_all_with_owner(
|
||||
if let Err(err) = os::rename_all_with_owner(
|
||||
staged_backup,
|
||||
&backup_path,
|
||||
&dst_volume_dir,
|
||||
commit.publication_root,
|
||||
Some(commit.mutation_lease.clone()),
|
||||
)
|
||||
.await;
|
||||
drop(backup_publish);
|
||||
if let Err(err) = backup_result {
|
||||
.await
|
||||
{
|
||||
let _ = remove_file_if_exists(staged_backup);
|
||||
return Err(err);
|
||||
}
|
||||
#[cfg(test)]
|
||||
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
|
||||
if sync {
|
||||
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_readmission");
|
||||
file_sync_admission = Some(
|
||||
os::acquire_file_sync_admission(self.file_sync_permits.clone())
|
||||
.await
|
||||
@@ -1666,7 +1309,6 @@ impl LocalDisk {
|
||||
if let Some(admission) = file_sync_admission.as_ref()
|
||||
&& let Some(backup_parent) = backup_path.parent()
|
||||
{
|
||||
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) =
|
||||
os::fsync_dir_with_namespace_file_sync_limit(backup_parent, commit.mutation_lease.clone(), admission)
|
||||
@@ -1686,7 +1328,6 @@ impl LocalDisk {
|
||||
local_rollback_path = None;
|
||||
}
|
||||
|
||||
let head_publish = os::StageTimer::new(native_timing, "pool_meta_native_head_publish");
|
||||
let commit_result = if should_fail_commit_rename(dst_path) {
|
||||
Err(DiskError::other("test fail during metadata commit rename"))
|
||||
} else {
|
||||
@@ -1701,7 +1342,6 @@ impl LocalDisk {
|
||||
)
|
||||
.await
|
||||
};
|
||||
drop(head_publish);
|
||||
if let Err(err) = commit_result {
|
||||
if let Some(backup_path) = local_rollback_path.as_ref().map(StagedRollbackBackup::path) {
|
||||
let _ = remove_file_if_exists(backup_path);
|
||||
@@ -1722,7 +1362,6 @@ impl LocalDisk {
|
||||
if let Some(admission) = file_sync_admission.as_ref()
|
||||
&& let Some(dst_parent) = dst_file_path.parent()
|
||||
{
|
||||
let _head_dir = os::StageTimer::new(native_timing, "pool_meta_native_head_dir_sync");
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_commit_directory(
|
||||
dst_parent,
|
||||
@@ -1803,12 +1442,6 @@ impl LocalDisk {
|
||||
return Err(DiskError::from(err));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
|
||||
.await;
|
||||
#[cfg(not(unix))]
|
||||
let _ = receipt_candidate;
|
||||
|
||||
// The commit no longer has a rollback path. Release the Windows
|
||||
// object identity guard before best-effort staging cleanup.
|
||||
#[cfg(windows)]
|
||||
@@ -1819,7 +1452,9 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else if let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
@@ -1838,12 +1473,7 @@ impl LocalDisk {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
|
||||
receipt_mutation.complete();
|
||||
}
|
||||
Ok(RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: cleanup_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::*;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use futures::FutureExt;
|
||||
|
||||
#[inline(never)]
|
||||
fn heap_scope<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_delete_recovery_cannot_restore_or_prepare_any_physical_multipart_scope() {
|
||||
let temporary = tempfile::tempdir().expect("recovery scope disk");
|
||||
let endpoint = Endpoint::try_from(temporary.path().to_string_lossy().as_ref()).expect("local endpoint");
|
||||
let disk = heap_scope(|| LocalDisk::new(&endpoint, false)).await.expect("local disk");
|
||||
let hash = "a".repeat(64);
|
||||
let upload = Uuid::new_v4().to_string();
|
||||
let object = format!("{hash}/{upload}");
|
||||
let source = disk
|
||||
.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
|
||||
.expect("actual source root");
|
||||
std::fs::create_dir_all(&source).expect("existing source root");
|
||||
// Scope rejection precedes any attempt to interpret or replace these bytes.
|
||||
let head = source.join(STORAGE_FORMAT_FILE);
|
||||
std::fs::write(&head, b"existing source head must not be read or replaced").expect("existing head bytes");
|
||||
let original = std::fs::read(&head).expect("original physical bytes");
|
||||
let logical = disk
|
||||
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
|
||||
.expect("configured metadata key");
|
||||
let owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&logical, None).await);
|
||||
let transaction = Uuid::new_v4();
|
||||
let aliases = [
|
||||
(RUSTFS_META_MULTIPART_BUCKET.to_owned(), object.clone()),
|
||||
(format!("{RUSTFS_META_MULTIPART_BUCKET}/."), object.clone()),
|
||||
(format!("{RUSTFS_META_MULTIPART_BUCKET}/{hash}"), upload),
|
||||
(crate::disk::RUSTFS_META_BUCKET.to_owned(), format!("multipart/{object}")),
|
||||
];
|
||||
for (volume, object) in aliases {
|
||||
let result = heap_scope(|| {
|
||||
disk.delete_recovery_settle(&volume, &object, transaction, [0; 32], DeleteRecoveryAction::Rollback, owner.clone())
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("scope is rejected before metadata lock acquisition");
|
||||
assert_eq!(
|
||||
result.expect_err("generic recovery cannot mutate MPI"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
let result = heap_scope(|| {
|
||||
disk.delete_recovery_settle_owned(
|
||||
&volume,
|
||||
&object,
|
||||
transaction,
|
||||
[0; 32],
|
||||
DeleteRecoveryAction::Rollback,
|
||||
owner.clone(),
|
||||
)
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("owned rollback is rejected before I/O");
|
||||
assert_eq!(
|
||||
result.expect_err("owned recovery cannot restore MPI head"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
let result = heap_scope(|| {
|
||||
disk.prepare_delete_recovery(
|
||||
&volume,
|
||||
&object,
|
||||
Some(&original),
|
||||
&[],
|
||||
None,
|
||||
DeletePreparationContext {
|
||||
transaction,
|
||||
bucket_incarnation: None,
|
||||
namespace_owner: owner.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("preparation is rejected before decoding or writing metadata");
|
||||
assert_eq!(
|
||||
result.expect_err("generic recovery cannot prepare MPI"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
assert_eq!(std::fs::read(&head).expect("source remains present"), original);
|
||||
assert_eq!(std::fs::read_dir(&source).expect("source inventory").count(), 1);
|
||||
}
|
||||
assert!(
|
||||
disk.reject_multipart_delete_recovery_mutation("ordinary-user-bucket", "object")
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
use super::*;
|
||||
use crate::disk::SnapshotLeaseToken;
|
||||
|
||||
async fn deferred_owned_undo() -> (Fixture, PublicationIdentity, SnapshotLeaseToken) {
|
||||
let fixture = Fixture::new().await;
|
||||
let result = fixture
|
||||
.publish_zero_on(&[0])
|
||||
.await
|
||||
.pop()
|
||||
.expect("real first-key native publication")
|
||||
.1;
|
||||
let publication = result.publication.clone();
|
||||
let path = format!("object/{}", publication.final_data_dir);
|
||||
let token = fixture.disks[0]
|
||||
.acquire_snapshot_lease("bucket", &path)
|
||||
.await
|
||||
.expect("retain the actual published DD");
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
|
||||
expected: Box::new(result.proof.snapshot),
|
||||
publication: publication.clone(),
|
||||
capability: result.undo_capability,
|
||||
round: Fixture::round_for(&publication),
|
||||
}))
|
||||
.await
|
||||
.expect("owned Undo restores absence and defers only its exact DD");
|
||||
assert!(!fixture.disks[0].root.join("bucket/object/xl.meta").exists());
|
||||
(fixture, publication, token)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_multipart_owned_undo_deferred_reclaim_rechecks_successors_references_and_ownership() {
|
||||
for changed in [
|
||||
"successor",
|
||||
"referenced",
|
||||
"child",
|
||||
"foreign-anchor",
|
||||
"missing-anchor",
|
||||
"unknown-file",
|
||||
] {
|
||||
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
|
||||
let disk = &fixture.disks[0];
|
||||
let path = format!("object/{}", publication.final_data_dir);
|
||||
let data = disk.io_get_object_path("bucket", &path).expect("actual failed DD");
|
||||
let part = std::fs::read(data.join("part.1")).expect("real selected shard before deferred callback");
|
||||
let sidecar = std::fs::read(data.join("part.1.meta")).expect("real selected generation before callback");
|
||||
let mut successor = None;
|
||||
let mut child = None;
|
||||
match changed {
|
||||
"successor" | "referenced" => {
|
||||
let referenced = (changed == "referenced").then_some(publication.final_data_dir);
|
||||
let info = super::super::super::test::test_file_info(
|
||||
"object",
|
||||
Uuid::new_v4(),
|
||||
referenced,
|
||||
Some(bytes::Bytes::from_static(b"newer PUT body")),
|
||||
);
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, "real-newer-put", info, "bucket", "object")
|
||||
.await
|
||||
.expect("real ordinary PUT while old failed DD is snapshot-protected");
|
||||
let raw = std::fs::read(disk.io_get_object_path("bucket", "object/xl.meta").expect("target FI"))
|
||||
.expect("actual successor FI");
|
||||
let refs = FileMeta::load(&raw)
|
||||
.expect("successor metadata")
|
||||
.get_data_dirs()
|
||||
.expect("all successor versions");
|
||||
assert_eq!(refs.contains(&Some(publication.final_data_dir)), changed == "referenced");
|
||||
successor = Some(raw);
|
||||
}
|
||||
"child" => {
|
||||
let child_path = format!("{path}/child");
|
||||
let info = super::super::super::test::test_file_info(
|
||||
"child",
|
||||
Uuid::nil(),
|
||||
None,
|
||||
Some(bytes::Bytes::from_static(b"child PUT body")),
|
||||
);
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, "real-child-put", info, "bucket", &child_path)
|
||||
.await
|
||||
.expect("real child PUT under the UUID-shaped S3 prefix");
|
||||
child = Some(std::fs::read(data.join("child/xl.meta")).expect("actual child metadata"));
|
||||
}
|
||||
"foreign-anchor" => {
|
||||
let mut anchor: UntrustedAnchorBlob =
|
||||
rmp_serde::from_slice(&std::fs::read(data.join(".mpu-source-retirement")).unwrap()).unwrap();
|
||||
anchor.disk_id = Uuid::new_v4();
|
||||
std::fs::write(data.join(".mpu-source-retirement"), rmp_serde::to_vec_named(&anchor).unwrap()).unwrap();
|
||||
}
|
||||
"missing-anchor" => {
|
||||
std::fs::remove_file(data.join(".mpu-source-retirement")).unwrap();
|
||||
}
|
||||
"unknown-file" => {
|
||||
std::fs::write(data.join("unknown"), b"protected").unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let anchor = std::fs::read(data.join(".mpu-source-retirement")).ok();
|
||||
let reclaimed = disk.release_snapshot_lease("bucket", &path, token).await;
|
||||
if changed == "successor" {
|
||||
reclaimed.expect("unreferenced owned failed DD is reclaimable beside a newer real PUT");
|
||||
assert!(!data.exists());
|
||||
} else {
|
||||
assert!(reclaimed.is_err(), "{changed} must reject the actual callback before deleting payload");
|
||||
assert_eq!(std::fs::read(data.join("part.1")).expect("protected actual shard"), part);
|
||||
assert_eq!(std::fs::read(data.join("part.1.meta")).expect("protected actual sidecar"), sidecar);
|
||||
assert_eq!(std::fs::read(data.join(".mpu-source-retirement")).ok(), anchor);
|
||||
}
|
||||
if let Some(raw) = successor {
|
||||
assert_eq!(std::fs::read(disk.io_get_object_path("bucket", "object/xl.meta").unwrap()).unwrap(), raw);
|
||||
}
|
||||
if let Some(raw) = child {
|
||||
assert_eq!(std::fs::read(data.join("child/xl.meta")).unwrap(), raw);
|
||||
}
|
||||
if changed == "unknown-file" {
|
||||
assert_eq!(std::fs::read(data.join("unknown")).unwrap(), b"protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn native_multipart_owned_undo_reclaim_keeps_anchor_until_payload_barrier_and_retries_empty_dd() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
for after_anchor in [false, true] {
|
||||
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
|
||||
let disk = &fixture.disks[0];
|
||||
let path = format!("object/{}", publication.final_data_dir);
|
||||
let data = disk.io_get_object_path("bucket", &path).expect("actual DD barrier path");
|
||||
let anchor_path = data.join(".mpu-source-retirement");
|
||||
let anchor = std::fs::read(&anchor_path).expect("actual owner anchor");
|
||||
let _hook = if after_anchor {
|
||||
let directory = data.clone();
|
||||
Some(hooks::install_at(hooks::Stage::Remove, &anchor_path, move || {
|
||||
os::fsync_dir_recorder::set_failure(&directory, std::io::ErrorKind::Other);
|
||||
}))
|
||||
} else {
|
||||
os::fsync_dir_recorder::set_failure(&data, std::io::ErrorKind::Other);
|
||||
None
|
||||
};
|
||||
assert!(disk.release_snapshot_lease("bucket", &path, token).await.is_err());
|
||||
assert!(
|
||||
os::fsync_dir_recorder::take_failure(&data).is_none(),
|
||||
"the real selected DD barrier must fail"
|
||||
);
|
||||
assert!(!data.join("part.1").exists() && !data.join("part.1.meta").exists());
|
||||
if after_anchor {
|
||||
assert!(
|
||||
std::fs::read_dir(&data).unwrap().next().is_none(),
|
||||
"only an empty DD remains after the final anchor unlink"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(std::fs::read(&anchor_path).expect("failed payload barrier retains authority"), anchor);
|
||||
}
|
||||
let retry = disk
|
||||
.acquire_snapshot_lease("bucket", &path)
|
||||
.await
|
||||
.expect("finished failed callback releases deleting");
|
||||
disk.release_snapshot_lease("bucket", &path, retry)
|
||||
.await
|
||||
.expect("same privately owned pending operation retries");
|
||||
assert!(!data.exists());
|
||||
disk.delete_volume("bucket", false)
|
||||
.await
|
||||
.expect("no owned native payload residue blocks empty bucket deletion");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn native_multipart_owned_undo_cancelled_snapshot_release_keeps_physical_delete_claim() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
let (fixture, publication, token) = Box::pin(deferred_owned_undo()).await;
|
||||
let disk = fixture.disks[0].clone();
|
||||
let path = format!("object/{}", publication.final_data_dir);
|
||||
let data = disk.io_get_object_path("bucket", &path).expect("actual callback DD");
|
||||
let (entered, receive_entered) = std::sync::mpsc::channel();
|
||||
let (release, receive_release) = std::sync::mpsc::channel();
|
||||
let _hook = hooks::install_at(hooks::Stage::Remove, &data.join("part.1"), move || {
|
||||
entered.send(()).expect("actual unlink callback entered");
|
||||
receive_release
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.expect("release physical unlink");
|
||||
});
|
||||
let owned_disk = disk.clone();
|
||||
let owned_path = path.clone();
|
||||
let caller = tokio::spawn(async move { owned_disk.release_snapshot_lease("bucket", &owned_path, token).await });
|
||||
tokio::task::spawn_blocking(move || receive_entered.recv_timeout(std::time::Duration::from_secs(5)))
|
||||
.await
|
||||
.expect("hook waiter joins")
|
||||
.expect("real unlink is blocked before cancelling its waiter");
|
||||
caller.abort();
|
||||
assert!(caller.await.expect_err("cancelled waiter").is_cancelled());
|
||||
let key = super::super::super::SnapshotLeaseKey {
|
||||
volume: "bucket".to_owned(),
|
||||
path: path.clone(),
|
||||
};
|
||||
assert!(
|
||||
disk.snapshot_leases
|
||||
.lock()
|
||||
.entries
|
||||
.get(&key)
|
||||
.is_some_and(|entry| entry.deleting),
|
||||
"only the actual physical callback may release the deleting claim"
|
||||
);
|
||||
release.send(()).expect("finish actual unlink after waiter cancellation");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if !data.exists() && !disk.snapshot_leases.lock().entries.contains_key(&key) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("physical callback finishes and clears its owned registry claim");
|
||||
assert!(!data.exists());
|
||||
assert!(matches!(disk.acquire_snapshot_lease("bucket", &path).await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_multipart_owned_undo_rpc_retries_a_failed_physical_reclaim() {
|
||||
let fixture = Fixture::new().await;
|
||||
let result = fixture
|
||||
.publish_zero_on(&[0])
|
||||
.await
|
||||
.pop()
|
||||
.expect("actual failed-owner publication")
|
||||
.1;
|
||||
let publication = result.publication.clone();
|
||||
let round = Fixture::round_for(&publication);
|
||||
let expected = result.proof.snapshot.clone();
|
||||
let capability_wire = rmp_serde::to_vec_named(&result.undo_capability).expect("original opaque native cap");
|
||||
let path = format!("object/{}", publication.final_data_dir);
|
||||
let data = fixture.disks[0].io_get_object_path("bucket", &path).expect("actual final DD");
|
||||
let anchor = std::fs::read(data.join(".mpu-source-retirement")).expect("real native anchor");
|
||||
os::fsync_dir_recorder::set_failure(&data, std::io::ErrorKind::Other);
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
|
||||
expected: Box::new(expected.clone()),
|
||||
publication: publication.clone(),
|
||||
capability: result.undo_capability,
|
||||
round: round.clone(),
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"a real reclaim barrier failure cannot become a successful first RPC"
|
||||
);
|
||||
assert!(
|
||||
os::fsync_dir_recorder::take_failure(&data).is_none(),
|
||||
"the selected physical reclaim barrier was reached"
|
||||
);
|
||||
let after_failure = fixture.snapshot(0).await;
|
||||
assert_eq!(after_failure.phase, MultipartSourcePhase::Recovering);
|
||||
assert!(after_failure.head_sha256.is_some() && after_failure.capability_sha256.is_none());
|
||||
assert!(!fixture.disks[0].root.join("bucket/object/xl.meta").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(data.join(".mpu-source-retirement")).expect("failed payload barrier retains exact anchor"),
|
||||
anchor
|
||||
);
|
||||
let capability = rmp_serde::from_slice(&capability_wire).expect("same opaque capability on RPC replay");
|
||||
let response = fixture.disks[0]
|
||||
.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
|
||||
expected: Box::new(expected),
|
||||
publication,
|
||||
capability,
|
||||
round,
|
||||
}))
|
||||
.await
|
||||
.expect("same UndoAndClaim RPC resumes its qualified reclaim before returning Claimed");
|
||||
let MultipartSourceResponse::Claimed(proof) = response else {
|
||||
panic!("native owned RPC replay must return its durable claim");
|
||||
};
|
||||
assert_eq!(proof.snapshot, after_failure);
|
||||
assert!(
|
||||
!data.exists(),
|
||||
"the early same-claim RPC branch must actually finish its interrupted reclaim"
|
||||
);
|
||||
fixture.disks[0]
|
||||
.delete_volume("bucket", false)
|
||||
.await
|
||||
.expect("RPC replay leaves an actually empty bucket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_multipart_owned_undo_pending_proof_survives_both_generic_request_orders() {
|
||||
for ordinary_first in [false, true] {
|
||||
let fixture = Fixture::new().await;
|
||||
let result = fixture.publish_zero_on(&[0]).await.pop().expect("actual native producer").1;
|
||||
let publication = result.publication.clone();
|
||||
let path = format!("object/{}", publication.final_data_dir);
|
||||
let disk = &fixture.disks[0];
|
||||
let data = disk.io_get_object_path("bucket", &path).expect("actual failed native DD");
|
||||
let token = disk
|
||||
.acquire_snapshot_lease("bucket", &path)
|
||||
.await
|
||||
.expect("real snapshot protects this DD");
|
||||
let ordinary = || DeleteOptions {
|
||||
orphan_data_dir: true,
|
||||
..Default::default()
|
||||
};
|
||||
if ordinary_first {
|
||||
assert_eq!(
|
||||
disk.delete_data_dir("bucket", &path, ordinary())
|
||||
.await
|
||||
.expect("generic request parks behind actual snapshot"),
|
||||
crate::disk::DataDirDeleteStatus::Deferred
|
||||
);
|
||||
}
|
||||
disk.recover_multipart_source_native(fixture.request(MultipartSourceOperation::UndoAndClaim {
|
||||
expected: Box::new(result.proof.snapshot),
|
||||
publication: publication.clone(),
|
||||
capability: result.undo_capability,
|
||||
round: Fixture::round_for(&publication),
|
||||
}))
|
||||
.await
|
||||
.expect("actual owned Undo qualifies missing-FI cleanup");
|
||||
if !ordinary_first {
|
||||
assert_eq!(
|
||||
disk.delete_data_dir("bucket", &path, ordinary())
|
||||
.await
|
||||
.expect("later generic request remains deferred"),
|
||||
crate::disk::DataDirDeleteStatus::Deferred
|
||||
);
|
||||
}
|
||||
let key = super::super::super::SnapshotLeaseKey {
|
||||
volume: "bucket".to_owned(),
|
||||
path: path.clone(),
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
disk.snapshot_leases
|
||||
.lock()
|
||||
.entries
|
||||
.get(&key)
|
||||
.and_then(|entry| entry.pending_delete.as_ref()),
|
||||
Some(super::super::super::SnapshotPendingDelete::OwnedMultipart(_))
|
||||
),
|
||||
"the exact native proof cannot be lost to generic options, ordinary_first={ordinary_first}"
|
||||
);
|
||||
assert!(data.join(".mpu-source-retirement").exists());
|
||||
disk.release_snapshot_lease("bucket", &path, token)
|
||||
.await
|
||||
.expect("release replays the qualified native owner");
|
||||
assert!(!data.exists(), "both request orders must reclaim the owned DD after its snapshot drains");
|
||||
disk.delete_volume("bucket", false)
|
||||
.await
|
||||
.expect("no generic pending request strands an owned absent-target DD");
|
||||
}
|
||||
}
|
||||
@@ -1,695 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! All-participant, irreversible source closure. No operation in this module undoes a target.
|
||||
use super::{
|
||||
LocalDisk,
|
||||
multipart_source_state::{
|
||||
LoadedSource, SourceHead, SourceState, SourceStore, decode, encode, load_source, raw_sha, read_optional,
|
||||
read_optional_in_pinned,
|
||||
},
|
||||
};
|
||||
use crate::disk::{
|
||||
DiskError, Error, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET, Result, STORAGE_FORMAT_FILE,
|
||||
multipart_source::*, os,
|
||||
};
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
io::{self, ErrorKind},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const STATE: &str = ".mpu-source-state";
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RetiredHead {
|
||||
pub sha256: [u8; 32],
|
||||
pub length: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct AbortCertificateRef {
|
||||
pub round: MultipartAbortRound,
|
||||
pub sha256: [u8; 32],
|
||||
pub slot: u8,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct NativeAbortState {
|
||||
pub round: MultipartAbortRound,
|
||||
pub pending_from: MultipartSourceSnapshot,
|
||||
pub retired_head: Option<RetiredHead>,
|
||||
pub committed: Option<AbortCertificateRef>,
|
||||
}
|
||||
impl NativeAbortState {
|
||||
pub fn validate(&self, state: &SourceState) -> Result<()> {
|
||||
self.round.validate()?;
|
||||
if state.phase != MultipartSourcePhase::Aborting
|
||||
|| state.capability_sha256.is_some()
|
||||
|| state.generation != self.round.generation
|
||||
|| state.lineage != self.round.lineage
|
||||
|| self.pending_from.lineage != state.lineage
|
||||
|| self.pending_from.disk_id != state.disk_id
|
||||
|| self.pending_from.physical_slot != state.physical_slot
|
||||
|| self.pending_from.generation.epoch > state.generation.epoch
|
||||
|| self.committed.as_ref().is_some_and(|reference| {
|
||||
reference.round.lineage != state.lineage
|
||||
|| reference.slot > 1
|
||||
|| reference.round.generation.epoch > state.generation.epoch
|
||||
|| (reference.round.generation.epoch == state.generation.epoch
|
||||
&& reference.round.generation != state.generation)
|
||||
})
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if let Some(reference) = &self.committed {
|
||||
reference.round.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn retired_path(source: &Path, state: &SourceState) -> PathBuf {
|
||||
source
|
||||
.join(".mpu-completions")
|
||||
.join(state.backup_owner.to_string())
|
||||
.join("aborted-head.xl.meta")
|
||||
}
|
||||
fn abort_certificate_path(source: &Path, owner: Uuid, slot: u8) -> PathBuf {
|
||||
source
|
||||
.join(".mpu-completions")
|
||||
.join(owner.to_string())
|
||||
.join(format!("abort-{slot}.msgpack"))
|
||||
}
|
||||
|
||||
/// The only cold local provenance rehydration token; callers cannot construct it.
|
||||
pub(in crate::disk) struct ValidatedAbortCertificate {
|
||||
digest: [u8; 32],
|
||||
round: MultipartAbortRound,
|
||||
}
|
||||
impl ValidatedAbortCertificate {
|
||||
pub(in crate::disk) fn validate_certificate(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
|
||||
if certificate.digest()? != self.digest || certificate.round != self.round {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_retired_head(
|
||||
source: &Path,
|
||||
base: &Path,
|
||||
root: &os::PublicationRoot,
|
||||
state: &SourceState,
|
||||
head: Option<&SourceHead>,
|
||||
) -> Result<()> {
|
||||
let Some(abort) = &state.abort else {
|
||||
return Ok(());
|
||||
};
|
||||
abort.validate(state)?;
|
||||
if let Some(head) = head {
|
||||
if abort.pending_from.head_sha256 != Some(head.sha256)
|
||||
&& abort
|
||||
.retired_head
|
||||
.as_ref()
|
||||
.is_none_or(|receipt| receipt.sha256 != head.sha256 || receipt.length != head.length)
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
} else if let Some(receipt) = &abort.retired_head {
|
||||
let observed = os::observe_multipart_source_metadata(&retired_path(source, state), base, root, receipt.length)?;
|
||||
if observed != (receipt.length, receipt.sha256) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SourceStore {
|
||||
fn verify_abort_cohort(&self, certificate: &MultipartAbortCertificate) -> Result<()> {
|
||||
certificate.verify_with_authority(&self.authority)?;
|
||||
if certificate.disk_ids != self.disk_ids
|
||||
|| certificate.disk_ids.get(self.physical_slot) != Some(&self.disk_id)
|
||||
|| certificate.round.lineage.path != self.path
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn read_abort_certificate(&self, loaded: &LoadedSource) -> Result<Option<MultipartAbortCertificate>> {
|
||||
let Some(reference) = loaded
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.abort.as_ref())
|
||||
.and_then(|abort| abort.committed.as_ref())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let owner = loaded.state.as_ref().ok_or(DiskError::FileCorrupt)?.backup_owner;
|
||||
let path = abort_certificate_path(&self.source, owner, reference.slot);
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let raw = os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
os::read_multipart_source_metadata(&path, &base, &root)
|
||||
})
|
||||
.await?;
|
||||
let mut certificate: MultipartAbortCertificate = decode(&raw)?;
|
||||
let validated = ValidatedAbortCertificate {
|
||||
digest: reference.sha256,
|
||||
round: reference.round.clone(),
|
||||
};
|
||||
certificate.hydrate_owned(&self.authority, &validated)?;
|
||||
self.verify_abort_cohort(&certificate)?;
|
||||
Ok(Some(certificate))
|
||||
}
|
||||
async fn persist_abort_certificate(
|
||||
&self,
|
||||
state: &SourceState,
|
||||
certificate: &MultipartAbortCertificate,
|
||||
) -> Result<AbortCertificateRef> {
|
||||
self.verify_abort_cohort(certificate)?;
|
||||
let raw = encode(certificate)?;
|
||||
let digest = raw_sha(&raw);
|
||||
let slot = state
|
||||
.abort
|
||||
.as_ref()
|
||||
.and_then(|abort| abort.committed.as_ref())
|
||||
.map_or(0, |current| 1 - current.slot);
|
||||
let path = abort_certificate_path(&self.source, state.backup_owner, slot);
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
let _source = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("abort certificate has no parent"))?;
|
||||
let _parents = os::mkdir_all_below_existing_base_std(parent, &base, &root)?;
|
||||
// Only the unreferenced slot may be replaced. The old committed
|
||||
// slot remains recoverable until the subsequent state barrier.
|
||||
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
|
||||
os::sync_multipart_owned_record_parents(&source, parent, true, &base, &root)
|
||||
})
|
||||
.await?;
|
||||
Ok(AbortCertificateRef {
|
||||
round: certificate.round.clone(),
|
||||
sha256: digest,
|
||||
slot,
|
||||
})
|
||||
}
|
||||
async fn retire_abort_head(&self, state: &SourceState) -> Result<()> {
|
||||
let abort = state.abort.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let state = state.clone();
|
||||
let receipt = abort.retired_head.clone();
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
let canonical = source.join(STORAGE_FORMAT_FILE);
|
||||
if let Some(receipt) = receipt {
|
||||
let retired = retired_path(&source, &state);
|
||||
os::retire_multipart_source_head_std(&canonical, &retired, &base, &root, receipt.length, receipt.sha256)?;
|
||||
os::sync_multipart_owned_record_parents(
|
||||
&source,
|
||||
retired
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("retired head parent missing"))?,
|
||||
true,
|
||||
&base,
|
||||
&root,
|
||||
)
|
||||
} else {
|
||||
match os::observe_multipart_source_metadata(&canonical, &base, &root, 0) {
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => os::fsync_dir_std(&source),
|
||||
Err(error) => Err(error),
|
||||
Ok(_) => Err(io::Error::new(ErrorKind::AlreadyExists, "closed source unexpectedly has a head")),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
async fn abort_proof(
|
||||
&self,
|
||||
kind: MultipartAbortProofKind,
|
||||
round: &MultipartAbortRound,
|
||||
certificate_sha256: Option<[u8; 32]>,
|
||||
publication: Option<PublicationIdentity>,
|
||||
challenge: Uuid,
|
||||
) -> Result<MultipartAbortProof> {
|
||||
let loaded = self.load().await?;
|
||||
let snapshot = loaded.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if loaded.head.is_some() {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
MultipartAbortProof::issue(&self.authority, kind, challenge, round.clone(), snapshot, certificate_sha256, publication)
|
||||
}
|
||||
async fn abort_inspection(&self) -> Result<MultipartSourceInspection> {
|
||||
let loaded = self.load().await?;
|
||||
let certificate = self.read_abort_certificate(&loaded).await?;
|
||||
Ok(MultipartSourceInspection {
|
||||
disk_id: self.disk_id,
|
||||
root_missing: loaded.root_missing,
|
||||
abort_round: loaded
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.abort.as_ref().map(|abort| abort.round.clone())),
|
||||
abort_certificate: certificate,
|
||||
snapshot: loaded.snapshot,
|
||||
original: loaded.original,
|
||||
recovery: loaded.state.as_ref().and_then(|state| state.recovery.clone()),
|
||||
negative_certificate: None,
|
||||
ready_certificate: None,
|
||||
terminal_certificate: loaded.state.map_or_else(Vec::new, |state| state.terminal_certificate),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
pub(super) async fn recover_multipart_abort_owned(
|
||||
&self,
|
||||
store: SourceStore,
|
||||
operation: MultipartSourceOperation,
|
||||
) -> Result<MultipartSourceResponse> {
|
||||
match operation {
|
||||
MultipartSourceOperation::UpgradeLegacy { expected } => {
|
||||
let loaded = store.load().await?;
|
||||
if loaded.snapshot.as_ref() != Some(&expected)
|
||||
|| loaded.state.is_some()
|
||||
|| expected.phase != MultipartSourcePhase::Active
|
||||
|| expected.generation.epoch != 0
|
||||
|| !loaded.head_matches_state
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let mut info = loaded.info.ok_or(DiskError::FileCorrupt)?;
|
||||
if native_source_protocol(&info)? {
|
||||
return Ok(MultipartSourceResponse::LegacyUpgraded(store.abort_inspection().await?));
|
||||
}
|
||||
if recognized_native_source_controls(&info)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let source = store.source.clone();
|
||||
let base = store.base.clone();
|
||||
let root = store.publication_root.clone();
|
||||
os::run_blocking_namespace_operation(store.lease.clone(), move || {
|
||||
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
for entry in std::fs::read_dir(&source)? {
|
||||
let entry = entry?;
|
||||
if entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.get(..5).is_some_and(|prefix| prefix.eq_ignore_ascii_case(".mpu-")))
|
||||
{
|
||||
return Err(io::Error::new(ErrorKind::InvalidData, "legacy source has native owned records"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
mark_native_source(&mut info);
|
||||
let mut metadata = FileMeta::new();
|
||||
metadata.add_version(info).map_err(Error::other)?;
|
||||
store.replace_head(&metadata.marshal_msg().map_err(Error::other)?).await?;
|
||||
Ok(MultipartSourceResponse::LegacyUpgraded(store.abort_inspection().await?))
|
||||
}
|
||||
MultipartSourceOperation::PrepareAbort { expected, round } => {
|
||||
round.validate()?;
|
||||
let loaded = store.load().await?;
|
||||
if loaded.snapshot.as_ref() != Some(&expected) || expected.lineage != round.lineage {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let mut state = store.ensure_state(&loaded).await?;
|
||||
if state
|
||||
.abort
|
||||
.as_ref()
|
||||
.and_then(|abort| abort.committed.as_ref())
|
||||
.is_some_and(|committed| committed.round == round)
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
if state.abort.as_ref().is_none_or(|abort| abort.round != round) {
|
||||
if round.generation.epoch <= state.generation.epoch {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
if let Some(head) = loaded.head.as_ref() {
|
||||
let owned = head.sha256 == state.original_sha256
|
||||
|| Some(head.sha256) == state.prepared_sha256
|
||||
|| loaded.head_matches_state;
|
||||
if !owned {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
}
|
||||
let previous = state.abort.take();
|
||||
let retired = previous.as_ref().and_then(|abort| abort.retired_head.clone()).or_else(|| {
|
||||
loaded.head.as_ref().map(|head| RetiredHead {
|
||||
sha256: head.sha256,
|
||||
length: head.length,
|
||||
})
|
||||
});
|
||||
state.abort = Some(NativeAbortState {
|
||||
round: round.clone(),
|
||||
pending_from: expected,
|
||||
retired_head: retired,
|
||||
committed: previous.and_then(|abort| abort.committed),
|
||||
});
|
||||
state.generation = round.generation;
|
||||
state.capability_sha256 = None;
|
||||
state.advance(MultipartSourcePhase::Aborting)?;
|
||||
store.persist(&state).await?;
|
||||
}
|
||||
store.retire_abort_head(&state).await?;
|
||||
Ok(MultipartSourceResponse::AbortPrepared(
|
||||
store
|
||||
.abort_proof(MultipartAbortProofKind::Prepared, &round, None, None, round.round)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
MultipartSourceOperation::CommitAbort { expected, certificate } => {
|
||||
let loaded = store.load().await?;
|
||||
if loaded.snapshot.as_ref() != Some(&expected) {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
store.verify_abort_cohort(&certificate)?;
|
||||
let mut state = loaded.state.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
let abort = state.abort.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if state.phase != MultipartSourcePhase::Aborting
|
||||
|| loaded.head.is_some()
|
||||
|| certificate.round.lineage != state.lineage
|
||||
|| certificate.round.generation.epoch > state.generation.epoch
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let digest = certificate.digest()?;
|
||||
if abort.committed.as_ref().is_none_or(|reference| reference.sha256 != digest) {
|
||||
let reference = store.persist_abort_certificate(&state, &certificate).await?;
|
||||
state.abort.as_mut().ok_or(DiskError::FileCorrupt)?.committed = Some(reference);
|
||||
state.advance(MultipartSourcePhase::Aborting)?;
|
||||
store.persist(&state).await?;
|
||||
} else {
|
||||
store
|
||||
.read_abort_certificate(&store.load().await?)
|
||||
.await?
|
||||
.ok_or(DiskError::FileCorrupt)?;
|
||||
}
|
||||
Ok(MultipartSourceResponse::AbortCommitted(
|
||||
store
|
||||
.abort_proof(
|
||||
MultipartAbortProofKind::Committed,
|
||||
&certificate.round,
|
||||
Some(digest),
|
||||
None,
|
||||
certificate.round.round,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
MultipartSourceOperation::DeleteAborted { expected, certificate } => {
|
||||
let loaded = store.load().await?;
|
||||
if loaded.snapshot.as_ref() != Some(&expected) {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
store.verify_abort_cohort(&certificate)?;
|
||||
let current = store
|
||||
.read_abort_certificate(&loaded)
|
||||
.await?
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if current != certificate || loaded.head.is_some() {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let permit = AbortDeletePermit::new(&store, &expected, certificate.clone())?;
|
||||
let destination = self.io_get_object_path(RUSTFS_META_TMP_DELETED_BUCKET, &Uuid::new_v4().to_string())?;
|
||||
let source = store.source.clone();
|
||||
let base = store.base.clone();
|
||||
let root = store.publication_root.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, Some(store.lease.clone())).await;
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
permit.validate()?;
|
||||
os::rename_multipart_source_owned_std(&source, &destination, &base, &root, || permit.validate())
|
||||
})
|
||||
.await?;
|
||||
let proof = MultipartAbortProof::issue(
|
||||
&store.authority,
|
||||
MultipartAbortProofKind::Deleted,
|
||||
certificate.round.round,
|
||||
certificate.round.clone(),
|
||||
expected,
|
||||
Some(certificate.digest()?),
|
||||
None,
|
||||
)?;
|
||||
Ok(MultipartSourceResponse::AbortDeleted(proof))
|
||||
}
|
||||
MultipartSourceOperation::ConfirmCleanupTarget {
|
||||
expected,
|
||||
publication,
|
||||
challenge,
|
||||
} => {
|
||||
let loaded = store.load().await?;
|
||||
if loaded.snapshot.as_ref() != Some(&expected)
|
||||
|| expected.phase != MultipartSourcePhase::Aborting
|
||||
|| loaded.head.is_some()
|
||||
|| expected.lineage != publication.lineage
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let certificate = store
|
||||
.read_abort_certificate(&loaded)
|
||||
.await?
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
let target = self.get_object_path(&publication.lineage.bucket, &publication.lineage.object)?;
|
||||
let _owner = os::acquire_metadata_mutation_lease(&target, Some(store.lease.clone())).await;
|
||||
store.validate_cleanup_target(&publication).await?;
|
||||
store
|
||||
.abort_proof(
|
||||
MultipartAbortProofKind::CleanupTarget,
|
||||
&certificate.round,
|
||||
Some(certificate.digest()?),
|
||||
Some(publication),
|
||||
challenge,
|
||||
)
|
||||
.await
|
||||
.map(MultipartSourceResponse::CleanupTargetConfirmed)
|
||||
}
|
||||
_ => Err(DiskError::UnsupportedDisk),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This permit is neither serializable nor constructible by a generic delete RPC.
|
||||
struct AbortDeletePermit {
|
||||
source: PathBuf,
|
||||
base: PathBuf,
|
||||
root: os::PublicationRoot,
|
||||
path: String,
|
||||
disk_id: Uuid,
|
||||
slot: usize,
|
||||
disk_ids: Vec<Uuid>,
|
||||
expected: MultipartSourceSnapshot,
|
||||
certificate: MultipartAbortCertificate,
|
||||
authority: ProofVerificationContext,
|
||||
}
|
||||
impl AbortDeletePermit {
|
||||
fn new(store: &SourceStore, expected: &MultipartSourceSnapshot, certificate: MultipartAbortCertificate) -> Result<Self> {
|
||||
store.verify_abort_cohort(&certificate)?;
|
||||
Ok(Self {
|
||||
source: store.source.clone(),
|
||||
base: store.base.clone(),
|
||||
root: store.publication_root.clone(),
|
||||
path: store.path.clone(),
|
||||
disk_id: store.disk_id,
|
||||
slot: store.physical_slot,
|
||||
disk_ids: store.disk_ids.clone(),
|
||||
expected: expected.clone(),
|
||||
certificate,
|
||||
authority: store.authority.clone(),
|
||||
})
|
||||
}
|
||||
fn validate(&self) -> io::Result<()> {
|
||||
(|| -> Result<()> {
|
||||
self.certificate.verify_with_authority(&self.authority)?;
|
||||
let loaded = load_source(&self.source, &self.base, &self.root, &self.path, self.disk_id, self.slot, &self.disk_ids)?;
|
||||
if loaded.snapshot.as_ref() != Some(&self.expected) || loaded.head.is_some() {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let reference = loaded
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.abort.as_ref())
|
||||
.and_then(|abort| abort.committed.as_ref())
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if reference.sha256 != self.certificate.digest()? || reference.round != self.certificate.round {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let raw = read_optional(
|
||||
&abort_certificate_path(
|
||||
&self.source,
|
||||
loaded.state.as_ref().ok_or(DiskError::FileCorrupt)?.backup_owner,
|
||||
reference.slot,
|
||||
),
|
||||
&self.base,
|
||||
&self.root,
|
||||
)?
|
||||
.ok_or(DiskError::FileCorrupt)?;
|
||||
if raw_sha(&raw) != reference.sha256 {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
})()
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
|
||||
/// A local ownership token can only be obtained by acquiring the upload's
|
||||
/// SourceRW. Appending another owner preserves that guard in the Arc chain.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct MultipartDeleteOwner {
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
source_key: Option<PathBuf>,
|
||||
}
|
||||
impl MultipartDeleteOwner {
|
||||
pub(super) fn namespace_owner(&self) -> Option<Arc<dyn Send + Sync>> {
|
||||
self.owner.clone()
|
||||
}
|
||||
pub(super) fn chain(self, owner: Option<Arc<dyn Send + Sync>>) -> Self {
|
||||
let owner = match (self.owner, owner) {
|
||||
(None, owner) | (owner, None) => owner,
|
||||
(Some(first), Some(second)) if Arc::ptr_eq(&first, &second) => Some(first),
|
||||
(Some(first), Some(second)) => Some(Arc::new((first, second)) as Arc<dyn Send + Sync>),
|
||||
};
|
||||
Self {
|
||||
owner,
|
||||
source_key: self.source_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl LocalDisk {
|
||||
fn multipart_delete_owner_key(&self, volume: &str, path: &str) -> Result<Option<PathBuf>> {
|
||||
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), volume, path)?;
|
||||
Ok((volume == RUSTFS_META_MULTIPART_BUCKET).then(|| {
|
||||
self.root
|
||||
.join(RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(super::multipart_staging::upload_scope_root(path, true).to_ascii_lowercase())
|
||||
}))
|
||||
}
|
||||
pub(super) async fn acquire_multipart_delete_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<MultipartDeleteOwner> {
|
||||
let source_key = self.multipart_delete_owner_key(volume, path)?;
|
||||
let owner = self.multipart_source_owner(volume, path, true, true, owner).await?;
|
||||
Ok(MultipartDeleteOwner { owner, source_key })
|
||||
}
|
||||
pub(super) fn validate_multipart_delete_owner(
|
||||
&self,
|
||||
token: &MultipartDeleteOwner,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
) -> Result<Option<Arc<dyn Send + Sync>>> {
|
||||
if token.source_key != self.multipart_delete_owner_key(volume, path)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(token.namespace_owner())
|
||||
}
|
||||
}
|
||||
|
||||
/// No prefix-wide upload lock exists. Ordinary recursive ancestor deletion is
|
||||
/// refused instead of scanning children and racing a newly closed upload.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct OrdinaryMultipartDeleteCheck {
|
||||
source: PathBuf,
|
||||
base: PathBuf,
|
||||
root: os::PublicationRoot,
|
||||
}
|
||||
impl OrdinaryMultipartDeleteCheck {
|
||||
pub(super) fn validate(&self) -> Result<()> {
|
||||
let pinned = match os::pin_multipart_source_directory(&self.source, &self.base, &self.root) {
|
||||
Ok(pinned) => pinned,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if let Some(raw) = read_optional_in_pinned(&pinned, &self.source, STATE, &self.base, &self.root)? {
|
||||
let state: SourceState = decode(&raw)?;
|
||||
if state.abort.is_some() || state.phase == MultipartSourcePhase::Aborting {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
} else if os::multipart_source_has_native_controls_in_pinned(&self.source, &pinned)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn callback(&self) -> Arc<dyn Fn() -> io::Result<()> + Send + Sync> {
|
||||
let check = self.clone();
|
||||
Arc::new(move || check.validate().map_err(io::Error::other))
|
||||
}
|
||||
}
|
||||
impl LocalDisk {
|
||||
/// Admission is read-only but may read metadata. The queued callback keeps
|
||||
/// SourceRW ownership and the job's resource unit after waiter cancellation.
|
||||
pub(super) async fn validate_multipart_delete_admission(
|
||||
&self,
|
||||
check: OrdinaryMultipartDeleteCheck,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
self.blocking_queue
|
||||
.run_cancellable(move |_| {
|
||||
let _owner = owner;
|
||||
Ok(check.validate())
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?
|
||||
}
|
||||
|
||||
pub(super) fn multipart_delete_check(&self, path: &Path, recursive: bool) -> Result<Option<OrdinaryMultipartDeleteCheck>> {
|
||||
let Ok(relative) = path.strip_prefix(self.io_root()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut components = relative.components();
|
||||
if !components
|
||||
.next()
|
||||
.and_then(|component| component.as_os_str().to_str())
|
||||
.is_some_and(|component| component.eq_ignore_ascii_case(crate::disk::RUSTFS_META_BUCKET))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
match components.next().and_then(|component| component.as_os_str().to_str()) {
|
||||
Some(component) if component.eq_ignore_ascii_case("multipart") => {}
|
||||
None if recursive => return Err(DiskError::ConditionalDeleteConflict),
|
||||
_ => return Ok(None),
|
||||
}
|
||||
let relative = components.as_path();
|
||||
let relative = relative.to_str().ok_or(DiskError::InvalidPath)?;
|
||||
let upload = super::multipart_staging::upload_scope_root(relative, true);
|
||||
let source = self.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, upload)?;
|
||||
// Only a production hash/upload root owns a recursive upload scope.
|
||||
// Empty SHA rmdir remains nonrecursive and unchanged.
|
||||
let canonical = Path::new(upload)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.and_then(|name| name.split(['x', 'X']).next())
|
||||
.and_then(|name| Uuid::parse_str(name).ok())
|
||||
.is_some_and(|id| !id.is_nil());
|
||||
let hashed_parent = Path::new(upload)
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.len() == 64 && name.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||
if recursive && !(canonical && hashed_parent) {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(Some(OrdinaryMultipartDeleteCheck {
|
||||
source,
|
||||
base: self.io_root().to_path_buf(),
|
||||
root: self.publication_root.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_order_tests {
|
||||
include!("multipart_source_lock_order_tests.rs");
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::{Fixture, put_actual_predecessor};
|
||||
use crate::disk::{
|
||||
DiskAPI, DiskError, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
|
||||
multipart_source::{
|
||||
MultipartAbortCertificate, MultipartAbortProofKind, MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase,
|
||||
MultipartSourceResponse, PublicationIdentity, SourceGeneration, native_source_protocol, publication_identity,
|
||||
recognized_native_source_controls,
|
||||
},
|
||||
os::{self, multipart_source_syscall_failures as failures, prepared_publication_test_hooks as hooks},
|
||||
};
|
||||
use futures::FutureExt as _;
|
||||
use rustfs_filemeta::{FileInfoOpts, get_file_info};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Construct concrete native fixture futures off the caller's poll frame;
|
||||
// boxing an already-built future would retain its large stack temporary.
|
||||
#[inline(never)]
|
||||
fn heap_native<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
async fn publication_volumes(fixture: &Fixture) {
|
||||
for disk in &fixture.disks {
|
||||
for volume in ["bucket", RUSTFS_META_TMP_BUCKET] {
|
||||
match disk.make_volume(volume).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(error) => panic!("prepare actual publication volume {volume}: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn abort_round(fixture: &Fixture) -> MultipartAbortRound {
|
||||
let snapshot = heap_native(|| fixture.snapshot(0)).await;
|
||||
MultipartAbortRound {
|
||||
lineage: snapshot.lineage,
|
||||
generation: SourceGeneration {
|
||||
epoch: snapshot.generation.epoch.checked_add(1).expect("fresh native abort epoch"),
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_all(fixture: &Fixture, round: &MultipartAbortRound) -> MultipartAbortCertificate {
|
||||
let authority = fixture.disks[0].multipart_proof_verification_context();
|
||||
let mut proofs = Vec::with_capacity(4);
|
||||
for slot in 0..4 {
|
||||
let expected = heap_native(|| fixture.snapshot(slot)).await;
|
||||
let response = heap_native(|| {
|
||||
fixture.disks[slot].recover_multipart_source_native(fixture.request(MultipartSourceOperation::PrepareAbort {
|
||||
expected,
|
||||
round: round.clone(),
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.expect("each actual participant durably closes its source");
|
||||
let MultipartSourceResponse::AbortPrepared(proof) = response else {
|
||||
panic!("actual PrepareAbort response");
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&authority)
|
||||
.expect("actual native preparation proof");
|
||||
assert_eq!(proof.kind, MultipartAbortProofKind::Prepared);
|
||||
assert_eq!(proof.snapshot.physical_slot, slot);
|
||||
assert_eq!(proof.snapshot.disk_id, fixture.disk_ids[slot]);
|
||||
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::Aborting);
|
||||
assert!(proof.snapshot.head_sha256.is_none());
|
||||
proofs.push(proof);
|
||||
}
|
||||
let certificate = MultipartAbortCertificate {
|
||||
round: round.clone(),
|
||||
disk_ids: fixture.disk_ids.clone(),
|
||||
proofs,
|
||||
};
|
||||
certificate
|
||||
.verify_with_authority(&authority)
|
||||
.expect("all-N actual native closure certificate");
|
||||
for slot in 0..4 {
|
||||
let expected = heap_native(|| fixture.snapshot(slot)).await;
|
||||
let response = heap_native(|| {
|
||||
fixture.disks[slot].recover_multipart_source_native(fixture.request(MultipartSourceOperation::CommitAbort {
|
||||
expected,
|
||||
certificate: certificate.clone(),
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.expect("all actual roots durably bind the closure certificate");
|
||||
let MultipartSourceResponse::AbortCommitted(proof) = response else {
|
||||
panic!("actual CommitAbort response");
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&authority)
|
||||
.expect("actual committed closure proof");
|
||||
assert_eq!(proof.kind, MultipartAbortProofKind::Committed);
|
||||
assert_eq!(proof.certificate_sha256, Some(certificate.digest().expect("exact certificate digest")));
|
||||
assert_eq!(heap_native(|| fixture.inspect(slot)).await.abort_certificate.as_ref(), Some(&certificate));
|
||||
assert!(!fixture.head(slot).exists(), "closure never leaves a reusable canonical head");
|
||||
}
|
||||
certificate
|
||||
}
|
||||
|
||||
type TargetBytes = Option<(Vec<u8>, Vec<u8>, Vec<u8>)>;
|
||||
|
||||
fn target_bytes(fixture: &Fixture) -> Vec<TargetBytes> {
|
||||
fixture
|
||||
.disks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, disk)| {
|
||||
let target = disk.io_get_object_path("bucket", "object").expect("actual physical target");
|
||||
let raw = match std::fs::read(target.join("xl.meta")) {
|
||||
Ok(raw) => raw,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(error) => panic!("read actual target on slot {slot}: {error}"),
|
||||
};
|
||||
let info = get_file_info(
|
||||
&raw,
|
||||
"bucket",
|
||||
"object",
|
||||
"",
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
.expect("actual target metadata decodes");
|
||||
assert_eq!(info.erasure.distribution.get(slot), Some(&info.erasure.index));
|
||||
let data = target.join(info.data_dir.expect("actual native target DD").to_string());
|
||||
Some((
|
||||
raw,
|
||||
std::fs::read(data.join("part.1")).expect("actual native selected shard"),
|
||||
std::fs::read(data.join("part.1.meta")).expect("actual native selected sidecar"),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_target_publications(fixture: &Fixture, expected: &PublicationIdentity, producers: &[usize]) {
|
||||
let targets = target_bytes(fixture);
|
||||
for (slot, target) in targets.iter().enumerate() {
|
||||
if !producers.contains(&slot) {
|
||||
assert!(target.is_none(), "slot {slot} never physically published the target");
|
||||
continue;
|
||||
}
|
||||
let raw = &target.as_ref().expect("actual physical target vote").0;
|
||||
let info = get_file_info(
|
||||
raw,
|
||||
"bucket",
|
||||
"object",
|
||||
"",
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
.expect("actual target vote decodes");
|
||||
assert_eq!(publication_identity(&info).expect("actual publication tuple").as_ref(), Some(expected));
|
||||
assert_eq!(info.data_dir, Some(expected.final_data_dir));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn native_abort_waits_for_a_real_late_third_publication_before_closing_all_sources() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
publication_volumes(&fixture).await;
|
||||
let published = heap_native(|| fixture.publish_zero_on(&[0, 1])).await;
|
||||
let expected_publication = published[0].1.publication.clone();
|
||||
assert_target_publications(&fixture, &expected_publication, &[0, 1]);
|
||||
let expected = heap_native(|| fixture.snapshot(2)).await;
|
||||
let round = abort_round(&fixture).await;
|
||||
let completed = fixture.zero_info(
|
||||
2,
|
||||
expected_publication.final_data_dir,
|
||||
OffsetDateTime::from_unix_timestamp_nanos(expected_publication.modified_unix_nanos).expect("shared publication time"),
|
||||
);
|
||||
let target = fixture.disks[2]
|
||||
.io_get_object_path("bucket", "object")
|
||||
.expect("late physical target");
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let hook = hooks::install(&target.join("xl.meta"), move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let disk = fixture.disks[2].clone();
|
||||
let path = fixture.path.clone();
|
||||
let mut publication = tokio::spawn(async move {
|
||||
disk.rename_data(RUSTFS_META_MULTIPART_BUCKET, &path, completed, "bucket", "object")
|
||||
.await
|
||||
});
|
||||
let mut release = Some(release_tx);
|
||||
let mut joined = false;
|
||||
let observations = std::panic::AssertUnwindSafe(async {
|
||||
tokio::time::timeout(Duration::from_secs(10), entered_rx)
|
||||
.await
|
||||
.expect("actual third publication callback must enter")
|
||||
.expect("physical callback notification");
|
||||
let prepared_head = std::fs::read(fixture.head(2)).expect("the actual callback owns a prepared source head");
|
||||
let mut abort = heap_native(|| {
|
||||
fixture.disks[2].recover_multipart_source_native(fixture.request(MultipartSourceOperation::PrepareAbort {
|
||||
expected,
|
||||
round: round.clone(),
|
||||
}))
|
||||
});
|
||||
// The native entry's first await is the SourceRW writer, before
|
||||
// disk inspection or mutation; polling reaches the real held owner.
|
||||
assert!(futures::poll!(abort.as_mut()).is_pending());
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), abort.as_mut())
|
||||
.await
|
||||
.is_err(),
|
||||
"PrepareAbort must wait for the actual publication's SourceRW owner"
|
||||
);
|
||||
assert_eq!(std::fs::read(fixture.head(2)).expect("head while publication is blocked"), prepared_head);
|
||||
assert_target_publications(&fixture, &expected_publication, &[0, 1]);
|
||||
drop(release.take());
|
||||
let response = tokio::time::timeout(Duration::from_secs(10), &mut publication)
|
||||
.await
|
||||
.expect("actual publication callback drains")
|
||||
.expect("native publication task")
|
||||
.expect("third actual publication");
|
||||
joined = true;
|
||||
let result = response.multipart_source.expect("actual native publication result");
|
||||
result
|
||||
.verify_with_authority(&fixture.disks[2].multipart_proof_verification_context())
|
||||
.expect("actual third proof");
|
||||
assert_eq!(result.publication, expected_publication);
|
||||
assert!(
|
||||
matches!(
|
||||
tokio::time::timeout(Duration::from_secs(10), abort.as_mut())
|
||||
.await
|
||||
.expect("stale closure waiter drains"),
|
||||
Err(DiskError::ConditionalDeleteConflict)
|
||||
),
|
||||
"the pre-publication snapshot cannot authorize a post-publication closure"
|
||||
);
|
||||
drop(abort);
|
||||
let before = target_bytes(&fixture);
|
||||
assert_target_publications(&fixture, &expected_publication, &[0, 1, 2]);
|
||||
heap_native(|| close_all(&fixture, &round)).await;
|
||||
assert_eq!(target_bytes(&fixture), before, "source closure cannot Undo the three actual target votes");
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
drop(release.take());
|
||||
drop(hook);
|
||||
if !joined {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(10), &mut publication)
|
||||
.await
|
||||
.expect("release and drain actual callback");
|
||||
}
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
drop(published);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn native_abort_preserves_target_votes_when_compensation_restores_prepared_head_but_target_undo_fails() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
publication_volumes(&fixture).await;
|
||||
let predecessor = heap_native(|| put_actual_predecessor(&fixture, 2)).await;
|
||||
let published = heap_native(|| fixture.publish_zero_on(&[0, 1])).await;
|
||||
let publication = published[0].1.publication.clone();
|
||||
let completed = fixture.zero_info(
|
||||
2,
|
||||
publication.final_data_dir,
|
||||
OffsetDateTime::from_unix_timestamp_nanos(publication.modified_unix_nanos).expect("shared publication time"),
|
||||
);
|
||||
let stale = completed.clone();
|
||||
let original = std::fs::read(fixture.head(2)).expect("exact original before physical publication");
|
||||
let target = fixture.disks[2]
|
||||
.io_get_object_path("bucket", "object")
|
||||
.expect("actual target for failed compensation");
|
||||
let undo_failure = Arc::new(Mutex::new(None::<failures::FailureGuard>));
|
||||
let undo_failure_callback = undo_failure.clone();
|
||||
let sync_hook = Arc::new(Mutex::new(None::<hooks::Guard>));
|
||||
let sync_hook_callback = sync_hook.clone();
|
||||
let target_callback = target.clone();
|
||||
let hook = hooks::install(&target.join("xl.meta"), move || {
|
||||
// macOS checks Rename again after PreparedRename. Arm the Undo fault
|
||||
// only at the actual parent sync, after the initial rename completes.
|
||||
let sync_target = target_callback.clone();
|
||||
let sync_key = if os::dst_dir_fsync_group_commit_enabled() {
|
||||
target_callback
|
||||
.canonicalize()
|
||||
.expect("group commit uses the canonical physical directory")
|
||||
} else {
|
||||
target_callback.clone()
|
||||
};
|
||||
let reached = hooks::install_at(hooks::Stage::DirFsync, &sync_key, move || {
|
||||
if os::dst_dir_fsync_group_commit_enabled() {
|
||||
os::fsync_dir_recorder::set_grouped_failure(&sync_target, std::io::ErrorKind::Other);
|
||||
} else {
|
||||
os::fsync_dir_recorder::set_failure(&sync_target, std::io::ErrorKind::Other);
|
||||
}
|
||||
*undo_failure_callback.lock().expect("retain exact Undo fault") = Some(failures::fail_once(
|
||||
failures::Stage::Rename,
|
||||
&sync_target.join("xl.meta"),
|
||||
std::io::ErrorKind::Other,
|
||||
));
|
||||
});
|
||||
*sync_hook_callback.lock().expect("retain actual parent sync hook") = Some(reached);
|
||||
});
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
fixture.disks[2].rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, completed, "bucket", "object"),
|
||||
)
|
||||
.await
|
||||
.expect("actual failed compensation is bounded");
|
||||
assert!(
|
||||
response.is_err(),
|
||||
"failed durability and physical Undo cannot ACK the publication: {response:?}"
|
||||
);
|
||||
let native_error = format!("{:?}", response.as_ref().err());
|
||||
drop(response);
|
||||
drop(hook);
|
||||
drop(sync_hook);
|
||||
assert!(
|
||||
undo_failure
|
||||
.lock()
|
||||
.expect("actual Undo fault")
|
||||
.as_ref()
|
||||
.expect("target callback armed Undo fault")
|
||||
.was_triggered(),
|
||||
"the physical target Undo must actually fail: native_error={native_error}"
|
||||
);
|
||||
drop(undo_failure);
|
||||
let pending_sync_failure = if os::dst_dir_fsync_group_commit_enabled() {
|
||||
os::fsync_dir_recorder::take_grouped_failure(&target)
|
||||
} else {
|
||||
os::fsync_dir_recorder::take_failure(&target)
|
||||
};
|
||||
assert!(
|
||||
pending_sync_failure.is_none(),
|
||||
"the required real target-parent sync failure must be consumed: native_error={native_error}"
|
||||
);
|
||||
let inspection = heap_native(|| fixture.inspect(2)).await;
|
||||
let snapshot = inspection.snapshot.expect("actual prepared state after failed Undo");
|
||||
assert_eq!(snapshot.phase, MultipartSourcePhase::Prepared);
|
||||
assert!(
|
||||
snapshot.head_sha256.is_some(),
|
||||
"compensation restored the exact prepared source proxy first"
|
||||
);
|
||||
assert_eq!(inspection.original.as_deref(), Some(original.as_slice()));
|
||||
let before = target_bytes(&fixture);
|
||||
assert_ne!(before[2].as_ref().expect("failed Undo leaves actual target vote").0, predecessor);
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.head(2)).expect("actual restored prepared head"),
|
||||
before[2].as_ref().expect("the failed Undo retained its real target vote").0
|
||||
);
|
||||
assert_target_publications(&fixture, &publication, &[0, 1, 2]);
|
||||
let round = abort_round(&fixture).await;
|
||||
heap_native(|| close_all(&fixture, &round)).await;
|
||||
assert_eq!(
|
||||
target_bytes(&fixture),
|
||||
before,
|
||||
"Abort never treats the restored prepared head as a target-negative vote"
|
||||
);
|
||||
assert!(
|
||||
fixture.disks[2]
|
||||
.rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, stale, "bucket", "object")
|
||||
.await
|
||||
.is_err(),
|
||||
"a captured old publication cannot reopen the closed source"
|
||||
);
|
||||
assert_eq!(target_bytes(&fixture), before, "the old publication cannot change retained target votes");
|
||||
drop(published);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn native_abort_waits_for_an_all_active_marked_source_stage_captured_before_reserve() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
publication_volumes(&fixture).await;
|
||||
let expected = heap_native(|| fixture.snapshot(0)).await;
|
||||
let round = abort_round(&fixture).await;
|
||||
for slot in 0..4 {
|
||||
let inspection = heap_native(|| fixture.inspect(slot)).await;
|
||||
let snapshot = inspection.snapshot.expect("all-ACTIVE actual source");
|
||||
assert_eq!(snapshot.phase, MultipartSourcePhase::Active);
|
||||
assert!(snapshot.publication.is_none());
|
||||
assert!(snapshot.capability_sha256.is_none());
|
||||
let raw = std::fs::read(fixture.head(slot)).expect("actual marked initial metadata");
|
||||
let info = get_file_info(
|
||||
&raw,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&fixture.path,
|
||||
"",
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
.expect("actual marked initial FI");
|
||||
assert!(native_source_protocol(&info).expect("strict known birth marker"));
|
||||
assert!(recognized_native_source_controls(&info).expect("native classification includes the birth marker"));
|
||||
assert!(
|
||||
!fixture.head(slot).parent().unwrap().join(".mpu-source-state").exists(),
|
||||
"all-ACTIVE native Init has no private reservation state yet"
|
||||
);
|
||||
}
|
||||
let completed = fixture.zero_info(0, Uuid::new_v4(), OffsetDateTime::now_utc());
|
||||
let original = std::fs::read(fixture.head(0)).expect("original head before capture");
|
||||
let staging = heap_native(|| fixture.disks[0].prepare_multipart_commit_staging(&fixture.path, &completed, "bucket", None))
|
||||
.await
|
||||
.expect("real private stage captures the original SourceRW owner");
|
||||
assert!(
|
||||
!fixture.head(0).parent().unwrap().join(".mpu-source-state").exists(),
|
||||
"capture must precede native reserve and prepared proxy creation"
|
||||
);
|
||||
let mut abort = heap_native(|| {
|
||||
fixture.disks[0].recover_multipart_source_native(fixture.request(MultipartSourceOperation::PrepareAbort {
|
||||
expected,
|
||||
round: round.clone(),
|
||||
}))
|
||||
});
|
||||
assert!(
|
||||
futures::poll!(abort.as_mut()).is_pending(),
|
||||
"the actual pre-reserve owner must enqueue Abort"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), abort.as_mut())
|
||||
.await
|
||||
.is_err(),
|
||||
"all-ACTIVE metadata cannot allow Abort to overtake a real pre-reserve source owner"
|
||||
);
|
||||
assert_eq!(std::fs::read(fixture.head(0)).expect("head protected by the old stage"), original);
|
||||
assert_eq!(target_bytes(&fixture), vec![None; 4], "the captured stage has not published any target");
|
||||
drop(staging);
|
||||
let response = tokio::time::timeout(Duration::from_secs(10), abort.as_mut())
|
||||
.await
|
||||
.expect("PrepareAbort proceeds after the actual stage releases its SourceRW owner")
|
||||
.expect("unchanged all-ACTIVE snapshot authorizes closure");
|
||||
assert!(matches!(response, MultipartSourceResponse::AbortPrepared(_)));
|
||||
drop(abort);
|
||||
heap_native(|| close_all(&fixture, &round)).await;
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, completed, "bucket", "object")
|
||||
.await
|
||||
.is_err(),
|
||||
"the pre-reserve captured condition cannot publish after certified closure"
|
||||
);
|
||||
assert_eq!(target_bytes(&fixture), vec![None; 4], "fenced old staging must not create any target");
|
||||
}
|
||||
@@ -1,836 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::*;
|
||||
use crate::disk::multipart_source::{MultipartAbortCertificate, MultipartAbortProofKind, MultipartAbortRound};
|
||||
use crate::disk::{DataDirDeleteStatus, DeleteOptions, RUSTFS_META_TMP_DELETED_BUCKET};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Construct concrete native fixture futures in a separate frame as well as
|
||||
// pinning them on the heap; boxing an already-built future in a test poll
|
||||
// frame would still leave its large construction temporary on that stack.
|
||||
#[inline(never)]
|
||||
fn heap_native<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
fn exact_tree(root: &Path) -> BTreeMap<PathBuf, Option<Vec<u8>>> {
|
||||
let mut entries = BTreeMap::new();
|
||||
if !root.exists() {
|
||||
return entries;
|
||||
}
|
||||
let mut pending = vec![(root.to_path_buf(), 0usize)];
|
||||
while let Some((path, depth)) = pending.pop() {
|
||||
assert!(depth <= 16, "native fixture tree must remain bounded");
|
||||
let metadata = std::fs::symlink_metadata(&path).expect("fixture entry metadata");
|
||||
assert!(!metadata.file_type().is_symlink(), "native fixture contains no aliases");
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.expect("entry belongs to fixed fixture root")
|
||||
.to_path_buf();
|
||||
if metadata.is_dir() {
|
||||
entries.insert(relative, None);
|
||||
for entry in std::fs::read_dir(path).expect("fixture directory") {
|
||||
pending.push((entry.expect("fixture directory entry").path(), depth + 1));
|
||||
}
|
||||
} else {
|
||||
assert!(metadata.is_file(), "only regular native records may be compared");
|
||||
entries.insert(relative, Some(std::fs::read(path).expect("exact native record bytes")));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn source_root(fixture: &Fixture, slot: usize) -> PathBuf {
|
||||
fixture.head(slot).parent().expect("canonical source root").to_path_buf()
|
||||
}
|
||||
|
||||
fn all_sources(fixture: &Fixture) -> Vec<BTreeMap<PathBuf, Option<Vec<u8>>>> {
|
||||
(0..4).map(|slot| exact_tree(&source_root(fixture, slot))).collect()
|
||||
}
|
||||
|
||||
fn all_targets(fixture: &Fixture) -> Vec<BTreeMap<PathBuf, Option<Vec<u8>>>> {
|
||||
fixture
|
||||
.disks
|
||||
.iter()
|
||||
.map(|disk| exact_tree(&disk.root.join("bucket/object")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn prepare_actual_abort(fixture: &Fixture) -> MultipartAbortCertificate {
|
||||
let snapshot = heap_native(|| fixture.snapshot(0)).await;
|
||||
let round = MultipartAbortRound {
|
||||
lineage: snapshot.lineage,
|
||||
generation: SourceGeneration {
|
||||
epoch: snapshot.generation.epoch + 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
};
|
||||
let mut proofs = Vec::with_capacity(4);
|
||||
for slot in 0..4 {
|
||||
let response = fixture.disks[slot]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::PrepareAbort {
|
||||
expected: heap_native(|| fixture.snapshot(slot)).await,
|
||||
round: round.clone(),
|
||||
}))
|
||||
.await
|
||||
.expect("close the actual physical source under its writer");
|
||||
let MultipartSourceResponse::AbortPrepared(proof) = response else {
|
||||
panic!("PrepareAbort must return a native closure proof")
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&fixture.disks[slot].multipart_proof_verification_context())
|
||||
.expect("actual native preparation proof");
|
||||
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::Aborting);
|
||||
assert_eq!(proof.snapshot.physical_slot, slot);
|
||||
assert_eq!(proof.snapshot.disk_id, fixture.disk_ids[slot]);
|
||||
assert!(proof.snapshot.head_sha256.is_none());
|
||||
assert!(proof.snapshot.capability_sha256.is_none());
|
||||
assert!(!fixture.head(slot).exists(), "PrepareAbort retires the canonical head durably");
|
||||
proofs.push(proof);
|
||||
}
|
||||
let certificate = MultipartAbortCertificate {
|
||||
round,
|
||||
disk_ids: fixture.disk_ids.clone(),
|
||||
proofs,
|
||||
};
|
||||
certificate
|
||||
.verify_with_authority(&fixture.disks[0].multipart_proof_verification_context())
|
||||
.expect("real all-N closure certificate");
|
||||
certificate
|
||||
}
|
||||
|
||||
async fn commit_actual_abort(fixture: &Fixture, certificate: &MultipartAbortCertificate) {
|
||||
for slot in 0..4 {
|
||||
let response = fixture.disks[slot]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::CommitAbort {
|
||||
expected: heap_native(|| fixture.snapshot(slot)).await,
|
||||
certificate: certificate.clone(),
|
||||
}))
|
||||
.await
|
||||
.expect("bind the complete certificate on every actual source root");
|
||||
let MultipartSourceResponse::AbortCommitted(proof) = response else {
|
||||
panic!("CommitAbort must return native durable binding")
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&fixture.disks[slot].multipart_proof_verification_context())
|
||||
.expect("native committed binding proof");
|
||||
assert_eq!(
|
||||
proof.certificate_sha256,
|
||||
Some(certificate.digest().expect("digest of actual full-N certificate"))
|
||||
);
|
||||
assert_eq!(heap_native(|| fixture.inspect(slot)).await.abort_certificate.as_ref(), Some(certificate));
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_generic_deletes_preserve_source(fixture: &Fixture, slot: usize) {
|
||||
let before = exact_tree(&source_root(fixture, slot));
|
||||
let disk = &fixture.disks[slot];
|
||||
for immediate in [false, true] {
|
||||
assert!(
|
||||
disk.delete(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&fixture.path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
immediate,
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"generic recursive deletion cannot discard durable closure evidence"
|
||||
);
|
||||
assert_eq!(exact_tree(&source_root(fixture, slot)), before);
|
||||
}
|
||||
assert!(
|
||||
disk.delete_paths(RUSTFS_META_MULTIPART_BUCKET, std::slice::from_ref(&fixture.path))
|
||||
.await
|
||||
.is_err(),
|
||||
"raw DeletePaths cannot move a closed upload to trash"
|
||||
);
|
||||
assert_eq!(exact_tree(&source_root(fixture, slot)), before);
|
||||
let prefix = fixture.path.rsplit_once('/').expect("real object hash prefix").0;
|
||||
assert!(
|
||||
disk.delete(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
prefix,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"an ancestor prefix cannot bypass an Aborting child"
|
||||
);
|
||||
assert_eq!(exact_tree(&source_root(fixture, slot)), before);
|
||||
assert!(
|
||||
disk.delete_paths(RUSTFS_META_MULTIPART_BUCKET, &[prefix.to_string()])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(exact_tree(&source_root(fixture, slot)), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_pending_intent_is_not_a_delete_certificate_and_raw_cleanup_preserves_exact_evidence() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
let original = all_sources(&fixture);
|
||||
let certificate = heap_native(|| prepare_actual_abort(&fixture)).await;
|
||||
let before = all_sources(&fixture);
|
||||
for slot in 0..4 {
|
||||
let initial_head = original[slot].get(Path::new("xl.meta")).unwrap().as_ref().unwrap();
|
||||
for name in ["original.xl.meta", "aborted-head.xl.meta"] {
|
||||
let matching = before[slot]
|
||||
.iter()
|
||||
.filter(|(path, _)| path.file_name().is_some_and(|leaf| leaf == name))
|
||||
.map(|(_, bytes)| bytes.as_ref().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
matching,
|
||||
vec![initial_head],
|
||||
"the exact marked initial metadata must survive in both owned records"
|
||||
);
|
||||
}
|
||||
for (path, bytes) in &original[slot] {
|
||||
if path != Path::new("xl.meta") {
|
||||
assert_eq!(
|
||||
before[slot].get(path),
|
||||
Some(bytes),
|
||||
"preparation cannot rewrite original source payload files"
|
||||
);
|
||||
}
|
||||
}
|
||||
let inspection = heap_native(|| fixture.inspect(slot)).await;
|
||||
assert_eq!(inspection.abort_round.as_ref(), Some(&certificate.round));
|
||||
assert!(
|
||||
inspection.abort_certificate.is_none(),
|
||||
"N preparations alone are not a durable committed binding"
|
||||
);
|
||||
heap_native(|| assert_generic_deletes_preserve_source(&fixture, slot)).await;
|
||||
assert!(
|
||||
fixture.disks[slot]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: heap_native(|| fixture.snapshot(slot)).await,
|
||||
certificate: certificate.clone(),
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"the executor must require this root's actual committed certificate"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
before,
|
||||
"failed deletion must preserve every original, retired head and receipt byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_delete_requires_all_n_actual_slots_and_the_current_exact_committed_binding() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
let first = heap_native(|| prepare_actual_abort(&fixture)).await;
|
||||
heap_native(|| commit_actual_abort(&fixture, &first)).await;
|
||||
let old_expected = heap_native(|| fixture.snapshot(0)).await;
|
||||
let mut fewer = first.clone();
|
||||
fewer.proofs.pop();
|
||||
let mut swapped = first.clone();
|
||||
swapped.disk_ids.swap(0, 1);
|
||||
let foreign_fixture = heap_native(Fixture::new).await;
|
||||
let foreign = heap_native(|| prepare_actual_abort(&foreign_fixture)).await;
|
||||
for certificate in [fewer, swapped, foreign] {
|
||||
let before = all_sources(&fixture);
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: heap_native(|| fixture.snapshot(0)).await,
|
||||
certificate,
|
||||
}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before, "bad evidence cannot mutate any native source");
|
||||
}
|
||||
let before = all_sources(&fixture);
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: heap_native(|| fixture.snapshot(1)).await,
|
||||
certificate: first.clone(),
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"a valid neighboring slot is not this participant's expected state"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
|
||||
// A real later all-N intent must preserve the old committed decision until
|
||||
// its own complete certificate is durably committed; it is not a new binding.
|
||||
let second = heap_native(|| prepare_actual_abort(&fixture)).await;
|
||||
assert_ne!(second.round, first.round);
|
||||
let before = all_sources(&fixture);
|
||||
assert_eq!(heap_native(|| fixture.inspect(0)).await.abort_certificate.as_ref(), Some(&first));
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: heap_native(|| fixture.snapshot(0)).await,
|
||||
certificate: second.clone(),
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"a prepared N certificate cannot substitute for the current committed binding"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
heap_native(|| commit_actual_abort(&fixture, &second)).await;
|
||||
let before = all_sources(&fixture);
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: heap_native(|| fixture.snapshot(0)).await,
|
||||
certificate: first,
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"an old real certificate cannot erase a newer committed binding"
|
||||
);
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: old_expected,
|
||||
certificate: second,
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"old state CAS cannot authorize a current certificate's delete"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_rejects_old_recovery_activation_init_part_and_publication_without_reopening_a_head() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
let initial_raw = std::fs::read(fixture.head(0)).expect("exact marked initial source head");
|
||||
let mut initial = get_file_info(
|
||||
&initial_raw,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&fixture.path,
|
||||
"",
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
.expect("decode the actual marked initial source");
|
||||
initial.fresh = true;
|
||||
let recovery = heap_native(|| fixture.start_round()).await;
|
||||
let negative = heap_native(|| fixture.negative(&recovery)).await;
|
||||
let ready = heap_native(|| fixture.restore_all(&negative)).await;
|
||||
heap_native(|| activate_all_actual_heads(&fixture, &ready)).await;
|
||||
let old_incoming = fixture.zero_info(0, Uuid::new_v4(), time::OffsetDateTime::now_utc());
|
||||
let certificate = heap_native(|| prepare_actual_abort(&fixture)).await;
|
||||
heap_native(|| commit_actual_abort(&fixture, &certificate)).await;
|
||||
let before = all_sources(&fixture);
|
||||
let current = heap_native(|| fixture.snapshot(0)).await;
|
||||
let fresh_recovery = MultipartRecoveryRound {
|
||||
lineage: current.lineage.clone(),
|
||||
from_generation: current.generation,
|
||||
old_attempt: Uuid::new_v4(),
|
||||
generation: SourceGeneration {
|
||||
epoch: current.generation.epoch + 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
};
|
||||
for operation in [
|
||||
MultipartSourceOperation::Claim {
|
||||
expected: current.clone(),
|
||||
round: fresh_recovery,
|
||||
},
|
||||
MultipartSourceOperation::Restore {
|
||||
expected: current.clone(),
|
||||
certificate: negative,
|
||||
},
|
||||
MultipartSourceOperation::Activate {
|
||||
expected: current,
|
||||
certificate: ready,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(operation))
|
||||
.await
|
||||
.is_err(),
|
||||
"normal recovery methods cannot reopen an irreversibly closed source"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
}
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.write_metadata("", RUSTFS_META_MULTIPART_BUCKET, &fixture.path, initial.clone())
|
||||
.await
|
||||
.is_err(),
|
||||
"a late actual Init cannot recreate the retired head"
|
||||
);
|
||||
let new_part = format!("{}/{}/part.2", fixture.path, initial.data_dir.expect("original source data directory"));
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.write_all(RUSTFS_META_MULTIPART_BUCKET, &new_part, bytes::Bytes::from_static(b"late part"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
fixture.disks[0]
|
||||
.rename_data(RUSTFS_META_MULTIPART_BUCKET, &fixture.path, old_incoming, "bucket", "object")
|
||||
.await
|
||||
.is_err(),
|
||||
"an old captured native publication cannot become current by reading the closure generation"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
assert!(!fixture.head(0).exists());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn native_abort_delete_rechecks_the_committed_certificate_inside_the_actual_rename_callback() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
match fixture.disks[0].make_volume(RUSTFS_META_TMP_DELETED_BUCKET).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(error) => panic!("prepare the actual strict-delete rename parent: {error}"),
|
||||
}
|
||||
let certificate = heap_native(|| prepare_actual_abort(&fixture)).await;
|
||||
heap_native(|| commit_actual_abort(&fixture, &certificate)).await;
|
||||
let expected = heap_native(|| fixture.snapshot(0)).await;
|
||||
let source = fixture.disks[0]
|
||||
.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, &fixture.path)
|
||||
.unwrap();
|
||||
let before = all_sources(&fixture);
|
||||
let original_certificate = rmp_serde::to_vec_named(&certificate).expect("actual native certificate encoding");
|
||||
let records = before[0]
|
||||
.iter()
|
||||
.filter(|(_, bytes)| bytes.as_ref() == Some(&original_certificate))
|
||||
.map(|(path, _)| path)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(records.len(), 1, "the first real commit has one exact durable certificate record");
|
||||
let certificate_path = source.join(records[0]);
|
||||
let entered = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let entered_callback = entered.clone();
|
||||
let fault_path = certificate_path.clone();
|
||||
let hook = hooks::install_at(hooks::Stage::Rename, &source, move || {
|
||||
// Simulate physical record corruption AFTER handler prevalidation,
|
||||
// without manufacturing any alternate certificate or local authority.
|
||||
std::fs::write(fault_path, b"faulted committed certificate").expect("inject an actual callback-time record fault");
|
||||
entered_callback.store(true, Ordering::Release);
|
||||
});
|
||||
let outcome = fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted { expected, certificate }))
|
||||
.await;
|
||||
drop(hook);
|
||||
assert!(
|
||||
entered.load(Ordering::Acquire),
|
||||
"the failure must occur at the actual physical rename, not an earlier rejection"
|
||||
);
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"the syscall's second permit validation must reject changed committed bytes"
|
||||
);
|
||||
assert!(
|
||||
source_root(&fixture, 0).exists(),
|
||||
"a failed callback must leave the authoritative source in place"
|
||||
);
|
||||
std::fs::write(certificate_path, original_certificate).expect("remove only the injected corruption");
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
before,
|
||||
"no source evidence may be moved or deleted by the failed callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_partial_delete_retains_the_producer_certificate_and_never_changes_exact_targets() {
|
||||
let mut fixture = heap_native(Fixture::new).await;
|
||||
for slot in 0..4 {
|
||||
heap_native(|| put_actual_predecessor(&fixture, slot)).await;
|
||||
match fixture.disks[slot].make_volume(RUSTFS_META_TMP_DELETED_BUCKET).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(error) => panic!("prepare the ordinary native trash volume: {error}"),
|
||||
}
|
||||
}
|
||||
let produced = heap_native(|| fixture.publish_zero_on(&[0, 1, 2])).await;
|
||||
let publication = produced[0].1.publication.clone();
|
||||
assert_eq!(produced.len(), 3, "real target C has the ordinary write quorum only on producers 0/1/2");
|
||||
let targets = all_targets(&fixture);
|
||||
let certificate = heap_native(|| prepare_actual_abort(&fixture)).await;
|
||||
assert_eq!(all_targets(&fixture), targets, "preparation must not touch any target");
|
||||
heap_native(|| commit_actual_abort(&fixture, &certificate)).await;
|
||||
assert_eq!(all_targets(&fixture), targets, "committing source closure must not touch any target");
|
||||
for slot in [0, 1, 3] {
|
||||
let before = exact_tree(&source_root(&fixture, slot));
|
||||
let response = fixture.disks[slot]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::DeleteAborted {
|
||||
expected: heap_native(|| fixture.snapshot(slot)).await,
|
||||
certificate: certificate.clone(),
|
||||
}))
|
||||
.await
|
||||
.expect("strict certified native source retirement");
|
||||
let MultipartSourceResponse::AbortDeleted(proof) = response else {
|
||||
panic!("strict deletion returns its bound native outcome")
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&fixture.disks[slot].multipart_proof_verification_context())
|
||||
.expect("native proof of strict source retirement");
|
||||
assert_eq!(proof.kind, MultipartAbortProofKind::Deleted);
|
||||
assert!(!source_root(&fixture, slot).exists());
|
||||
let trash = fixture.disks[slot].root.join(RUSTFS_META_TMP_DELETED_BUCKET);
|
||||
let exact_retirements = std::fs::read_dir(trash)
|
||||
.expect("native trash entries after strict retirement")
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_type().expect("native trash entry type").is_dir() && exact_tree(&entry.path()) == before)
|
||||
.count();
|
||||
assert_eq!(
|
||||
exact_retirements, 1,
|
||||
"atomic retirement preserves every exact source file in one native trash root"
|
||||
);
|
||||
assert_eq!(
|
||||
all_targets(&fixture),
|
||||
targets,
|
||||
"Abort must never restore, rewrite or delete any target version"
|
||||
);
|
||||
}
|
||||
// The coordinator may lose any/all deletion responses; the actual survivor
|
||||
// must preserve and cold-rehydrate the earlier all-N committed decision.
|
||||
drop(produced);
|
||||
heap_native(|| fixture.reopen()).await;
|
||||
let survivor = heap_native(|| fixture.inspect(2)).await;
|
||||
let cold_certificate = survivor
|
||||
.abort_certificate
|
||||
.expect("the only remaining source retains the committed N certificate");
|
||||
cold_certificate
|
||||
.verify_with_authority(&fixture.disks[2].multipart_proof_verification_context())
|
||||
.expect("rehydrated native authority for the committed certificate");
|
||||
assert_eq!(
|
||||
cold_certificate.digest().expect("digest of actual full-N certificate"),
|
||||
certificate.digest().expect("digest of actual full-N certificate")
|
||||
);
|
||||
let challenge = Uuid::new_v4();
|
||||
let response = fixture.disks[2]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::ConfirmCleanupTarget {
|
||||
expected: survivor.snapshot.expect("remaining producer's exact Aborting state"),
|
||||
publication: publication.clone(),
|
||||
challenge,
|
||||
}))
|
||||
.await
|
||||
.expect("the true producer can confirm C while preserving its cleanup evidence");
|
||||
let MultipartSourceResponse::CleanupTargetConfirmed(proof) = response else {
|
||||
panic!("an existing Aborting source cannot be faked as RootMissing")
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&fixture.disks[2].multipart_proof_verification_context())
|
||||
.expect("actual surviving producer cleanup target proof");
|
||||
assert_eq!(proof.kind, MultipartAbortProofKind::CleanupTarget);
|
||||
assert_eq!(proof.challenge, challenge);
|
||||
assert_eq!(
|
||||
proof.certificate_sha256,
|
||||
Some(cold_certificate.digest().expect("digest of actual full-N certificate"))
|
||||
);
|
||||
assert_eq!(proof.publication.as_ref(), Some(&publication));
|
||||
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::Aborting);
|
||||
for slot in [0, 1] {
|
||||
let response = fixture.disks[slot]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::ConfirmTerminal {
|
||||
expected: None,
|
||||
publication: publication.clone(),
|
||||
challenge,
|
||||
}))
|
||||
.await
|
||||
.expect("a deleted producer root still has its exact own target retirement anchor");
|
||||
let MultipartSourceResponse::TerminalConfirmed(proof) = response else {
|
||||
panic!("deleted producer target confirmation")
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&fixture.disks[slot].multipart_proof_verification_context())
|
||||
.expect("fresh native terminal proof of the deleted producer");
|
||||
assert_eq!(proof.kind, MultipartProofKind::RootMissingTerminal);
|
||||
assert_eq!(proof.challenge, challenge);
|
||||
}
|
||||
assert!(
|
||||
fixture.disks[3]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::ConfirmTerminal {
|
||||
expected: None,
|
||||
publication,
|
||||
challenge,
|
||||
}))
|
||||
.await
|
||||
.is_err(),
|
||||
"the participant that never produced C cannot supply a target vote"
|
||||
);
|
||||
let survivor_source = exact_tree(&source_root(&fixture, 2));
|
||||
assert!(!survivor_source.is_empty());
|
||||
heap_native(|| assert_generic_deletes_preserve_source(&fixture, 2)).await;
|
||||
assert_eq!(all_targets(&fixture), targets);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_missing_state_with_actual_owned_records_never_becomes_legacy_or_missing() {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
let initial = std::fs::read(fixture.head(0)).expect("exact actual marked source before publication");
|
||||
let produced = heap_native(|| fixture.publish_zero_on(&[0])).await;
|
||||
produced[0]
|
||||
.1
|
||||
.verify_with_authority(&fixture.disks[0].multipart_proof_verification_context())
|
||||
.expect("the orphan input must come from an actual native publisher");
|
||||
let snapshot = heap_native(|| fixture.snapshot(0)).await;
|
||||
assert_eq!(snapshot.phase, MultipartSourcePhase::ConsumedUnsealed);
|
||||
assert!(!fixture.head(0).exists(), "the canonical source head was physically consumed");
|
||||
drop(produced);
|
||||
let source = source_root(&fixture, 0);
|
||||
let prepared = exact_tree(&source);
|
||||
assert!(
|
||||
prepared.iter().any(|(path, bytes)| {
|
||||
path.file_name().is_some_and(|name| name == "original.xl.meta") && bytes.as_ref() == Some(&initial)
|
||||
}),
|
||||
"the original private native backup must really remain"
|
||||
);
|
||||
std::fs::remove_file(source.join(".mpu-source-state")).expect("inject only a lost native state record");
|
||||
let before = all_sources(&fixture);
|
||||
let targets = all_targets(&fixture);
|
||||
let inspected = fixture.disks[0]
|
||||
.recover_multipart_source(fixture.request(MultipartSourceOperation::Inspect { lineage: None }))
|
||||
.await;
|
||||
assert!(
|
||||
matches!(inspected, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"known private native evidence without its state cannot become missing or legacy: {inspected:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
before,
|
||||
"readonly inspection cannot repair or erase the corrupt input"
|
||||
);
|
||||
let deleted = fixture.disks[0]
|
||||
.delete(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&fixture.path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(deleted, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"ordinary delete must preserve orphan authority: {deleted:?}"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
let deleted = fixture.disks[0]
|
||||
.delete_paths(RUSTFS_META_MULTIPART_BUCKET, std::slice::from_ref(&fixture.path))
|
||||
.await;
|
||||
assert!(
|
||||
matches!(deleted, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"raw DeletePaths must preserve orphan authority: {deleted:?}"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), before);
|
||||
heap_native(|| assert_generic_deletes_preserve_source(&fixture, 0)).await;
|
||||
assert_eq!(all_sources(&fixture), before, "every exact native evidence byte must remain retryable");
|
||||
assert_eq!(all_targets(&fixture), targets, "corrupt source-state handling cannot change target bytes");
|
||||
}
|
||||
|
||||
async fn close_actual_source_owned_by_staging(
|
||||
fixture: &Fixture,
|
||||
staging: &crate::disk::local::multipart_staging::MultipartCommitStaging,
|
||||
) {
|
||||
let store = heap_native(|| fixture.disks[0].multipart_store_from_staging(&fixture.path, staging))
|
||||
.await
|
||||
.expect("reuse the real staging SourceRW owner without reacquisition");
|
||||
let expected = heap_native(|| store.load())
|
||||
.await
|
||||
.expect("load the actual writer-owned source")
|
||||
.snapshot
|
||||
.expect("the selected source actually exists");
|
||||
assert_eq!(expected.phase, MultipartSourcePhase::Active);
|
||||
assert_eq!(expected.generation.epoch, 0, "the held stage has not reserved a publication");
|
||||
assert!(expected.capability_sha256.is_none());
|
||||
let round = MultipartAbortRound {
|
||||
lineage: expected.lineage.clone(),
|
||||
generation: SourceGeneration {
|
||||
epoch: expected.generation.epoch.checked_add(1).expect("fresh closure epoch"),
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
};
|
||||
let response = heap_native(|| {
|
||||
fixture.disks[0].recover_multipart_abort_owned(store, MultipartSourceOperation::PrepareAbort { expected, round })
|
||||
})
|
||||
.await
|
||||
.expect("the same actual SourceRW owner can durably close its source");
|
||||
let MultipartSourceResponse::AbortPrepared(proof) = response else {
|
||||
panic!("the owned native executor must produce an actual preparation proof");
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&fixture.disks[0].multipart_proof_verification_context())
|
||||
.expect("the closure proof comes from the actual formatted disk");
|
||||
assert_eq!(proof.kind, MultipartAbortProofKind::Prepared);
|
||||
assert_eq!(proof.snapshot.physical_slot, 0);
|
||||
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::Aborting);
|
||||
assert!(proof.snapshot.head_sha256.is_none());
|
||||
assert!(!fixture.head(0).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_public_data_dir_delete_waits_for_the_actual_staging_source_writer_and_then_denies_closure() {
|
||||
use std::time::Duration;
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
for slot in 0..4 {
|
||||
heap_native(|| put_actual_predecessor(&fixture, slot)).await;
|
||||
}
|
||||
match fixture.disks[0].make_volume(RUSTFS_META_TMP_DELETED_BUCKET).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(error) => panic!("prepare a real public data-dir deletion destination: {error}"),
|
||||
}
|
||||
let initial = heap_native(|| fixture.snapshot(0)).await;
|
||||
let data_path = format!("{}/{}", fixture.path, initial.lineage.original_data_dir);
|
||||
let incoming = fixture.zero_info(0, Uuid::new_v4(), time::OffsetDateTime::now_utc());
|
||||
let staged = heap_native(|| fixture.disks[0].prepare_multipart_commit_staging(&fixture.path, &incoming, "bucket", None))
|
||||
.await
|
||||
.expect("hold the real pre-reservation SourceRW writer and selected physical part");
|
||||
assert!(!source_root(&fixture, 0).join(".mpu-source-state").exists());
|
||||
let active_sources = all_sources(&fixture);
|
||||
let active_targets = all_targets(&fixture);
|
||||
let mut deleted = fixture.disks[0].delete_data_dir(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&data_path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), deleted.as_mut())
|
||||
.await
|
||||
.is_err(),
|
||||
"public DeleteDataDir must acquire SourceRW before it can remove this actual selected directory"
|
||||
);
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
active_sources,
|
||||
"the queued public API cannot outrun the held stage"
|
||||
);
|
||||
assert_eq!(all_targets(&fixture), active_targets);
|
||||
heap_native(|| close_actual_source_owned_by_staging(&fixture, &staged)).await;
|
||||
let closed_sources = all_sources(&fixture);
|
||||
let closed_targets = all_targets(&fixture);
|
||||
drop(staged);
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(10), deleted.as_mut())
|
||||
.await
|
||||
.expect("public deletion settles after the physical staging owner drains");
|
||||
assert!(
|
||||
matches!(outcome, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"new Aborting state must deny the queued public API: {outcome:?}"
|
||||
);
|
||||
assert_eq!(all_sources(&fixture), closed_sources, "closure evidence and selected bytes remain exact");
|
||||
assert_eq!(all_targets(&fixture), closed_targets);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_deferred_snapshot_release_and_settlement_reacquire_source_rw_before_cleanup() {
|
||||
use std::time::Duration;
|
||||
for settle_while_reader_retained in [false, true] {
|
||||
let fixture = heap_native(Fixture::new).await;
|
||||
for slot in 0..4 {
|
||||
heap_native(|| put_actual_predecessor(&fixture, slot)).await;
|
||||
}
|
||||
match fixture.disks[0].make_volume(RUSTFS_META_TMP_DELETED_BUCKET).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(error) => panic!("prepare actual deferred cleanup destination: {error}"),
|
||||
}
|
||||
let initial = heap_native(|| fixture.snapshot(0)).await;
|
||||
let data_path = format!("{}/{}", fixture.path, initial.lineage.original_data_dir);
|
||||
let incoming = fixture.zero_info(0, Uuid::new_v4(), time::OffsetDateTime::now_utc());
|
||||
let token = fixture.disks[0]
|
||||
.acquire_snapshot_lease(RUSTFS_META_MULTIPART_BUCKET, &data_path)
|
||||
.await
|
||||
.expect("retain an actual snapshot of the selected source directory");
|
||||
assert_eq!(
|
||||
fixture.disks[0]
|
||||
.delete_data_dir(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&data_path,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the real snapshot reader defers deletion"),
|
||||
DataDirDeleteStatus::Deferred
|
||||
);
|
||||
let staged = heap_native(|| fixture.disks[0].prepare_multipart_commit_staging(&fixture.path, &incoming, "bucket", None))
|
||||
.await
|
||||
.expect("hold the actual source writer after deferral but before physical reclaim");
|
||||
assert!(!source_root(&fixture, 0).join(".mpu-source-state").exists());
|
||||
let active_sources = all_sources(&fixture);
|
||||
let active_targets = all_targets(&fixture);
|
||||
let mut reclaim = heap_native(|| async {
|
||||
if settle_while_reader_retained {
|
||||
heap_native(|| fixture.disks[0].settle_pending_snapshot_deletes(RUSTFS_META_MULTIPART_BUCKET)).await;
|
||||
Ok(())
|
||||
} else {
|
||||
fixture.disks[0]
|
||||
.release_snapshot_lease(RUSTFS_META_MULTIPART_BUCKET, &data_path, token)
|
||||
.await
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), reclaim.as_mut())
|
||||
.await
|
||||
.is_err(),
|
||||
"both last-reader release and volume settlement must reacquire the actual SourceRW writer"
|
||||
);
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
active_sources,
|
||||
"deferred reclamation cannot bypass the physical writer"
|
||||
);
|
||||
assert_eq!(all_targets(&fixture), active_targets);
|
||||
heap_native(|| close_actual_source_owned_by_staging(&fixture, &staged)).await;
|
||||
let closed_sources = all_sources(&fixture);
|
||||
let closed_targets = all_targets(&fixture);
|
||||
drop(staged);
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(10), reclaim.as_mut())
|
||||
.await
|
||||
.expect("deferred reclaim settles after the staging owner drains");
|
||||
drop(reclaim);
|
||||
if settle_while_reader_retained {
|
||||
assert_eq!(outcome, Ok(()), "the settlement API reports errors by retaining pending cleanup");
|
||||
let release = fixture.disks[0]
|
||||
.release_snapshot_lease(RUSTFS_META_MULTIPART_BUCKET, &data_path, token)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(release, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"retained deferred cleanup still rejects the actual Aborting source: {release:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(outcome, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"last-reader cleanup must reject the new closure: {outcome:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
closed_sources,
|
||||
"last-reader cleanup preserves all closure evidence bytes"
|
||||
);
|
||||
assert_eq!(all_targets(&fixture), closed_targets);
|
||||
heap_native(|| fixture.disks[0].settle_pending_snapshot_deletes(RUSTFS_META_MULTIPART_BUCKET)).await;
|
||||
assert_eq!(
|
||||
all_sources(&fixture),
|
||||
closed_sources,
|
||||
"later volume settlement also preserves the actual closed source"
|
||||
);
|
||||
assert_eq!(all_targets(&fixture), closed_targets);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::*;
|
||||
use crate::disk::{DeleteOptions, endpoint::Endpoint};
|
||||
use futures::FutureExt;
|
||||
|
||||
#[inline(never)]
|
||||
fn heap<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_mpi_residue_under_metadata_ownership_never_waits_for_source_rw_or_deletes_bytes() {
|
||||
let temporary = tempfile::tempdir().expect("legacy residue disk");
|
||||
let endpoint = Endpoint::try_from(temporary.path().to_string_lossy().as_ref()).expect("local endpoint");
|
||||
let disk = heap(|| LocalDisk::new(&endpoint, false)).await.expect("local disk");
|
||||
let path = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
|
||||
let source = disk
|
||||
.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, &path)
|
||||
.expect("pinned source path");
|
||||
let data_dir = Uuid::new_v4();
|
||||
let transaction = Uuid::new_v4();
|
||||
let data = source.join(data_dir.to_string());
|
||||
let receipt = source.join(transaction.to_string());
|
||||
std::fs::create_dir_all(&data).expect("legacy payload directory");
|
||||
std::fs::create_dir_all(&receipt).expect("legacy transaction directory");
|
||||
let payload = data.join("part.1");
|
||||
std::fs::write(&payload, b"retained legacy payload").expect("legacy payload");
|
||||
let committed = data.join(format!("{}{transaction}", super::super::DELETE_DATA_DIR_MARKER_PREFIX));
|
||||
let quorum = receipt.join(super::super::DELETE_QUORUM_COMMITTED_FILE);
|
||||
std::fs::write(&committed, []).expect("legacy data receipt");
|
||||
std::fs::write(&quorum, []).expect("legacy quorum receipt");
|
||||
let transactions = Box::pin(disk.committed_delete_transactions(RUSTFS_META_MULTIPART_BUCKET, &path))
|
||||
.await
|
||||
.expect("actual metadata-less legacy inventory");
|
||||
assert!(transactions.contains(&transaction), "fixture must enter the actual residue branch");
|
||||
|
||||
// These are the two real lock domains in the reported inversion. A None
|
||||
// token does not grant permission to wait for the already-held SourceRW.
|
||||
let source_writer = disk
|
||||
.multipart_source_owner(RUSTFS_META_MULTIPART_BUCKET, &path, false, true, None)
|
||||
.await
|
||||
.expect("independent source writer");
|
||||
let logical = disk
|
||||
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &path)
|
||||
.expect("configured metadata key");
|
||||
let metadata: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&logical, None).await);
|
||||
let options = DeleteOptions {
|
||||
recursive: true,
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
};
|
||||
let transaction_path = format!("{path}/{transaction}");
|
||||
let data_path = format!("{path}/{data_dir}");
|
||||
let result = heap(|| {
|
||||
disk.delete_data_dir_with_namespace_owner(RUSTFS_META_MULTIPART_BUCKET, &data_path, options.clone(), metadata.clone())
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("metadata-owned data-dir cleanup cannot wait for SourceRW");
|
||||
assert_eq!(
|
||||
result.expect_err("public or typed source entry is required"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
|
||||
let result = heap(|| {
|
||||
disk.delete_unleased_with_namespace_owner(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&transaction_path,
|
||||
&options,
|
||||
metadata.clone(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("residue refusal must not await SourceRW");
|
||||
assert_eq!(result.expect_err("unowned MPI residue is retained"), DiskError::ConditionalDeleteConflict);
|
||||
let result =
|
||||
heap(|| disk.finish_version_delete(RUSTFS_META_MULTIPART_BUCKET, &path, transaction, false, metadata.clone(), None))
|
||||
.now_or_never()
|
||||
.expect("version cleanup refuses before any lock wait");
|
||||
assert_eq!(
|
||||
result.expect_err("typed source ownership is required"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
let result = heap(|| {
|
||||
disk.finish_delete_transaction_with_namespace_owner(
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&path,
|
||||
transaction,
|
||||
metadata.clone(),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("transaction cleanup refuses before any lock wait");
|
||||
assert_eq!(
|
||||
result.expect_err("typed source ownership is required"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
let finished = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
Box::pin(disk.finish_internal_delete_residue(RUSTFS_META_MULTIPART_BUCKET, &path, metadata)),
|
||||
)
|
||||
.await
|
||||
.expect("metadata-owned residue must not deadlock");
|
||||
assert_eq!(finished, 0);
|
||||
assert_eq!(std::fs::read(&payload).expect("retained payload"), b"retained legacy payload");
|
||||
assert_eq!(std::fs::read(&committed).expect("retained committed receipt"), b"");
|
||||
assert_eq!(std::fs::read(&quorum).expect("retained quorum receipt"), b"");
|
||||
assert!(!source.join(STORAGE_FORMAT_FILE).exists());
|
||||
drop(source_writer);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,202 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Observation {
|
||||
rebuilds: usize,
|
||||
normalizations: usize,
|
||||
before_recheck: Option<Box<dyn FnOnce()>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static OBSERVATION: RefCell<Option<Observation>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Observe;
|
||||
|
||||
impl Observe {
|
||||
fn new(before_recheck: Option<Box<dyn FnOnce()>>) -> Self {
|
||||
OBSERVATION.with(|state| {
|
||||
assert!(state.borrow().is_none(), "read-path observations must not nest");
|
||||
*state.borrow_mut() = Some(Observation {
|
||||
rebuilds: 0,
|
||||
normalizations: 0,
|
||||
before_recheck,
|
||||
});
|
||||
});
|
||||
Self
|
||||
}
|
||||
|
||||
fn normalizations(&self) -> usize {
|
||||
OBSERVATION.with(|state| state.borrow().as_ref().expect("active observation").normalizations)
|
||||
}
|
||||
|
||||
fn rebuilds(&self) -> usize {
|
||||
OBSERVATION.with(|state| state.borrow().as_ref().expect("active observation").rebuilds)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Observe {
|
||||
fn drop(&mut self) {
|
||||
OBSERVATION.with(|state| *state.borrow_mut() = None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_bucket_rebuild() {
|
||||
OBSERVATION.with(|state| {
|
||||
if let Some(observation) = state.borrow_mut().as_mut() {
|
||||
observation.rebuilds += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn record_normalization() {
|
||||
OBSERVATION.with(|state| {
|
||||
if let Some(observation) = state.borrow_mut().as_mut() {
|
||||
observation.normalizations += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn before_bucket_recheck() {
|
||||
let hook = OBSERVATION.with(|state| state.borrow_mut().as_mut().and_then(|value| value.before_recheck.take()));
|
||||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_disk(root: &Path) -> LocalDisk {
|
||||
let endpoint = Endpoint::try_from(root.to_string_lossy().as_ref()).expect("local endpoint");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
disk
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_reuse_bucket_construction() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
std::fs::create_dir_all(root.path().join("bucket/prefix/object")).expect("object directories");
|
||||
for key in ["prefix/object/xl.meta", "", "prefix/../object/xl.meta"] {
|
||||
let expected = build_local_disk_object_path(disk.io_root(), "bucket", key);
|
||||
let observation = Observe::new(None);
|
||||
let actual = disk.io_get_checked_read_paths("bucket", key).expect("checked read paths");
|
||||
assert_eq!(actual, expected, "returned paths must retain their existing spelling");
|
||||
assert_eq!(observation.rebuilds(), 0, "the validated tuple already owns its bucket path");
|
||||
// path_join_buf already resolves dot components in object keys.
|
||||
let expected_normalizations = if cfg!(windows) { 3 } else { 0 };
|
||||
assert_eq!(observation.normalizations(), expected_normalizations);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
assert_eq!(
|
||||
disk.io_get_bucket_and_object_open_path("bucket", key)
|
||||
.expect("read-version paths"),
|
||||
expected
|
||||
);
|
||||
assert_eq!(observation.rebuilds(), 0, "read_version shares the same checked path core");
|
||||
assert_eq!(observation.normalizations(), 2 * expected_normalizations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_preserve_containment_errors() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
for (bucket, key) in [("../outside", "object"), ("bucket", "../outside"), ("bucket", "/outside")] {
|
||||
let old = disk
|
||||
.io_get_object_path(bucket, key)
|
||||
.and_then(|path| disk.io_get_bucket_path(bucket).map(|volume| (volume, path)));
|
||||
let new = disk.io_get_checked_read_paths(bucket, key);
|
||||
assert_eq!(new, old, "path and error behavior for {bucket}/{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_metadata_bytes_and_absence_errors() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
let object = root.path().join("bucket/prefix/object");
|
||||
std::fs::create_dir_all(&object).expect("object");
|
||||
let metadata = b"opaque metadata bytes passed through the read API";
|
||||
std::fs::write(object.join(STORAGE_FORMAT_FILE), metadata).expect("metadata");
|
||||
{
|
||||
let observation = Observe::new(None);
|
||||
let actual = disk
|
||||
.read_metadata("bucket", "prefix/object/xl.meta")
|
||||
.await
|
||||
.expect("read metadata");
|
||||
assert_eq!(actual.as_ref(), metadata);
|
||||
assert_eq!(observation.rebuilds(), 0, "scanner metadata reads use the shared path core");
|
||||
assert_eq!(observation.normalizations(), if cfg!(windows) { 3 } else { 0 });
|
||||
}
|
||||
assert!(matches!(
|
||||
disk.read_metadata("bucket", "missing/xl.meta").await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
assert!(matches!(
|
||||
disk.read_metadata("missing", "object/xl.meta").await,
|
||||
Err(DiskError::VolumeNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_recheck_bucket_after_object_validation() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let outside = tempfile::tempdir().expect("outside root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
std::fs::create_dir_all(root.path().join("bucket/object")).expect("object");
|
||||
std::fs::write(root.path().join("bucket/object/xl.meta"), b"inside").expect("inside metadata");
|
||||
let bucket = root.path().join("bucket");
|
||||
let saved = root.path().join("saved");
|
||||
let target = outside.path().to_path_buf();
|
||||
let _observation = Observe::new(Some(Box::new(move || {
|
||||
std::fs::rename(&bucket, saved).expect("replace bucket after full-path validation");
|
||||
std::os::unix::fs::symlink(target, bucket).expect("bucket replacement symlink");
|
||||
})));
|
||||
assert!(
|
||||
matches!(disk.io_get_checked_read_paths("bucket", "object/xl.meta"), Err(DiskError::InvalidPath)),
|
||||
"the second bucket check must remain effective before queuing native IO"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_path_normalization_matches_component_normalizer() {
|
||||
for value in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"./a",
|
||||
"a/./b",
|
||||
"a//b/",
|
||||
"a/../b",
|
||||
"../../a",
|
||||
"/",
|
||||
"//a///b/",
|
||||
"/a/../b",
|
||||
"/../../a",
|
||||
r"C:\a\b",
|
||||
r"C:\a\..\b",
|
||||
r"\\server\share\a",
|
||||
r"\\?\C:\a\.\b",
|
||||
] {
|
||||
let path = Path::new(value);
|
||||
let expected = normalize_path_components(path);
|
||||
let observation = Observe::new(None);
|
||||
let actual = normalize_path_cow(path);
|
||||
assert_eq!(
|
||||
actual.components().collect::<Vec<_>>(),
|
||||
expected.components().collect::<Vec<_>>(),
|
||||
"{value}"
|
||||
);
|
||||
let needs_buffer = cfg!(windows)
|
||||
|| path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir | std::path::Component::CurDir));
|
||||
assert_eq!(matches!(actual, std::borrow::Cow::Owned(_)), needs_buffer, "{value}");
|
||||
assert_eq!(observation.normalizations(), usize::from(needs_buffer), "{value}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user