mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 22:37:13 +00:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e337bad97 | |||
| 12a23859a1 | |||
| 081d3b1469 | |||
| 56a80f7b62 | |||
| 0a366d967b | |||
| fea5a584c3 | |||
| e28ac2c9d4 | |||
| 4a96f06983 | |||
| 927b3969bf | |||
| 31c9d85645 | |||
| fab4bb0586 | |||
| b7b26a6a49 | |||
| 197ab19f0c | |||
| a5e1e9312d | |||
| 62d148b045 | |||
| 8e6dc42772 | |||
| af10369f34 | |||
| c610d11e8f | |||
| c721416e4c | |||
| 7b29e9d65f | |||
| 85231b71e6 | |||
| ceddcdd878 | |||
| 7728eeab1a | |||
| 13dc635d51 | |||
| ead77d799f | |||
| 67db0f241f | |||
| a94f625bc8 | |||
| b7835f0f9a | |||
| 299609bb83 | |||
| 1800b90d06 | |||
| 2ccc8d8647 | |||
| 124cbfc00a | |||
| 3aa7964c58 | |||
| 42d376c6b7 | |||
| 017b85a5bf | |||
| 55023ecd86 | |||
| 1cd62c4249 | |||
| b1f7fb2769 | |||
| c20448dfce | |||
| 887716ca61 | |||
| dae95d7afe | |||
| b0f60ed8d9 | |||
| 78c803e1ef | |||
| a2d6aa5a7f | |||
| 553be4b4c6 | |||
| 96858cd1f1 | |||
| 3d357cec44 | |||
| 9bce929ae5 | |||
| a74ddfab3c | |||
| 1676878676 | |||
| b3031bed59 | |||
| c86faf193a | |||
| e30012c4ef | |||
| 2babf7624d | |||
| 757b751482 | |||
| 09fb813a00 | |||
| 738e5fa14a | |||
| 8380466f3a | |||
| 170ec8d983 | |||
| bd0671ba78 | |||
| f954164048 | |||
| 6afdec9fe1 | |||
| 87bd36b313 | |||
| a4c02d5ac3 | |||
| 62ff96774f | |||
| 85c706cac0 | |||
| 7951bb6298 | |||
| a3230599a8 | |||
| 1c7fc818dd | |||
| 5475bd06bc | |||
| a3ba311ab0 | |||
| e517d35fa2 | |||
| 1b7a197267 | |||
| 3b416b76cd | |||
| bebee3d5d0 | |||
| 1aa9be87d1 | |||
| e224eb48e5 | |||
| 16cf19817e | |||
| 32fd1e704a | |||
| ac197ea655 | |||
| e18ed73942 | |||
| 3c131f2b2e | |||
| c5f4b5a4f7 | |||
| 03fb3d78a0 | |||
| b8126e50f7 | |||
| 59fb72aa82 | |||
| 9de01c9170 | |||
| 2ec26fa4b9 | |||
| f4507178a3 | |||
| 9b4cc40abd | |||
| b14ff4eeb1 | |||
| ae9b5cbabf | |||
| 6c78b0d996 | |||
| 0066e34764 | |||
| fbddcc2965 | |||
| b1793b55a0 | |||
| 835bd3f52d | |||
| cb32d55878 | |||
| 864e58592c | |||
| 3016209330 | |||
| 64e0eeba91 | |||
| 35fa7488a5 | |||
| 1b77846f8e | |||
| b0c861841e |
@@ -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](references/post-release-updates.md) only after complete publication. |
|
||||
| 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. |
|
||||
|
||||
## 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 candidate branches or preview tags requires an explicit decision.
|
||||
- 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.
|
||||
|
||||
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,3 +48,10 @@ 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=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
sha256-darwin=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
sha256-linux=9bd4b5ed4d135c6f61e0d144ddeb6b897fd5a6037b51f31088693cf1b23e0718
|
||||
sha256-darwin=9bd4b5ed4d135c6f61e0d144ddeb6b897fd5a6037b51f31088693cf1b23e0718
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
|
||||
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
|
||||
sha256-darwin=da00472db3d8181ed413b8e9919f43e86fe644ccee444d92035ce189a33ab037
|
||||
sha256-linux=42e95c9480e5bfa0d717be6652c6500b43e3a337cb63907869104bb155b5bd91
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
|
||||
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
|
||||
|
||||
@@ -241,11 +241,6 @@
|
||||
"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",
|
||||
|
||||
@@ -63,6 +63,8 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_validation_runner.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_retain_validation_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
|
||||
|
||||
@@ -19,9 +19,11 @@ 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/**"
|
||||
@@ -128,6 +130,7 @@ 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: |
|
||||
@@ -185,6 +188,28 @@ 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
|
||||
|
||||
@@ -20,13 +20,91 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_run_id:
|
||||
description: Successful nightly build run on main
|
||||
description: Successful nightly or validation package run; empty for package-only build
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ''
|
||||
build_run_attempt:
|
||||
description: Exact successful build attempt
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ''
|
||||
validation_package_sha:
|
||||
description: Exact source SHA for an isolated package-only build
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_case:
|
||||
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
i05_diagnostic_branch:
|
||||
description: I05 diagnostic-only branch; queued-successor requires the exact native evidence bundle
|
||||
type: choice
|
||||
options: [timeout, queued-successor]
|
||||
required: false
|
||||
default: timeout
|
||||
auto_testing_sha:
|
||||
description: Exact auto-testing commit for a validation case
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_retain_evidence_run_id:
|
||||
description: Upload existing persistent evidence from one interrupted validation run without fleet access
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_retain_evidence_run_attempt:
|
||||
description: Exact interrupted run attempt to retain
|
||||
type: string
|
||||
required: false
|
||||
default: '1'
|
||||
validation_runner_inspect_only:
|
||||
description: Inspect GitHub runner registration and queued jobs on a hosted runner without fleet access
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_tools_only:
|
||||
description: Build candidate-bound validation tools without running a fleet case
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_native_tools:
|
||||
description: Include exact-source native regressions in a tools-only build
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_shared_tools:
|
||||
description: Include the controlled C10 shared-reference encoder in a tools-only build
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_manager_tools:
|
||||
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_cleanup_run_id:
|
||||
description: Failed validation run whose supported retained environment needs evidence-based finalization
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_cleanup_retained_evidence:
|
||||
description: Recover from verified persistent runner-local evidence when the failed run uploaded no artifact
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_tools_run_id:
|
||||
description: Successful tool build run; required by Heal physical metadata checks
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_tools_run_attempt:
|
||||
description: Exact tool build attempt
|
||||
type: string
|
||||
required: false
|
||||
default: '1'
|
||||
workflow_run:
|
||||
workflows: ["Nightly GNU Build"]
|
||||
types: [completed]
|
||||
@@ -36,12 +114,103 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-functional-chain-runs
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only && 'rustfs-functional-validation-runner-inspection' || github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validation-runner-inspection:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Inspect shared runner registration and queue without fleet access
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
RUNNER_INSPECTION_INPUTS: ${{ toJSON(inputs) }}
|
||||
run: >-
|
||||
python3 -B scripts/inspect_validation_runner.py
|
||||
--output "${RUNNER_TEMP}/validation-runner-inspection/report.json"
|
||||
- name: Upload read-only runner inspection including incomplete reports
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-runner-inspection-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-runner-inspection/report.json
|
||||
if-no-files-found: error
|
||||
validation-evidence-retention:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id != '' }}
|
||||
# The original interrupted evidence exists only on this long-lived runner.
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests-v2
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify existing interrupted evidence without changing it
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVIDENCE_RETAIN_INPUTS: ${{ toJSON(inputs) }}
|
||||
run: >-
|
||||
python3 -B scripts/retain_validation_evidence.py
|
||||
--report "${RUNNER_TEMP}/validation-evidence-retention/report.json"
|
||||
- name: Upload admitted original evidence under collection provenance
|
||||
if: ${{ env.VALIDATION_RETAIN_ROOT != '' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-retained-${{ env.VALIDATION_RETAIN_SOURCE_RUN }}-${{ env.VALIDATION_RETAIN_SOURCE_ATTEMPT }}-by-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.VALIDATION_RETAIN_ROOT }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
- name: Upload retention receipt including incomplete reports
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-evidence-retention-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-evidence-retention/report.json
|
||||
if-no-files-found: error
|
||||
validation-package-request:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Validate exclusive package-only request
|
||||
env:
|
||||
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
value = json.loads(os.environ["PACKAGE_INPUTS"])
|
||||
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
|
||||
raise ValueError("package build requires an exact source SHA")
|
||||
exclusive = ("validation_retain_evidence_run_id", "validation_runner_inspect_only", "build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
|
||||
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
|
||||
"validation_manager_tools", "validation_cleanup_run_id", "validation_cleanup_retained_evidence", "validation_tools_run_id")
|
||||
if any(value.get(name) for name in exclusive):
|
||||
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
|
||||
PY
|
||||
|
||||
validation-package:
|
||||
needs: validation-package-request
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
|
||||
uses: ./.github/workflows/rustfs-validation-package.yml
|
||||
with:
|
||||
source_sha: ${{ inputs.validation_package_sha }}
|
||||
secrets:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
|
||||
prepare:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && !inputs.validation_cleanup_retained_evidence && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
@@ -89,6 +258,395 @@ jobs:
|
||||
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
validation:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '' || inputs.validation_cleanup_retained_evidence) }}
|
||||
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
|
||||
timeout-minutes: 90
|
||||
outputs:
|
||||
source_sha: ${{ steps.candidate.outputs.source_sha }}
|
||||
concurrency:
|
||||
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
|
||||
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
|
||||
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
|
||||
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
|
||||
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
|
||||
steps:
|
||||
- name: Checkout candidate resolver
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize validation evidence
|
||||
env:
|
||||
VALIDATION_TOOLS_ONLY: ${{ inputs.validation_tools_only }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
python3 -B scripts/initialize_validation_evidence.py
|
||||
|
||||
- name: Resolve immutable candidate
|
||||
id: candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
BUILD_RUN_ID: ${{ inputs.build_run_id }}
|
||||
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
|
||||
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
|
||||
SELECTED_CASE: ${{ inputs.validation_case }}
|
||||
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
|
||||
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
|
||||
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
|
||||
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
|
||||
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from scripts.resolve_functional_candidate import resolve
|
||||
|
||||
revision = os.environ["AUTO_TESTING_SHA"]
|
||||
cleanup = os.environ["CLEANUP_RUN_ID"]
|
||||
if os.environ["CLEANUP_RETAINED_EVIDENCE"] == "true" and not cleanup:
|
||||
raise ValueError("persistent recovery input requires one exact cleanup run")
|
||||
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
|
||||
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
|
||||
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
|
||||
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
|
||||
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
|
||||
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
|
||||
diagnostic_branch = os.environ["I05_DIAGNOSTIC_BRANCH"] or "timeout"
|
||||
if diagnostic_branch not in {"timeout", "queued-successor"}:
|
||||
raise ValueError("I05 diagnostic branch is not registered")
|
||||
if diagnostic_branch == "queued-successor" and os.environ["SELECTED_CASE"] != "heal.lock-timeout-and-caller-abort":
|
||||
raise ValueError("queued-successor is available only for the I05 diagnostic case")
|
||||
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
|
||||
raise ValueError("validation requires an exact auto-testing commit SHA")
|
||||
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
|
||||
manifest = candidate["manifest"]
|
||||
root = Path(os.environ["VALIDATION_ROOT"])
|
||||
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
|
||||
(root / "candidate.json").write_text(json.dumps({
|
||||
"package_url": manifest["package_url"],
|
||||
"package_sha256": manifest["package_sha256"],
|
||||
"source_sha": manifest["source_sha"],
|
||||
}, sort_keys=True) + "\n")
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||||
output.write("source_sha=" + manifest["source_sha"] + "\n")
|
||||
PY
|
||||
|
||||
- name: Checkout exact auto-testing revision
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare validation Python runtime
|
||||
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install validation Python client
|
||||
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
validation_python="${RUNNER_TEMP}/rustfs-validation-python-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
test ! -e "${validation_python}"
|
||||
python3 -m venv "${validation_python}"
|
||||
"${validation_python}/bin/python" -m pip install \
|
||||
-r auto-testing/scripts/validation_ilm/requirements.txt
|
||||
printf '%s\n' "${validation_python}/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Validate Heal tool build identity
|
||||
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
|
||||
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
|
||||
> "${VALIDATION_ROOT}/tool-run.json"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
|
||||
if (value.get("conclusion") != "success" or value.get("status") != "completed"
|
||||
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
|
||||
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
|
||||
raise ValueError("Heal tool run is not an exact successful validation build attempt")
|
||||
PY
|
||||
|
||||
- name: Download pinned Heal metadata inspector
|
||||
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify Heal metadata inspector
|
||||
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
python3 -B auto-testing/scripts/validation_heal/inspector.py \
|
||||
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
> "${VALIDATION_ROOT}/tool-manifest.json"
|
||||
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Select exact-source race native evidence
|
||||
id: race_native
|
||||
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' || inputs.validation_case == 'heal.race-multipart-complete-abort' || inputs.validation_case == 'heal.admin-nolock-safety' || inputs.validation_case == 'heal.race-copy-and-metadata-update' }}
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
|
||||
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
required=false
|
||||
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
|
||||
status=0
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
|
||||
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
|
||||
if [ "${status}" -eq 0 ]; then
|
||||
required=true
|
||||
elif [ "${status}" -ne 2 ]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
fi
|
||||
reviewed=false
|
||||
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
|
||||
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
|
||||
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
|
||||
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
|
||||
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete | \
|
||||
757b751482bb970bc321e92059690a7698554358:heal.admin-nolock-safety | \
|
||||
757b751482bb970bc321e92059690a7698554358:heal.race-multipart-complete-abort | \
|
||||
757b751482bb970bc321e92059690a7698554358:heal.race-copy-and-metadata-update) reviewed=true ;;
|
||||
esac
|
||||
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
|
||||
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Download pinned native storage evidence
|
||||
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' || (inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch == 'queued-successor') }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify C07 native source and exact test evidence
|
||||
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/authority.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
> "${VALIDATION_ROOT}/native-authority-manifest.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify C11 physical storage and exact final-rename barrier
|
||||
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${{ inputs.auto_testing_sha }}" \
|
||||
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify exact-source race native and runtime evidence
|
||||
if: ${{ steps.race_native.outputs.required == 'true' }}
|
||||
env:
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
|
||||
> "${VALIDATION_ROOT}/native-race-manifest.json"
|
||||
runtime_driver=race.py
|
||||
if [ "${VALIDATION_CASE_ID}" = 'heal.admin-nolock-safety' ]; then
|
||||
runtime_driver=nolock.py
|
||||
fi
|
||||
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
|
||||
python3 -B "auto-testing/scripts/validation_heal/${runtime_driver}" preflight \
|
||||
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
|
||||
--case-id "${VALIDATION_CASE_ID}" \
|
||||
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify I05 queued-successor native prerequisite
|
||||
if: ${{ inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch == 'queued-successor' }}
|
||||
env:
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" --case-id heal.lock-timeout-and-caller-abort \
|
||||
> "${VALIDATION_ROOT}/native-i05-prerequisite.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download pinned manager persistence and incarnation evidence
|
||||
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify exact manager native prerequisites before fleet mutation
|
||||
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
|
||||
env:
|
||||
VALIDATION_CASE: ${{ inputs.validation_case }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
|
||||
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
|
||||
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download pinned C10 shared-reference encoder
|
||||
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify C10 exact-source codec and original tests
|
||||
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
|
||||
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
|
||||
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Run selected canonical validation case
|
||||
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
|
||||
env:
|
||||
VALIDATION_CASE: ${{ inputs.validation_case }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
./auto-testing/scripts/run_validation_case.sh \
|
||||
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
|
||||
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
|
||||
|
||||
- name: Inspect current Heal fleet resources without mutation
|
||||
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
|
||||
env:
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
python3 -B auto-testing/scripts/validation_heal/environment.py \
|
||||
--candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
|
||||
|
||||
- name: Audit and finalize retained validation environment
|
||||
if: ${{ inputs.validation_cleanup_run_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
|
||||
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
|
||||
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
|
||||
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
|
||||
PY
|
||||
if [ "${CLEANUP_RETAINED_EVIDENCE}" = true ]; then
|
||||
python3 -B scripts/prepare_retained_validation_recovery.py \
|
||||
--report "${VALIDATION_ROOT}/retained-recovery-input.json"
|
||||
else
|
||||
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
|
||||
fi
|
||||
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
|
||||
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
|
||||
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
|
||||
|
||||
- name: Show validation report
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
|
||||
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
- name: Upload validation evidence
|
||||
if: ${{ always() && env.VALIDATION_ROOT != '' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.VALIDATION_ROOT }}
|
||||
if-no-files-found: error
|
||||
|
||||
validation-tools:
|
||||
needs: [validation]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
|
||||
uses: ./.github/workflows/rustfs-validation-tools.yml
|
||||
with:
|
||||
candidate_sha: ${{ needs.validation.outputs.source_sha }}
|
||||
auto_testing_sha: ${{ inputs.auto_testing_sha }}
|
||||
build_native_heal: ${{ inputs.validation_native_tools }}
|
||||
build_shared_heal: ${{ inputs.validation_shared_tools }}
|
||||
build_manager_native: ${{ inputs.validation_manager_tools }}
|
||||
secrets: inherit
|
||||
|
||||
upgrade:
|
||||
needs: [prepare]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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.
|
||||
|
||||
name: RustFS Validation Package
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact RustFS commit SHA to build
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
R2_ACCESS_KEY_ID:
|
||||
required: true
|
||||
R2_SECRET_ACCESS_KEY:
|
||||
required: true
|
||||
R2_ENDPOINT:
|
||||
required: true
|
||||
R2_BUCKET:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact RustFS commit SHA to build
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-validation-package-${{ inputs.source_sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve-source:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Resolve exact validation source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.source.outputs.sha }}
|
||||
source_ref: ${{ steps.source.outputs.ref }}
|
||||
steps:
|
||||
- name: Validate requested source
|
||||
id: source
|
||||
env:
|
||||
REQUESTED_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
|
||||
trap 'rm -rf "${source_directory}"' EXIT
|
||||
git init -q "${source_directory}"
|
||||
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
|
||||
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
|
||||
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
build:
|
||||
needs: resolve-source
|
||||
name: Build immutable validation DEB
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout exact source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
|
||||
- name: Confirm checked out source
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
|
||||
cache-save-if: "false"
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
- name: Build RustFS
|
||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||
|
||||
- name: Build validation DEB
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
|
||||
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
|
||||
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
|
||||
mkdir -p "${RUNNER_TEMP}/validation-package"
|
||||
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
|
||||
test -x "${binary}"
|
||||
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
|
||||
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
|
||||
cp "${binary}" "${package_dir}/usr/bin/rustfs"
|
||||
chmod 755 "${package_dir}/usr/bin/rustfs"
|
||||
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
|
||||
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
|
||||
# RustFS Environment Configuration
|
||||
# RUSTFS_ACCESS_KEY=<changeme>
|
||||
# RUSTFS_SECRET_KEY=<changeme>
|
||||
# RUSTFS_VOLUMES=""
|
||||
# RUSTFS_ADDRESS=":9000"
|
||||
ENVEOF
|
||||
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
|
||||
Package: rustfs
|
||||
Version: 0.0.0~validation
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends: libc6 (>= 2.31)
|
||||
Maintainer: RustFS Team <support@rustfs.com>
|
||||
Description: RustFS immutable validation package
|
||||
Candidate package for RustFS validation only.
|
||||
CONTROLEOF
|
||||
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
|
||||
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
|
||||
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
|
||||
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
|
||||
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
|
||||
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
|
||||
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
|
||||
test -s "${package_file}"
|
||||
|
||||
- name: Upload opaque validation package
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-package/rustfs.deb
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: [resolve-source, build]
|
||||
name: Publish immutable validation DEB
|
||||
# Candidate-controlled build scripts never run on this fresh hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
|
||||
steps:
|
||||
- name: Download opaque validation package
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-package-input
|
||||
|
||||
- name: Validate and stage opaque package bytes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -I - <<'PYTHON'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
|
||||
root = Path(os.environ["RUNNER_TEMP"])
|
||||
incoming = root / "validation-package-input"
|
||||
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
|
||||
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
|
||||
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as source:
|
||||
info = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
|
||||
raise SystemExit("Validation package must be a nonempty regular file")
|
||||
staged = root / "validation-publication"
|
||||
staged.mkdir(mode=0o700)
|
||||
with (staged / "rustfs.deb").open("xb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
PYTHON
|
||||
|
||||
- name: Prepare trusted publication tools
|
||||
id: tools
|
||||
env:
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AWS_CLI=aws
|
||||
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y -qq python3-venv
|
||||
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
|
||||
python3 -m venv "${AWS_CLI_DIR}"
|
||||
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||
fi
|
||||
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload validation package to immutable R2 path
|
||||
id: publish
|
||||
env:
|
||||
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
|
||||
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
|
||||
echo "Validation package publication requires complete R2 configuration" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
|
||||
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
|
||||
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
|
||||
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
|
||||
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
|
||||
url="https://dl.rustfs.com/${key}"
|
||||
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
|
||||
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
|
||||
test "${downloaded_sha256}" = "${package_sha256}"
|
||||
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
|
||||
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
|
||||
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
|
||||
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
|
||||
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload validation candidate manifest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.publish.outputs.candidate_file }}
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,343 @@
|
||||
# Build source-bound helpers separately so live validation needs only artifacts.
|
||||
name: RustFS Validation Tools
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
candidate_sha:
|
||||
type: string
|
||||
required: true
|
||||
auto_testing_sha:
|
||||
type: string
|
||||
required: true
|
||||
build_native_heal:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_shared_heal:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_manager_native:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
PF_TESTING_GH_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
candidate_sha:
|
||||
description: Exact RustFS source SHA from the validation candidate manifest
|
||||
type: string
|
||||
required: true
|
||||
auto_testing_sha:
|
||||
description: Exact auto-testing SHA containing the Heal metadata inspector
|
||||
type: string
|
||||
required: true
|
||||
build_native_heal:
|
||||
description: Also compile and run separate exact-source native Heal regressions
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_shared_heal:
|
||||
description: Build the controlled C10 shared-reference fixture encoder
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_manager_native:
|
||||
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
manager-native-regressions:
|
||||
if: ${{ inputs.build_manager_native }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
steps:
|
||||
- name: Validate immutable manager native inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
- name: Checkout exact manager candidate source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ inputs.candidate_sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout exact manager collector source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install manager native build dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
|
||||
df -h .
|
||||
|
||||
- name: Build exact unmodified candidate manager test executable
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
|
||||
|
||||
- name: Run exact manager regressions independently
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
|
||||
--directory manager-tools --out manager-evidence
|
||||
|
||||
- name: Upload manager executable identities and original diagnostics
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
manager-tools/heal-manager-native-tests
|
||||
manager-tools/*.json
|
||||
manager-tools/*.jsonl
|
||||
manager-tools/*.stderr
|
||||
manager-tools/*.log
|
||||
manager-evidence/
|
||||
if-no-files-found: warn
|
||||
|
||||
native-heal-regressions:
|
||||
if: ${{ inputs.build_native_heal }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
steps:
|
||||
- name: Validate immutable native source inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
- name: Checkout exact native candidate source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ inputs.candidate_sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout exact native collector source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
|
||||
df -h .
|
||||
|
||||
- name: Build and enumerate the unmodified candidate test executable
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
use_race_native=false
|
||||
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
|
||||
support_rc=0
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
|
||||
--commit "${CANDIDATE_SHA}" || support_rc=$?
|
||||
case "${support_rc}" in
|
||||
0) use_race_native=true ;;
|
||||
2) ;; # The original exact-source collector handles legacy candidates.
|
||||
*) exit "${support_rc}" ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "${use_race_native}" == true ]]; then
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
|
||||
else
|
||||
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
|
||||
fi
|
||||
|
||||
- name: Run exact native regressions separately from fleet validation
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -f native-tools/race-native.json ]]; then
|
||||
test ! -e native-tools/native-regressions.json
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
|
||||
--directory native-tools --out native-evidence
|
||||
else
|
||||
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
|
||||
--directory native-tools --out native-evidence
|
||||
fi
|
||||
|
||||
- name: Run existing F07 directory fsync failure regressions
|
||||
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Older immutable harness pins retain their original native suite;
|
||||
# absence of this additive collector must never fabricate its proof.
|
||||
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
|
||||
mkdir native-persistence-evidence
|
||||
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
|
||||
> native-persistence-evidence/not-available.json
|
||||
exit 0
|
||||
fi
|
||||
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
|
||||
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
|
||||
--testing-commit "${AUTO_TESTING_SHA}"
|
||||
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
|
||||
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
|
||||
--testing-commit "${AUTO_TESTING_SHA}"
|
||||
|
||||
- name: Upload native executable identities and original diagnostics
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
native-tools/ecstore-native-tests
|
||||
native-tools/ecstore-race-native-tests
|
||||
native-tools/*.json
|
||||
native-tools/*.jsonl
|
||||
native-tools/*.stderr
|
||||
native-tools/*.log
|
||||
native-evidence/
|
||||
native-persistence-evidence/
|
||||
if-no-files-found: warn
|
||||
|
||||
heal-metadata-inspector:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
steps:
|
||||
- name: Validate immutable source inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
- name: Checkout exact candidate source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ inputs.candidate_sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout exact inspector source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build the read-only candidate metadata decoder
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
df -h .
|
||||
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
|
||||
# Seed the helper's separate graph from the candidate's frozen graph.
|
||||
# Cargo adds the helper package; both lock identities are retained.
|
||||
cp candidate/Cargo.lock inspector-build/Cargo.lock
|
||||
cargo build --release --manifest-path inspector-build/Cargo.toml
|
||||
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
|
||||
--binary inspector-build/target/release/heal-meta-inspect --source candidate
|
||||
mkdir validation-tools
|
||||
cp inspector-build/target/release/heal-meta-inspect validation-tools/
|
||||
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
source = json.loads(Path("inspector-build/source.json").read_text())
|
||||
binary = Path("validation-tools/heal-meta-inspect")
|
||||
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
|
||||
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
|
||||
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
|
||||
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
|
||||
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
|
||||
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
|
||||
PY
|
||||
git -C candidate diff --exit-code
|
||||
|
||||
- name: Build and test the controlled C10 metadata encoder
|
||||
if: ${{ inputs.build_shared_heal }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
|
||||
cp candidate/Cargo.lock shared-build/Cargo.lock
|
||||
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
|
||||
cargo build --release --manifest-path shared-build/Cargo.toml
|
||||
mkdir shared-tools
|
||||
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
|
||||
> shared-tools/shared-fixture-tests.log 2>&1; then
|
||||
cat shared-tools/shared-fixture-tests.log
|
||||
exit 1
|
||||
fi
|
||||
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
|
||||
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
source = json.loads(Path("shared-build/source.json").read_text())
|
||||
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
|
||||
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
|
||||
testingCommit=os.environ["AUTO_TESTING_SHA"],
|
||||
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
|
||||
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
|
||||
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
|
||||
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
|
||||
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
|
||||
PY
|
||||
git -C candidate diff --exit-code
|
||||
|
||||
- name: Upload controlled C10 fixture encoder evidence
|
||||
if: ${{ always() && inputs.build_shared_heal }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: shared-tools/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload immutable validation tools
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: validation-tools/
|
||||
if-no-files-found: error
|
||||
@@ -103,6 +103,10 @@ 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"
|
||||
|
||||
@@ -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.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
```
|
||||
|
||||
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.3 --push
|
||||
./docker-buildx.sh --release 1.0.4 --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.3 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.4 # 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.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# 构建指定版本
|
||||
./docker-buildx.sh --release 1.0.3 --push
|
||||
./docker-buildx.sh --release 1.0.4 --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.3 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -49,12 +49,28 @@ 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",
|
||||
@@ -82,6 +98,7 @@ impl Workers {
|
||||
});
|
||||
if released {
|
||||
self.semaphore.add_permits(1);
|
||||
self.in_use.send_modify(|_| {});
|
||||
}
|
||||
trace!(
|
||||
event = "worker_slot.release",
|
||||
@@ -99,7 +116,9 @@ 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).await;
|
||||
let _ = rx
|
||||
.wait_for(|&held| held == 0 && self.semaphore.available_permits() == self.limit)
|
||||
.await;
|
||||
debug!(
|
||||
event = "worker_slot.wait",
|
||||
component = "concurrency",
|
||||
@@ -114,7 +133,9 @@ impl Workers {
|
||||
|
||||
/// Return the current number of available worker slots.
|
||||
pub async fn available(&self) -> usize {
|
||||
self.limit.saturating_sub(*self.in_use.borrow())
|
||||
self.limit
|
||||
.saturating_sub(*self.in_use.borrow())
|
||||
.min(self.semaphore.available_permits())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,4 +238,36 @@ 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,6 +197,10 @@ 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;
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
//! 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};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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(())
|
||||
}
|
||||
@@ -347,6 +347,9 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
|
||||
}
|
||||
|
||||
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
|
||||
cluster.set_env("RUST_LOG", filter);
|
||||
}
|
||||
if let Some(directory) = capture_dir {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
for node_idx in 0..cluster.nodes.len() {
|
||||
|
||||
@@ -104,7 +104,12 @@ async fn verified_get(
|
||||
stage = "response body";
|
||||
let mut digest = Sha256::new();
|
||||
while let Some(chunk) = output.body.next().await {
|
||||
let chunk = chunk?;
|
||||
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);
|
||||
|
||||
@@ -28,6 +28,7 @@ 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 replication_delete_marker_test;
|
||||
@@ -36,6 +37,7 @@ 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
@@ -216,6 +216,10 @@ 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.
|
||||
@@ -236,6 +240,7 @@ 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?;
|
||||
@@ -329,6 +334,11 @@ 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)?;
|
||||
@@ -360,6 +370,10 @@ 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, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
|
||||
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
// 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::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 lock_events(run: &TopologyRun, bucket: &str) -> TestResult<Vec<Value>> {
|
||||
let mut events = Vec::new();
|
||||
for path in run.node_log_paths() {
|
||||
let log = std::fs::read_to_string(path)?;
|
||||
for line in log.lines() {
|
||||
let Ok(row) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
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(KEY)
|
||||
{
|
||||
let mut event = fields.clone();
|
||||
event["timestamp"] = row["timestamp"].clone();
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
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(run: &TopologyRun, bucket: &str, started: OffsetDateTime) -> TestResult<Value> {
|
||||
loop {
|
||||
let events = lock_events(run, bucket)?;
|
||||
for event in &events {
|
||||
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"
|
||||
&& !events
|
||||
.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(run: &TopologyRun, bucket: &str, pending: &Value) -> TestResult<Value> {
|
||||
loop {
|
||||
for event in lock_events(run, bucket)? {
|
||||
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
|
||||
continue;
|
||||
}
|
||||
match event["state"].as_str() {
|
||||
Some("acquired") => return Ok(event),
|
||||
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 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(&run, &bucket, movement_started))
|
||||
.await
|
||||
.map_err(|error| format!(
|
||||
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
|
||||
lock_events(&run, &bucket), 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(&run, &bucket, &pending))
|
||||
.await
|
||||
.map_err(|error| format!(
|
||||
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
|
||||
lock_events(&run, &bucket), 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"
|
||||
);
|
||||
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,
|
||||
}
|
||||
|
||||
fn source_binary() -> TestResult<PathBuf> {
|
||||
pub(super) 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,36 +487,43 @@ impl JournaledHeaders {
|
||||
struct ControlState {
|
||||
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
||||
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
||||
held_get: Option<HeldGetObject>,
|
||||
held_get: Option<HeldObject>,
|
||||
held_delete: Option<HeldObject>,
|
||||
requests: VecDeque<RequestRecord>,
|
||||
next_sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HeldGetObject {
|
||||
struct HeldObject {
|
||||
bucket: String,
|
||||
key: String,
|
||||
entered: watch::Sender<usize>,
|
||||
released: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Holds every matching operation, including retries, until this guard is dropped.
|
||||
#[must_use = "dropping the guard releases the held requests"]
|
||||
pub struct ObjectGate {
|
||||
control: Arc<Mutex<ControlState>>,
|
||||
operation: Operation,
|
||||
entered: watch::Receiver<usize>,
|
||||
released: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl GetObjectGate {
|
||||
impl ObjectGate {
|
||||
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GetObjectGate {
|
||||
impl Drop for ObjectGate {
|
||||
fn drop(&mut self) {
|
||||
lock(&self.control).held_get = None;
|
||||
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"),
|
||||
}
|
||||
self.released.send_replace(true);
|
||||
}
|
||||
}
|
||||
@@ -1108,24 +1115,40 @@ impl FakeS3Target {
|
||||
}
|
||||
|
||||
/// Hold one exact bucket/key before any GET response can reach the client.
|
||||
/// The fixture supports one live gate; request and connection deadlines still apply.
|
||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
|
||||
/// 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 {
|
||||
assert!(
|
||||
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
||||
"held GET identifiers exceed the fixture limit"
|
||||
"held object identifiers exceed the fixture limit"
|
||||
);
|
||||
let mut state = lock(&self.control);
|
||||
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
|
||||
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");
|
||||
let (entered, entered_rx) = watch::channel(0);
|
||||
let (released, released_rx) = watch::channel(false);
|
||||
state.held_get = Some(HeldGetObject {
|
||||
*slot = Some(HeldObject {
|
||||
bucket: bucket.to_string(),
|
||||
key: key.to_string(),
|
||||
entered,
|
||||
released: released_rx,
|
||||
});
|
||||
GetObjectGate {
|
||||
ObjectGate {
|
||||
control: Arc::clone(&self.control),
|
||||
operation,
|
||||
entered: entered_rx,
|
||||
released,
|
||||
}
|
||||
@@ -2795,6 +2818,15 @@ 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"));
|
||||
@@ -3436,6 +3468,68 @@ 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, rustfs_binary_path,
|
||||
init_logging, local_http_client, rustfs_binary_path,
|
||||
};
|
||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||
use aws_sdk_s3::{
|
||||
@@ -53,6 +53,196 @@ 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,
|
||||
@@ -1206,7 +1396,9 @@ mod tests {
|
||||
let interval = interval
|
||||
.filter(|interval| *interval != 0)
|
||||
.ok_or("scanner schedule has no positive interval")?;
|
||||
let next_cycle = cycle_end
|
||||
// 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
|
||||
.checked_add(interval)
|
||||
.ok_or("scanner schedule timestamp overflows")?;
|
||||
let budget = next_cycle
|
||||
@@ -1223,6 +1415,11 @@ 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]
|
||||
@@ -1243,7 +1440,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 schedule_accounted = false;
|
||||
let mut accounted_schedules = HashSet::new();
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
@@ -1290,15 +1487,27 @@ mod tests {
|
||||
return Err("scanner cycle timestamp is in the future".into());
|
||||
}
|
||||
let schedule = &status["cycle_schedule"];
|
||||
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
|
||||
if 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 || (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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Superseded walks complete all sets while withholding quota authority.
|
||||
@@ -2213,6 +2422,7 @@ 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(
|
||||
@@ -2254,14 +2464,23 @@ 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) => {
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
|
||||
.matches_manifest(expected)
|
||||
let actual =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
pool_metadata_matches_republished_manifest(&actual, 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;
|
||||
}
|
||||
@@ -2271,6 +2490,15 @@ 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}"));
|
||||
@@ -2294,7 +2522,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={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_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}",
|
||||
expected_manifests.len()
|
||||
)
|
||||
.into());
|
||||
|
||||
@@ -147,14 +147,19 @@ 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))
|
||||
stats
|
||||
.get("current_usage")
|
||||
.and_then(|value| value.as_u64())
|
||||
.ok_or_else(|| format!("bucket quota usage is not authoritative for {}", self.bucket_name).into())
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let convergence = async {
|
||||
loop {
|
||||
let usage = self.get_bucket_usage().await?;
|
||||
if usage == expected {
|
||||
let stats = self.get_bucket_quota_stats().await?;
|
||||
if let Some(usage) = stats.get("current_usage").and_then(|value| value.as_u64())
|
||||
&& usage == expected
|
||||
{
|
||||
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
@@ -214,9 +219,11 @@ impl QuotaTestEnv {
|
||||
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 {
|
||||
let stats: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if stats.get("current_usage").and_then(|value| value.as_u64()).is_some() {
|
||||
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
|
||||
}
|
||||
} else if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
|
||||
@@ -548,6 +548,20 @@ 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>,
|
||||
@@ -562,6 +576,25 @@ 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,7 +15,11 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk};
|
||||
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::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
@@ -55,5 +59,24 @@ pub(crate) mod replication_extension {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod upgrade_compatibility {
|
||||
pub(crate) use super::{DiskOption, Endpoint, new_disk};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -52,6 +53,8 @@ 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
|
||||
@@ -301,18 +304,27 @@ async fn read_object(
|
||||
Ok((encryption, body))
|
||||
}
|
||||
|
||||
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")?;
|
||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||
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> {
|
||||
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 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()
|
||||
@@ -323,16 +335,38 @@ async fn write_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,
|
||||
})
|
||||
}
|
||||
|
||||
client
|
||||
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
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.upload_id(&pending.upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(pending.completed_parts.clone()))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
.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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -379,6 +413,330 @@ 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,
|
||||
@@ -625,14 +983,11 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
||||
|
||||
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;
|
||||
// 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?;
|
||||
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
@@ -648,9 +1003,9 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
let last_put_key = format!("{phase}/put/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
assert_eq!(
|
||||
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
|
||||
read_object(previous_client, MIXED_BUCKET, &last_put_key, None).await?.1,
|
||||
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
|
||||
);
|
||||
|
||||
@@ -790,12 +1145,20 @@ 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. This preparation ends before any current binary is started.
|
||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
// 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?;
|
||||
// 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
|
||||
@@ -805,7 +1168,7 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
||||
for node in [1, 2, 3, 0] {
|
||||
cluster.stop_node(node)?;
|
||||
cluster.start_node_from_binary(node, previous_binary).await?;
|
||||
let context = format!("previous-restart-{node}");
|
||||
let context = format!("{prefix}-restart-{node}");
|
||||
wait_for_upgrade_write_readiness(
|
||||
std::slice::from_ref(&clients[node]),
|
||||
MIXED_BUCKET,
|
||||
@@ -822,15 +1185,17 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
||||
.await?;
|
||||
}
|
||||
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, &format!("{prefix}-baseline"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
for (reader, client) in clients.iter().enumerate() {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
read_object(client, MIXED_BUCKET, &format!(".upgrade-readiness/{prefix}-seed/node-0"), None)
|
||||
.await?
|
||||
.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
"previous-release node {reader} must retain the seed across its rolling restart"
|
||||
);
|
||||
for writer in 0..clients.len() {
|
||||
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
|
||||
let key = format!(".upgrade-readiness/{prefix}-baseline/node-{writer}");
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, &key, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
@@ -1883,10 +2248,21 @@ 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)?;
|
||||
@@ -1898,6 +2274,9 @@ 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!(
|
||||
@@ -1916,47 +2295,6 @@ 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]
|
||||
@@ -1980,6 +2318,115 @@ 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ 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,6 +429,16 @@ 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::{
|
||||
@@ -438,9 +448,12 @@ 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,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
pub use bytes::Bytes;
|
||||
pub use endpoint::Endpoint;
|
||||
@@ -513,13 +526,16 @@ pub mod metrics {
|
||||
pub mod notification {
|
||||
pub use crate::services::notification_sys::{
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::{
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
use rand::RngExt as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -70,6 +71,7 @@ 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);
|
||||
@@ -6104,57 +6106,114 @@ 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
|
||||
}
|
||||
|
||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||
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,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut marker: Option<String> = None;
|
||||
let mut manifest_marker: Option<String> = None;
|
||||
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
|
||||
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_tier_delete_journal_pass(
|
||||
api.clone(),
|
||||
&cancel_token,
|
||||
&mut manifest_marker,
|
||||
&mut marker,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
||||
)
|
||||
.await;
|
||||
let stats = recover().await;
|
||||
if stats.canceled {
|
||||
return;
|
||||
}
|
||||
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"
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -6180,16 +6239,20 @@ 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, TierDeleteDispatchManifestState,
|
||||
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
|
||||
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
|
||||
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,
|
||||
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_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
|
||||
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
|
||||
validate_legacy_tier_delete_recovery_source,
|
||||
};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||
@@ -6833,6 +6896,213 @@ 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(
|
||||
|
||||
@@ -3186,9 +3186,17 @@ pub mod test_support {
|
||||
use crate::store::init_local_disks_with_instance_ctx;
|
||||
|
||||
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let mut dirs = Vec::with_capacity(4);
|
||||
let mut endpoints = Vec::with_capacity(4);
|
||||
for disk_idx in 0..4 {
|
||||
isolated_store_over_temp_disks_with_count(4).await
|
||||
}
|
||||
|
||||
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
assert!(
|
||||
matches!(disk_count, 4 | 16),
|
||||
"isolated store fixture requires a registered logical set geometry"
|
||||
);
|
||||
let mut dirs = Vec::with_capacity(disk_count);
|
||||
let mut endpoints = Vec::with_capacity(disk_count);
|
||||
for disk_idx in 0..disk_count {
|
||||
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");
|
||||
@@ -3201,7 +3209,7 @@ pub mod test_support {
|
||||
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
drives_per_set: disk_count,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "metadata-sys-cache-test".to_string(),
|
||||
platform: "test".to_string(),
|
||||
|
||||
@@ -235,6 +235,8 @@ 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 {
|
||||
@@ -244,18 +246,13 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
let quota = self.get_quota_config(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?)
|
||||
};
|
||||
let current_usage = self.report_only_usage(bucket).await;
|
||||
|
||||
Ok((quota, current_usage))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Best-effort usage for statistics and durable admission reports.
|
||||
/// Missing scanner coverage is unknown, not an error or a zero-sized bucket.
|
||||
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;
|
||||
@@ -267,7 +264,7 @@ impl QuotaChecker {
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
"durable quota usage report degraded"
|
||||
"quota usage report degraded"
|
||||
);
|
||||
}
|
||||
usage
|
||||
@@ -742,8 +739,12 @@ mod tests {
|
||||
1,
|
||||
"fail-closed admission takes the early-reload lookup"
|
||||
);
|
||||
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);
|
||||
let (_, usage) = checker
|
||||
.get_quota_stats(&legacy)
|
||||
.await
|
||||
.expect("legacy quota statistics are reporting only");
|
||||
assert_eq!(usage, None);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 1);
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
|
||||
0,
|
||||
|
||||
@@ -245,3 +245,86 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -707,13 +707,6 @@ 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,
|
||||
@@ -765,7 +758,7 @@ impl QuotaReservation {
|
||||
let Some(ledger) = self.ledger.as_ref() else {
|
||||
return;
|
||||
};
|
||||
crate::store::list_objects::observe_list_objects_mutation(ledger.store(), ledger.bucket()).await;
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(ledger.bucket(), 1);
|
||||
match settle(ledger, true).await {
|
||||
Ok(()) => self.ledger = None,
|
||||
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
|
||||
@@ -1290,7 +1283,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_list_objects_mutation(store, bucket).await;
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
|
||||
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::{
|
||||
future::Future,
|
||||
io::ErrorKind,
|
||||
pin::Pin,
|
||||
sync::{Arc, OnceLock, atomic::AtomicBool},
|
||||
sync::{Arc, OnceLock},
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -198,6 +198,8 @@ 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>),
|
||||
@@ -236,8 +238,6 @@ 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>,
|
||||
@@ -248,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<AtomicBool>>,
|
||||
pub(crate) test_disk_io_context_observed: Option<Arc<std::sync::atomic::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>>,
|
||||
@@ -273,7 +273,6 @@ 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)]
|
||||
@@ -301,22 +300,31 @@ 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, None).await
|
||||
list_path_raw_inner(rx, opts, None, None).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_path_raw_with_claim_tracker(
|
||||
rx: CancellationToken,
|
||||
opts: ListPathRawOptions,
|
||||
claim_tracker: FallbackClaimTracker,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
|
||||
@@ -327,7 +335,7 @@ pub(crate) async fn list_path_raw_with_partial_result(
|
||||
opts: ListPathRawOptions,
|
||||
claim_tracker: FallbackClaimTracker,
|
||||
partial_result: PartialResultFn,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
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
|
||||
@@ -338,7 +346,7 @@ async fn list_path_raw_inner(
|
||||
opts: ListPathRawOptions,
|
||||
fallback_claim_tracker: Option<FallbackClaimTracker>,
|
||||
partial_result: Option<PartialResultFn>,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
if opts.disks.is_empty() {
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
@@ -393,6 +401,14 @@ 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;
|
||||
@@ -531,6 +547,16 @@ 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;
|
||||
@@ -699,6 +725,9 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
let mut peek_outcomes: Vec<Option<PeekOutcome>> = std::iter::repeat_with(|| None).take(readers.len()).collect();
|
||||
// Sticky across rounds: once any reader's trailer says its producer
|
||||
// stopped at the per-disk limit, the merged stream may hide more keys.
|
||||
let mut limit_reached = false;
|
||||
|
||||
loop {
|
||||
let mut current = MetaCacheEntry::default();
|
||||
@@ -710,7 +739,7 @@ async fn list_path_raw_inner(
|
||||
// );
|
||||
|
||||
if revjob_rx.is_cancelled() {
|
||||
return Ok(());
|
||||
return Ok(ListPathRawOutcome { limit_reached });
|
||||
}
|
||||
|
||||
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
@@ -760,6 +789,7 @@ 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;
|
||||
@@ -833,7 +863,7 @@ async fn list_path_raw_inner(
|
||||
*r = MetacacheReader::new(detached_rd);
|
||||
continue;
|
||||
}
|
||||
PeekOutcome::Cancelled => return Ok(()),
|
||||
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -892,7 +922,7 @@ async fn list_path_raw_inner(
|
||||
if let Some(finished_fn) = opts.finished.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -949,7 +979,7 @@ async fn list_path_raw_inner(
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -969,7 +999,7 @@ async fn list_path_raw_inner(
|
||||
// warn!("list_path_raw: agreed_fn start, current: {:?}", ¤t.name);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
result = agreed_fn(current) => result?
|
||||
}
|
||||
// warn!("list_path_raw: agreed_fn done");
|
||||
@@ -993,58 +1023,61 @@ async fn list_path_raw_inner(
|
||||
if let Some(partial_fn) = partial_result.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
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(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(ListPathRawOutcome { limit_reached })
|
||||
});
|
||||
|
||||
let merge_started = std::time::Instant::now();
|
||||
let Some(revjob) = revjobs.join_next().await else {
|
||||
return Err(DiskError::Unexpected);
|
||||
};
|
||||
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();
|
||||
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);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if rx.is_cancelled() {
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
return Ok(());
|
||||
return Ok(outcome);
|
||||
}
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
@@ -1113,7 +1146,7 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
|
||||
// warn!("list_path_raw: done");
|
||||
Ok(())
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1132,6 +1165,7 @@ 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;
|
||||
|
||||
@@ -1252,6 +1286,61 @@ 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,
|
||||
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,
|
||||
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(
|
||||
|
||||
@@ -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 (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion) pass
|
||||
/// Bulk `bytes`-carrying RPCs, including native pool metadata recovery inventories, 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,6 +357,7 @@ 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"
|
||||
)
|
||||
@@ -379,6 +380,7 @@ 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()
|
||||
@@ -447,13 +449,21 @@ 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))
|
||||
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)
|
||||
|| matches!(sent_state.cache_capability, Some(PeerReplayCapability::Capable { .. })))
|
||||
{
|
||||
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,6 +631,17 @@ 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>,
|
||||
@@ -1964,6 +1975,16 @@ 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
|
||||
@@ -4333,6 +4354,49 @@ 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,13 +22,17 @@ 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, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
|
||||
PartTransactionAction, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse, 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,
|
||||
@@ -62,11 +66,14 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
BatchReadVersionRequest, BatchReadVersionResponse, CheckPartsRequest, CompareAndUpdateFileOutcome,
|
||||
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
|
||||
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, GuardedDeleteVersionsRequest, ListDirRequest,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
@@ -237,6 +244,36 @@ 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()
|
||||
@@ -2128,7 +2165,7 @@ impl RemoteDisk {
|
||||
.map_err(|err| Error::RemoteClientUnavailable(err.to_string()))
|
||||
}
|
||||
|
||||
/// Client for large `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion).
|
||||
/// Client for large `bytes`-carrying RPCs, including native pool metadata recovery inventories.
|
||||
/// 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.
|
||||
@@ -2224,6 +2261,38 @@ 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)
|
||||
}
|
||||
@@ -2399,6 +2468,18 @@ 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,
|
||||
@@ -2441,9 +2522,12 @@ impl RemoteDisk {
|
||||
&fi.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
);
|
||||
if quota_fenced {
|
||||
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 {
|
||||
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
|
||||
} else if scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
} else if multipart_rename || 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)?;
|
||||
@@ -2458,7 +2542,12 @@ impl RemoteDisk {
|
||||
let rpc_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = rustfs_io_metrics::observe_put_stage_future(
|
||||
async {
|
||||
if quota_fenced {
|
||||
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 {
|
||||
// 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 {
|
||||
@@ -2472,7 +2561,14 @@ 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?;
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if (multipart_rename || pool_meta_cas) && status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?;
|
||||
let response_process_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response_headers_started = rustfs_io_metrics::put_stage_timer();
|
||||
let _ = response.metadata().len();
|
||||
@@ -2492,7 +2588,9 @@ impl RemoteDisk {
|
||||
);
|
||||
|
||||
let success_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if !response.success {
|
||||
if multipart_rename {
|
||||
validate_multipart_publication_envelope(&response)?;
|
||||
} else if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
@@ -2501,11 +2599,15 @@ impl RemoteDisk {
|
||||
);
|
||||
|
||||
let decode_started = rustfs_io_metrics::put_stage_timer();
|
||||
let rename_data_resp = decode_msgpack_or_json::<RenameDataResp>(
|
||||
&response.rename_data_resp_bin,
|
||||
&response.rename_data_resp,
|
||||
"RenameDataResp",
|
||||
)?;
|
||||
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",
|
||||
)?
|
||||
};
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_REMOTE_CLIENT_DECODE,
|
||||
decode_started,
|
||||
@@ -2515,6 +2617,14 @@ 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(),
|
||||
@@ -2907,6 +3017,20 @@ 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)?;
|
||||
|
||||
@@ -2926,7 +3050,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 {
|
||||
if incarnation_bound || pool_meta_undo {
|
||||
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 {
|
||||
@@ -2935,7 +3059,15 @@ 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 conditional_marker {
|
||||
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 {
|
||||
client.delete_retired_marker(request).await?
|
||||
} else if incarnation_bound {
|
||||
client.delete_version_at_incarnation(request).await?
|
||||
@@ -3617,6 +3749,107 @@ 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");
|
||||
@@ -4553,6 +4786,322 @@ 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();
|
||||
@@ -4607,6 +5156,153 @@ 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();
|
||||
@@ -5494,6 +6190,7 @@ 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()),
|
||||
@@ -5898,6 +6595,7 @@ 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()),
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
// 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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,569 @@
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
+982
-127
File diff suppressed because it is too large
Load Diff
@@ -473,7 +473,6 @@ 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()
|
||||
@@ -482,9 +481,43 @@ 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 {
|
||||
Arc::make_mut(pool).set_instance_ctx_for_test(Arc::clone(&ctx));
|
||||
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));
|
||||
}
|
||||
}
|
||||
*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(),
|
||||
@@ -6886,7 +6919,6 @@ 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();
|
||||
@@ -6906,6 +6938,9 @@ 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;
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
@@ -60,16 +59,10 @@ 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 {
|
||||
@@ -882,67 +875,6 @@ 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;
|
||||
@@ -1515,9 +1447,7 @@ 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 {
|
||||
@@ -2097,100 +2027,6 @@ 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,6 +958,26 @@ 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,
|
||||
@@ -972,6 +992,7 @@ 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
|
||||
@@ -994,6 +1015,14 @@ 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(
|
||||
@@ -1033,6 +1062,25 @@ 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 {
|
||||
@@ -3549,6 +3597,268 @@ 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 _ = load_data_usage_from_backend_cached(store).await;
|
||||
let _ = project_data_usage_from_backend_cached(store, |_| ()).await;
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
cache
|
||||
.as_ref()
|
||||
@@ -237,31 +237,34 @@ struct CachedDataUsageSnapshot {
|
||||
}
|
||||
|
||||
impl CachedDataUsageSnapshot {
|
||||
fn result(&self) -> Result<DataUsageInfo, Error> {
|
||||
fn result<T>(&self, project: impl FnOnce(&DataUsageInfo) -> T) -> Result<T, Error> {
|
||||
self.info
|
||||
.clone()
|
||||
.as_ref()
|
||||
.map(project)
|
||||
.ok_or_else(|| Error::other("data usage snapshot load recently failed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_cached_data_usage_snapshot(
|
||||
fn fresh_cached_data_usage_snapshot<T>(
|
||||
cache: &Option<CachedDataUsageSnapshot>,
|
||||
now: tokio::time::Instant,
|
||||
ttl: Duration,
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
project: impl FnOnce(&DataUsageInfo) -> T,
|
||||
) -> Option<Result<T, Error>> {
|
||||
cache
|
||||
.as_ref()
|
||||
.filter(|cached| now.duration_since(cached.loaded_at) < ttl)
|
||||
.map(CachedDataUsageSnapshot::result)
|
||||
.map(|cached| cached.result(project))
|
||||
}
|
||||
|
||||
fn cache_data_usage_snapshot_result(
|
||||
fn cache_data_usage_snapshot_result<T>(
|
||||
cache: &mut Option<CachedDataUsageSnapshot>,
|
||||
result: Result<LoadedUsageBaseline, Error>,
|
||||
loaded_at: tokio::time::Instant,
|
||||
refresh_generation: u64,
|
||||
current_generation: u64,
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
project: impl FnOnce(&DataUsageInfo) -> T,
|
||||
) -> Option<Result<T, Error>> {
|
||||
if current_generation != refresh_generation {
|
||||
return None;
|
||||
}
|
||||
@@ -280,12 +283,13 @@ fn cache_data_usage_snapshot_result(
|
||||
degraded_baseline.entry(bucket.clone()).or_insert(*size);
|
||||
}
|
||||
}
|
||||
let projected = project(&info);
|
||||
*cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info.clone()),
|
||||
info: Some(info),
|
||||
loaded_at,
|
||||
degraded_baseline,
|
||||
});
|
||||
Ok(info)
|
||||
Ok(projected)
|
||||
}
|
||||
Err(e) => {
|
||||
// Keep the previous baseline through a failed refresh: quota
|
||||
@@ -1486,12 +1490,21 @@ 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) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1503,7 +1516,7 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
.await;
|
||||
{
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1512,9 +1525,14 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
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())
|
||||
{
|
||||
if let Some(result) = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
result,
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
&project,
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
drop(cache);
|
||||
@@ -1532,7 +1550,8 @@ 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) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1543,7 +1562,8 @@ 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) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1564,6 +1584,7 @@ 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;
|
||||
}
|
||||
@@ -2277,8 +2298,10 @@ async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
|
||||
}
|
||||
|
||||
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
let info = load_data_usage_from_backend_cached(store).await.ok()?;
|
||||
info.buckets_usage.get(bucket).map(|usage| usage.size)
|
||||
project_data_usage_from_backend_cached(store, |info| info.buckets_usage.get(bucket).map(|usage| usage.size))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn reload_persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
@@ -2375,6 +2398,7 @@ where
|
||||
tokio::time::Instant::now(),
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
|_| (),
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
@@ -2891,6 +2915,8 @@ 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;
|
||||
@@ -4572,15 +4598,29 @@ 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))
|
||||
.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),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.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)).is_none());
|
||||
assert!(
|
||||
fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4601,14 +4641,20 @@ 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))
|
||||
.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),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("successful load must remain cached within the TTL")
|
||||
.expect("cached success must remain successful");
|
||||
assert_snapshot_bucket(&cached, "bucket");
|
||||
}
|
||||
|
||||
@@ -4633,6 +4679,7 @@ 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");
|
||||
@@ -4666,6 +4713,7 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
);
|
||||
|
||||
assert!(stale_result.is_none());
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
|
||||
pub(crate) 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,
|
||||
@@ -803,12 +803,45 @@ 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.
|
||||
@@ -834,6 +867,8 @@ 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),
|
||||
@@ -893,6 +928,46 @@ 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);
|
||||
}
|
||||
@@ -1570,6 +1645,52 @@ 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,
|
||||
@@ -2034,6 +2155,11 @@ 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(),
|
||||
@@ -2738,13 +2864,23 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete_paths(&self, volume: &str, paths: &[String]) -> 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)]
|
||||
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
|
||||
}
|
||||
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
|
||||
@@ -2918,6 +3054,70 @@ 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",
|
||||
@@ -3087,13 +3287,23 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
|
||||
self.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.cleanup_delete(volume, path, opt, None).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
#[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
|
||||
}
|
||||
|
||||
async fn verify_file(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp> {
|
||||
@@ -3219,6 +3429,100 @@ 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");
|
||||
@@ -3831,6 +4135,166 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
#[test]
|
||||
|
||||
+1055
-554
File diff suppressed because it is too large
Load Diff
@@ -281,10 +281,12 @@ type PreparedInlineMetadata = (
|
||||
Option<StagedRollbackBackup>,
|
||||
bool,
|
||||
os::PreparedRenameSource,
|
||||
Option<Arc<os::PreparedPoolMetaBackup>>,
|
||||
Option<super::pool_meta_native::DurableHeadReceipt>,
|
||||
);
|
||||
|
||||
enum InlinePreparationOutcome {
|
||||
Committed(RenameDataResp),
|
||||
Committed(RenameDataResp, Option<super::pool_meta_native::DurableHeadReceipt>),
|
||||
CommitErr(DiskError),
|
||||
Prepared(PreparedInlineMetadata),
|
||||
}
|
||||
@@ -400,6 +402,14 @@ 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(
|
||||
@@ -411,12 +421,54 @@ 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
|
||||
@@ -442,8 +494,32 @@ 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.
|
||||
let no_inline = fi.data.is_none() && fi.size > 0;
|
||||
// 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 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)?)
|
||||
@@ -455,19 +531,46 @@ impl LocalDisk {
|
||||
.strip_prefix(self.io_root())
|
||||
.map_err(|_| DiskError::InvalidPath)?,
|
||||
);
|
||||
let metadata_lease =
|
||||
os::acquire_metadata_mutation_lease(&root_destination_object_path, state.namespace_owner.take()).await;
|
||||
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 mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
|
||||
&self.root,
|
||||
dst_volume,
|
||||
&destination_object_path,
|
||||
&root_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;
|
||||
}
|
||||
@@ -524,8 +627,19 @@ impl LocalDisk {
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
|
||||
// xl.meta path
|
||||
let src_file_path = self.io_get_object_path(src_volume, format!("{}/{}", src_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
// 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())?,
|
||||
};
|
||||
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)
|
||||
@@ -551,6 +665,22 @@ 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())?;
|
||||
|
||||
@@ -570,8 +700,10 @@ 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,
|
||||
!no_inline && !preserve_upload_metadata,
|
||||
src_file_parent,
|
||||
dst_file_parent,
|
||||
&dst_file_path,
|
||||
@@ -590,6 +722,39 @@ 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();
|
||||
@@ -619,13 +784,17 @@ 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 = 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 = 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
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(old_data_dir) = has_old_data_dir.as_ref() {
|
||||
let _ = xlmeta.data.remove_two(version_id, *old_data_dir);
|
||||
}
|
||||
@@ -639,7 +808,10 @@ 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.
|
||||
let tmp_meta_sync = if durability.syncs_commit_metadata() {
|
||||
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() {
|
||||
SyncMode::FileOnly
|
||||
} else {
|
||||
SyncMode::None
|
||||
@@ -659,7 +831,9 @@ 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 = commit.source_metadata.to_path_buf();
|
||||
let src_file_path = multipart_publication
|
||||
.as_ref()
|
||||
.map_or_else(|| commit.source_metadata.to_path_buf(), |publication| publication.prepared_path.clone());
|
||||
let dst_file_path = dst_file_path.clone();
|
||||
let owned_directory_guard = commit.directory_guard.clone();
|
||||
let owned_mutation_lease = commit.mutation_lease.clone();
|
||||
@@ -696,6 +870,10 @@ 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,
|
||||
@@ -734,30 +912,49 @@ 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()
|
||||
&& let Err(err) = os::rename_all_with_commit_guard(
|
||||
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(
|
||||
src_data_path,
|
||||
dst_data_path,
|
||||
&skip_parent,
|
||||
commit.publication_root,
|
||||
&commit.directory_guard,
|
||||
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() {
|
||||
@@ -795,7 +992,9 @@ impl LocalDisk {
|
||||
} else {
|
||||
SyncMode::None
|
||||
};
|
||||
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
|
||||
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)
|
||||
{
|
||||
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 {
|
||||
@@ -887,7 +1086,11 @@ impl LocalDisk {
|
||||
return Err(err);
|
||||
}
|
||||
if should_fail_after_metadata_commit(dst_path) {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
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)?;
|
||||
}
|
||||
return Err(DiskError::Unexpected);
|
||||
}
|
||||
|
||||
@@ -914,7 +1117,11 @@ impl LocalDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
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)?;
|
||||
}
|
||||
// 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).
|
||||
@@ -951,7 +1158,12 @@ impl LocalDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
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)?;
|
||||
}
|
||||
// Same post-commit rollback window as above — drop cached
|
||||
// dst part fds so readers re-open the restored inode
|
||||
// (rustfs/backlog#1177).
|
||||
@@ -977,14 +1189,11 @@ impl LocalDisk {
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
|
||||
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;
|
||||
}
|
||||
if multipart_publication.is_none()
|
||||
&& !preserve_upload_metadata
|
||||
&& let Some(parent) = src_file_path.parent()
|
||||
{
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
|
||||
@@ -1002,9 +1211,24 @@ 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,
|
||||
rollback_data_dir: if multipart_publication.as_ref().is_some_and(|publication| !publication.legacy) {
|
||||
None
|
||||
} else {
|
||||
rollback_data_dir
|
||||
},
|
||||
cleanup_data_dir: has_old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
@@ -1017,25 +1241,47 @@ 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();
|
||||
#[cfg(not(windows))]
|
||||
let cleanup_src_parent = cleanup_path.is_none();
|
||||
let cleanup_src_parent = multipart_publication.is_none() && !preserve_upload_metadata;
|
||||
#[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
|
||||
@@ -1045,7 +1291,10 @@ 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)]
|
||||
@@ -1079,7 +1328,9 @@ 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 fi_healing {
|
||||
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
|
||||
condition.has_parent().then_some(condition.attempt_id)
|
||||
} else if fi_healing {
|
||||
None
|
||||
} else {
|
||||
old_data_dir.or_else(|| {
|
||||
@@ -1102,13 +1353,61 @@ 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.
|
||||
prepared_metadata_source.write_all(&new_buf, sync)?;
|
||||
{
|
||||
let _new_file = os::StageTimer::new(native_timing, "pool_meta_native_staged_write_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"))?
|
||||
@@ -1117,9 +1416,13 @@ impl LocalDisk {
|
||||
backup_path
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let backup_path = create_local_inline_rollback_backup(&dst, &src, old_metadata)?;
|
||||
let backup_path = {
|
||||
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
|
||||
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));
|
||||
@@ -1178,15 +1481,19 @@ impl LocalDisk {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
));
|
||||
}
|
||||
|
||||
Ok::<_, std::io::Error>(InlinePreparationOutcome::Prepared((
|
||||
@@ -1199,8 +1506,11 @@ 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(),
|
||||
@@ -1213,6 +1523,7 @@ impl LocalDisk {
|
||||
}
|
||||
.map_err(to_file_error)
|
||||
.map_err(DiskError::from);
|
||||
drop(preparation_total);
|
||||
let (
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir,
|
||||
@@ -1223,22 +1534,29 @@ impl LocalDisk {
|
||||
mut local_rollback_path,
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
owned_backup,
|
||||
receipt_candidate,
|
||||
) = match inline_preparation {
|
||||
Ok(InlinePreparationOutcome::Committed(resp)) => {
|
||||
Ok(InlinePreparationOutcome::Committed(resp, receipt_candidate)) => {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if cleanup_src_parent && 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) => {
|
||||
@@ -1272,6 +1590,42 @@ 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))
|
||||
{
|
||||
@@ -1284,21 +1638,24 @@ 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());
|
||||
if let Err(err) = os::rename_all_with_owner(
|
||||
let backup_publish = os::StageTimer::new(native_timing, "pool_meta_native_backup_publish");
|
||||
let backup_result = os::rename_all_with_owner(
|
||||
staged_backup,
|
||||
&backup_path,
|
||||
&dst_volume_dir,
|
||||
commit.publication_root,
|
||||
Some(commit.mutation_lease.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
drop(backup_publish);
|
||||
if let Err(err) = backup_result {
|
||||
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
|
||||
@@ -1309,6 +1666,7 @@ 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)
|
||||
@@ -1328,6 +1686,7 @@ 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 {
|
||||
@@ -1342,6 +1701,7 @@ 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);
|
||||
@@ -1362,6 +1722,7 @@ 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,
|
||||
@@ -1442,6 +1803,12 @@ 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)]
|
||||
@@ -1452,9 +1819,7 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
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() {
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
@@ -1473,7 +1838,12 @@ 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,
|
||||
|
||||
@@ -32,6 +32,7 @@ impl DiscoverySession {
|
||||
}
|
||||
}
|
||||
let mut transactions = BTreeSet::new();
|
||||
let mut seen_candidates = HashSet::new();
|
||||
let mut visited = 0usize;
|
||||
while !self.frames.is_empty()
|
||||
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
|
||||
@@ -91,6 +92,9 @@ impl DiscoverySession {
|
||||
.find_map(|prefix| delete_transaction_marker_id(&name, prefix))
|
||||
};
|
||||
if let Some(transaction) = transaction {
|
||||
if !seen_candidates.insert((object.to_owned(), transaction)) {
|
||||
continue;
|
||||
}
|
||||
let physical = match self.object.as_ref() {
|
||||
Some(_) => self.root.clone(),
|
||||
None => self.root.join(object),
|
||||
@@ -101,15 +105,20 @@ impl DiscoverySession {
|
||||
continue;
|
||||
}
|
||||
let proof_root = physical.join(transaction.to_string());
|
||||
if self.object.is_some() {
|
||||
regular_file_exists(&proof_root.join(PREPARED)).map_err(std::io::Error::from)?;
|
||||
regular_file_exists(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE)).map_err(std::io::Error::from)?;
|
||||
transactions.insert((object.to_owned(), transaction));
|
||||
continue;
|
||||
}
|
||||
let prepared = regular_bytes(&proof_root.join(PREPARED)).map_err(std::io::Error::from)?;
|
||||
let receipt =
|
||||
regular_bytes(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE)).map_err(std::io::Error::from)?;
|
||||
// Only a fully bound journal can name a logical
|
||||
// key. Other records remain discoverable by their
|
||||
// physical key for canonical inspection to reject.
|
||||
let record = match prepared {
|
||||
Some(bytes) => serde_json::from_slice::<DeleteRecoveryJournal>(&bytes).ok(),
|
||||
None => receipt
|
||||
None => regular_bytes(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE))
|
||||
.map_err(std::io::Error::from)?
|
||||
.filter(|bytes| !bytes.is_empty())
|
||||
.and_then(|bytes| serde_json::from_slice::<CommitReceipt>(&bytes).ok())
|
||||
.map(|receipt| receipt.journal),
|
||||
@@ -280,11 +289,19 @@ fn regular_metadata_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn regular_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.len() > MAX_METADATA) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
fn regular_file_exists(path: &Path) -> Result<bool> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_file() && meta.len() <= MAX_METADATA => Ok(true),
|
||||
Ok(_) => Err(DiskError::FileCorrupt),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(to_file_error(err).into()),
|
||||
}
|
||||
regular_metadata_bytes(path)
|
||||
}
|
||||
|
||||
fn regular_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
regular_file_exists(path)?
|
||||
.then(|| std::fs::read(path).map_err(|err| DiskError::from(to_file_error(err))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// A write/heal backup is not a delete transaction when the live metadata
|
||||
@@ -299,7 +316,7 @@ fn live_write_backup(root: &Path, transaction: Uuid) -> Result<bool> {
|
||||
DELETE_MARKER_ROLLBACK_FILE,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
] {
|
||||
if regular_bytes(&rollback.join(name))?.is_some() {
|
||||
if regular_file_exists(&rollback.join(name))? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
@@ -309,7 +326,7 @@ fn live_write_backup(root: &Path, transaction: Uuid) -> Result<bool> {
|
||||
RECOVERY_DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
] {
|
||||
let name = format!("{prefix}{transaction}");
|
||||
if regular_bytes(&root.join(&name))?.is_some() || regular_bytes(&rollback.join(name))?.is_some() {
|
||||
if regular_file_exists(&root.join(&name))? || regular_file_exists(&rollback.join(name))? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
@@ -530,13 +547,11 @@ struct ExplicitDeletePlan {
|
||||
proof_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn explicit_delete_plan(
|
||||
root: &Path,
|
||||
transaction: Uuid,
|
||||
fn explicit_delete_referenced_data_dirs(
|
||||
original: Option<&FileMeta>,
|
||||
journal: Option<&DeleteRecoveryJournal>,
|
||||
current_missing: bool,
|
||||
) -> Result<Option<ExplicitDeletePlan>> {
|
||||
) -> Result<Option<HashSet<Uuid>>> {
|
||||
if !current_missing
|
||||
|| journal
|
||||
.is_some_and(|journal| journal.target_digest.is_some() || (journal.original_digest.is_some() && original.is_none()))
|
||||
@@ -562,6 +577,19 @@ fn explicit_delete_plan(
|
||||
}
|
||||
referenced.extend(metadata.get_data_dirs()?.into_iter().flatten().filter(|id| !id.is_nil()));
|
||||
}
|
||||
Ok(Some(referenced))
|
||||
}
|
||||
|
||||
fn explicit_delete_plan(
|
||||
root: &Path,
|
||||
transaction: Uuid,
|
||||
original: Option<&FileMeta>,
|
||||
journal: Option<&DeleteRecoveryJournal>,
|
||||
current_missing: bool,
|
||||
) -> Result<Option<ExplicitDeletePlan>> {
|
||||
let Some(referenced) = explicit_delete_referenced_data_dirs(original, journal, current_missing)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rollback = root.join(transaction.to_string());
|
||||
let marker_names = [
|
||||
DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
@@ -669,7 +697,12 @@ fn canonical_uuid(name: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(name).ok().filter(|id| name == id.hyphenated().to_string())
|
||||
}
|
||||
|
||||
fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Result<DeleteRecoveryInspection> {
|
||||
pub(super) fn inspection_with_markers(
|
||||
root: &Path,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
) -> Result<(DeleteRecoveryInspection, bool)> {
|
||||
let rollback = root.join(transaction.to_string());
|
||||
let before = regular_metadata_bytes(&rollback.join(STORAGE_FORMAT_FILE_BACKUP))?;
|
||||
let current = regular_metadata_bytes(&root.join(STORAGE_FORMAT_FILE))?;
|
||||
@@ -739,6 +772,8 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
}
|
||||
fingerprint.update([u8::from(committed), u8::from(finished)]);
|
||||
let mut any_marker = false;
|
||||
let mut has_other_uuid_dir = false;
|
||||
let mut has_staged_dir = false;
|
||||
let mut descriptions = Vec::new();
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => Some(entries),
|
||||
@@ -762,6 +797,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
}
|
||||
descriptions.push((name.clone(), kind.is_dir(), entry.metadata().map_err(to_file_error)?.len()));
|
||||
if kind.is_dir() && id.is_some_and(|id| !id.is_nil()) {
|
||||
has_other_uuid_dir |= id != Some(transaction);
|
||||
for child in std::fs::read_dir(entry.path()).map_err(to_file_error)? {
|
||||
let child = child.map_err(to_file_error)?;
|
||||
let kind = child.file_type().map_err(to_file_error)?;
|
||||
@@ -807,6 +843,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if name == transaction.to_string() && kind.is_dir() {
|
||||
has_staged_dir = true;
|
||||
for file in std::fs::read_dir(child.path()).map_err(to_file_error)? {
|
||||
let file = file.map_err(to_file_error)?;
|
||||
let kind = file.file_type().map_err(to_file_error)?;
|
||||
@@ -924,9 +961,18 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
} else {
|
||||
DeleteRecoveryState::Absent
|
||||
};
|
||||
let explicit_delete_safe =
|
||||
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some();
|
||||
Ok(DeleteRecoveryInspection {
|
||||
let has_physical_data_dirs_or_markers = any_marker || has_other_uuid_dir || has_staged_dir;
|
||||
let explicit_delete_safe = match state {
|
||||
DeleteRecoveryState::NewPrepared if !has_physical_data_dirs_or_markers => {
|
||||
explicit_delete_referenced_data_dirs(before_meta.as_ref(), journal.as_ref(), current.is_none())?
|
||||
.is_some_and(|referenced| !referenced.contains(&transaction))
|
||||
}
|
||||
DeleteRecoveryState::LegacyAmbiguous | DeleteRecoveryState::NewPrepared => {
|
||||
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let inspection = DeleteRecoveryInspection {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
id: DeleteTransactionId {
|
||||
object: object.to_owned(),
|
||||
@@ -950,10 +996,22 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
fingerprint: fingerprint.finalize().into(),
|
||||
})
|
||||
};
|
||||
Ok((inspection, has_physical_data_dirs_or_markers))
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
// Native MPU source closure has its own conditional protocol. Generic
|
||||
// object recovery must never restore its head or acquire SourceRW under
|
||||
// an already-owned object metadata lease.
|
||||
fn reject_multipart_delete_recovery_mutation(&self, volume: &str, object: &str) -> Result<()> {
|
||||
multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), volume, object)?;
|
||||
if volume == RUSTFS_META_MULTIPART_BUCKET {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_recovery_predecessors(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -1196,12 +1254,25 @@ impl LocalDisk {
|
||||
transaction: Uuid,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
Ok(self
|
||||
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
|
||||
async fn delete_recovery_inspect_owned_with_markers(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<(DeleteRecoveryInspection, bool)> {
|
||||
let root = self.io_get_object_path(volume, object)?;
|
||||
self.io_get_object_path(volume, &format!("{object}/{transaction}"))?;
|
||||
let volume = volume.to_owned();
|
||||
let object = object.to_owned();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
|
||||
os::run_blocking_namespace_operation(lease, move || Ok(inspection(&root, &volume, &object, transaction)))
|
||||
os::run_blocking_namespace_operation(lease, move || Ok(inspection_with_markers(&root, &volume, &object, transaction)))
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
}
|
||||
@@ -1219,15 +1290,16 @@ impl LocalDisk {
|
||||
let journal = journal_bytes(journal)?;
|
||||
self.wait_for_startup_cleanup().await;
|
||||
let temporary = self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?;
|
||||
let root = self.io_get_object_path(volume, object)?;
|
||||
let (bucket, root) = self.io_get_bucket_and_object_path(volume, object)?;
|
||||
let destination = root.join(transaction.to_string());
|
||||
let destination_was_absent = original.is_none();
|
||||
let original = original.map(<[u8]>::to_vec);
|
||||
let sync = effective_durability(volume).syncs_commit_metadata();
|
||||
#[cfg(test)]
|
||||
let record_destination = destination.clone();
|
||||
let staging_root = temporary.clone();
|
||||
let staging_root = temporary;
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, owner.clone()).await;
|
||||
let (staged, target) = os::run_blocking_namespace_operation(lease, move || {
|
||||
let (staged, target) = os::run_blocking_namespace_operation_with_sync(lease, sync, move || {
|
||||
std::fs::create_dir_all(&staging_root)?;
|
||||
let staged = Arc::new(
|
||||
tempfile::Builder::new()
|
||||
@@ -1248,8 +1320,8 @@ impl LocalDisk {
|
||||
run_owned_file_write_before_open(&record_destination.join(name));
|
||||
let mut file = std::fs::File::create(staged.path().join(name))?;
|
||||
std::io::Write::write_all(&mut file, bytes)?;
|
||||
if sync {
|
||||
file.sync_all()?;
|
||||
if sync && !bytes.is_empty() {
|
||||
file.sync_data()?;
|
||||
}
|
||||
}
|
||||
if sync {
|
||||
@@ -1280,25 +1352,26 @@ impl LocalDisk {
|
||||
// The directory owner accompanies the blocking rename, so cancelling
|
||||
// its waiter cannot delete staged files while activation is running.
|
||||
let publication_owner: Option<Arc<dyn Send + Sync>> = Some(Arc::new((owner.clone(), staged.clone())));
|
||||
os::rename_all_with_owner(
|
||||
staged.path(),
|
||||
&destination,
|
||||
&self.io_get_bucket_path(volume)?,
|
||||
&self.publication_root,
|
||||
publication_owner,
|
||||
)
|
||||
.await?;
|
||||
os::rename_all_with_owner(staged.path(), &destination, &bucket, &self.publication_root, publication_owner).await?;
|
||||
if sync {
|
||||
let bucket = self.io_get_bucket_path(volume)?;
|
||||
let mut dirs_to_sync = Vec::new();
|
||||
let mut path = root.as_path();
|
||||
loop {
|
||||
os::fsync_dir_with_owner(path, owner.clone()).await.map_err(to_file_error)?;
|
||||
if path == bucket {
|
||||
dirs_to_sync.push(path.to_path_buf());
|
||||
if !destination_was_absent || path == bucket {
|
||||
break;
|
||||
}
|
||||
path = path.parent().ok_or(DiskError::InvalidPath)?;
|
||||
}
|
||||
os::fsync_dir_with_owner(&temporary, owner.clone())
|
||||
let sync_owner = owner.clone();
|
||||
self.blocking_queue
|
||||
.run_fsync_cancellable(move |_| {
|
||||
let _owner = sync_owner;
|
||||
for dir in dirs_to_sync {
|
||||
os::fsync_dir_std(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
@@ -1315,6 +1388,8 @@ impl LocalDisk {
|
||||
authority: Option<&DeleteRecoveryJournal>,
|
||||
context: DeletePreparationContext,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
|
||||
let DeletePreparationContext {
|
||||
transaction,
|
||||
bucket_incarnation: incarnation,
|
||||
@@ -1406,6 +1481,8 @@ impl LocalDisk {
|
||||
action: DeleteRecoveryAction,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
|
||||
if !safe_path(object, false) || transaction.is_nil() {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
@@ -1445,8 +1522,10 @@ impl LocalDisk {
|
||||
action: DeleteRecoveryAction,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
let mut observed = self
|
||||
.delete_recovery_inspect_owned(volume, object, transaction, owner.clone())
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
|
||||
let (mut observed, mut has_data_dir_work) = self
|
||||
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
if observed.fingerprint != expected_fingerprint {
|
||||
return Err(DiskError::OutdatedXLMeta);
|
||||
@@ -1572,8 +1651,8 @@ impl LocalDisk {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
observed = self
|
||||
.delete_recovery_inspect_owned(volume, object, transaction, owner.clone())
|
||||
(observed, has_data_dir_work) = self
|
||||
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
} else {
|
||||
return Ok(observed);
|
||||
@@ -1680,17 +1759,22 @@ impl LocalDisk {
|
||||
journal: journal.clone(),
|
||||
finished: false,
|
||||
};
|
||||
write_delete_rollback_file(
|
||||
let inspect_after = (!cfg!(feature = "e2e-test-hooks")).then_some(object);
|
||||
let inspected = write_delete_rollback_files(
|
||||
self.io_root(),
|
||||
volume,
|
||||
&root,
|
||||
transaction,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
&serde_json::to_vec(&receipt)?,
|
||||
&[],
|
||||
&[(DELETE_QUORUM_COMMITTED_FILE, &serde_json::to_vec(&receipt)?)],
|
||||
inspect_after,
|
||||
owner.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.delete_recovery_barrier(volume, object, transaction, "DECISION").await?;
|
||||
if let Some(inspected) = inspected {
|
||||
return Ok(inspected);
|
||||
}
|
||||
}
|
||||
DeleteRecoveryAction::FinishCommit => {
|
||||
if !matches!(observed.state, DeleteRecoveryState::QuorumCommitted | DeleteRecoveryState::CommitFinished)
|
||||
@@ -1701,7 +1785,7 @@ impl LocalDisk {
|
||||
if observed.state == DeleteRecoveryState::CommitFinished {
|
||||
return Ok(observed);
|
||||
}
|
||||
for dir in &journal.data_dirs {
|
||||
for dir in journal.data_dirs.iter().filter(|_| has_data_dir_work) {
|
||||
self.reserve_version_delete_with_namespace_owner(volume, object, *dir, transaction, owner.clone())
|
||||
.await?;
|
||||
}
|
||||
@@ -1731,27 +1815,26 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
|
||||
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
if has_data_dir_work {
|
||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
os::remove_files_and_fsync_with_owner(
|
||||
&rollback,
|
||||
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE],
|
||||
effective_durability(volume).syncs_commit_metadata(),
|
||||
None,
|
||||
owner.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
let (_, complete) = self
|
||||
.finish_version_delete(volume, object, transaction, true, owner.clone(), None)
|
||||
.await?;
|
||||
if !complete {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback, owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
let (_, complete) = self
|
||||
.finish_version_delete(volume, object, transaction, true, owner.clone())
|
||||
.await?;
|
||||
if !complete {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
for dir in &journal.data_dirs {
|
||||
for dir in journal.data_dirs.iter().filter(|_| has_data_dir_work) {
|
||||
let staged = rollback.join(dir.to_string());
|
||||
match fs::symlink_metadata(&staged).await {
|
||||
Ok(metadata) if metadata.is_dir() => {
|
||||
@@ -1798,52 +1881,97 @@ impl LocalDisk {
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
write_delete_rollback_file(self.io_root(), volume, &root, transaction, FINISHED, &[], owner.clone()).await?;
|
||||
let receipt = CommitReceipt {
|
||||
journal: journal.clone(),
|
||||
finished: true,
|
||||
};
|
||||
write_delete_rollback_file(
|
||||
let receipt_bytes = serde_json::to_vec(&receipt)?;
|
||||
let remove_before_publish: &[&'static str] = if has_data_dir_work {
|
||||
&[]
|
||||
} else {
|
||||
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE]
|
||||
};
|
||||
if let Some(inspected) = write_delete_rollback_files(
|
||||
self.io_root(),
|
||||
volume,
|
||||
&root,
|
||||
transaction,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
&serde_json::to_vec(&receipt)?,
|
||||
remove_before_publish,
|
||||
&[(FINISHED, &[]), (DELETE_QUORUM_COMMITTED_FILE, &receipt_bytes)],
|
||||
Some(object),
|
||||
owner.clone(),
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
{
|
||||
return Ok(inspected);
|
||||
}
|
||||
}
|
||||
DeleteRecoveryAction::FinalizeCommit => {
|
||||
if observed.state != DeleteRecoveryState::CommitFinished {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
let bucket_path = self.io_get_bucket_path(volume)?;
|
||||
let mut file_leases = Vec::with_capacity(3);
|
||||
for name in [TARGET, PREPARED, FINISHED] {
|
||||
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
let path = rollback.join(name);
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, owner.clone()).await;
|
||||
file_leases.push((path, lease));
|
||||
}
|
||||
let receipt_path = rollback.join(DELETE_QUORUM_COMMITTED_FILE);
|
||||
let receipt_lease = os::acquire_namespace_mutation_lease_with_owner(&receipt_path, owner.clone()).await;
|
||||
let prune_paths: Vec<_> = rollback
|
||||
.ancestors()
|
||||
.take_while(|dir| *dir != bucket_path.as_path() && dir.starts_with(&bucket_path))
|
||||
.map(Path::to_path_buf)
|
||||
.collect();
|
||||
let mut prune_dirs = Vec::with_capacity(prune_paths.len());
|
||||
for dir in prune_paths {
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&dir, owner.clone()).await;
|
||||
prune_dirs.push((dir, lease));
|
||||
}
|
||||
let sync = effective_durability(volume).syncs_commit_metadata();
|
||||
let (rollback_buf, root_buf, volume_str, object_str) =
|
||||
(rollback.clone(), root.clone(), volume.to_owned(), object.to_owned());
|
||||
let publication_root = self.publication_root.clone();
|
||||
return os::run_blocking_namespace_operation_with_sync(receipt_lease, sync, move || {
|
||||
let _publication_root = publication_root;
|
||||
for (path, _) in &file_leases {
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, path);
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback, owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
os::remove_file_with_owner(rollback.join(DELETE_QUORUM_COMMITTED_FILE), owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
self.delete_unleased_with_namespace_owner(
|
||||
volume,
|
||||
&format!("{object}/{transaction}"),
|
||||
&DeleteOptions {
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
},
|
||||
owner.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if sync {
|
||||
os::fsync_dir_std(&rollback_buf)?;
|
||||
}
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &receipt_path);
|
||||
std::fs::remove_file(&receipt_path)?;
|
||||
for (dir_path, _) in &prune_dirs {
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, dir_path);
|
||||
if let Err(err) = std::fs::remove_dir(dir_path) {
|
||||
if is_dir_not_empty_error(&err) {
|
||||
break;
|
||||
}
|
||||
if !is_benign_object_rmdir_error(&err) {
|
||||
return Err(std::io::Error::other(FileAccessDeniedWithContext {
|
||||
path: dir_path.clone(),
|
||||
source: err,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inspected =
|
||||
inspection_with_markers(&root_buf, &volume_str, &object_str, transaction).map(|(state, _)| state);
|
||||
drop((file_leases, prune_dirs));
|
||||
Ok(inspected)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
DeleteRecoveryAction::Rollback => {
|
||||
if observed.state != DeleteRecoveryState::NewPrepared || !observed.rollback_data_complete {
|
||||
@@ -2605,6 +2733,149 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_known_heal_backup_inspection_preserves_unproven_owners_after_reopen() {
|
||||
for change in [
|
||||
"unchanged",
|
||||
"generation",
|
||||
"tier-object",
|
||||
"tier-version",
|
||||
"missing-free-owner",
|
||||
"replaced-backup",
|
||||
] {
|
||||
let (_temp, disk, mut info, _) = fixture().await;
|
||||
info.metadata.insert("etag".to_owned(), "same-visible-etag".to_owned());
|
||||
info.set_object_transaction_epoch(Uuid::new_v4());
|
||||
let free_id = Uuid::new_v4();
|
||||
info.set_tier_free_version_id(&free_id.to_string());
|
||||
let mut remote = test_file_info("object", Uuid::nil(), Some(Uuid::new_v4()), None);
|
||||
remote.mod_time = info.mod_time;
|
||||
remote.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_owned();
|
||||
remote.transition_tier = "WARM".to_owned();
|
||||
remote.transitioned_objname = "remote/original".to_owned();
|
||||
remote.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
|
||||
remote.transition_version = Some("original-provider-version".to_owned());
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut remote.metadata,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
"ab".repeat(32),
|
||||
);
|
||||
let metadata = |remote: FileInfo, current: FileInfo| {
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(remote).expect("seed transitioned predecessor");
|
||||
meta.add_version(current)
|
||||
.expect("commit local replacement and its free-version owner");
|
||||
meta
|
||||
};
|
||||
let original = metadata(remote.clone(), info.clone());
|
||||
let (_, free) = original.find_version(Some(free_id)).expect("original free-version owner");
|
||||
assert!(free.free_version(), "fixture must retain a separate remote cleanup owner");
|
||||
let backup_bytes = original.marshal_msg().expect("encode historical backup");
|
||||
let transaction = inline_metadata_rollback_dir(Uuid::nil(), &original);
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
let backup_path = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
fs::create_dir_all(backup_path.parent().expect("backup parent"))
|
||||
.await
|
||||
.expect("create backup directory");
|
||||
fs::write(&backup_path, &backup_bytes)
|
||||
.await
|
||||
.expect("retain historical backup");
|
||||
|
||||
let mut current = info.clone();
|
||||
match change {
|
||||
"generation" => current.set_object_transaction_epoch(Uuid::new_v4()),
|
||||
"tier-object" => remote.transitioned_objname = "remote/successor".to_owned(),
|
||||
"tier-version" => remote.transition_version = Some("successor-provider-version".to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
let mut current_meta = metadata(remote, current);
|
||||
if change == "missing-free-owner" {
|
||||
current_meta
|
||||
.versions
|
||||
.retain(|version| version.header.version_id != Some(free_id));
|
||||
} else if change == "replaced-backup" {
|
||||
let replacement = root.join("replacement-backup");
|
||||
fs::write(&replacement, &backup_bytes).await.expect("write replacement inode");
|
||||
fs::rename(replacement, &backup_path)
|
||||
.await
|
||||
.expect("replace backup without changing its bytes");
|
||||
}
|
||||
let current_bytes = current_meta.marshal_msg().expect("encode current metadata");
|
||||
fs::write(root.join(STORAGE_FORMAT_FILE), ¤t_bytes)
|
||||
.await
|
||||
.expect("publish readable current object");
|
||||
let visible = disk
|
||||
.read_version(
|
||||
"",
|
||||
"recovery",
|
||||
"object",
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("current object remains physically readable");
|
||||
assert_eq!(visible.data, info.data, "{change}: current payload");
|
||||
assert_eq!(visible.mod_time, info.mod_time, "{change}: unchanged modification time");
|
||||
assert_eq!(visible.metadata.get("etag"), info.metadata.get("etag"), "{change}: unchanged ETag");
|
||||
|
||||
let before = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("query known backup without inferring commit authority");
|
||||
let endpoint = disk.endpoint();
|
||||
drop(disk);
|
||||
let reopened = LocalDisk::new(&endpoint, false).await.expect("reopen original disk endpoint");
|
||||
// Linux I/O paths belong to the open disk's mount descriptor.
|
||||
let root = reopened
|
||||
.io_get_object_path("recovery", "object")
|
||||
.expect("reopened object path");
|
||||
let backup_path = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
let after = reopened
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("known historical backup remains queryable after reopen");
|
||||
assert_eq!(after, before, "{change}: inspection must preserve the complete evidence snapshot");
|
||||
assert!(after.journal.is_none(), "{change}: no historical success or delete authority exists");
|
||||
assert_eq!(after.backup_digest, Some(digest(&backup_bytes)), "{change}: retained C-class evidence");
|
||||
assert!(!after.explicit_delete_safe, "{change}: current data forbids legacy discard");
|
||||
let expected_state = if matches!(change, "unchanged" | "replaced-backup") {
|
||||
DeleteRecoveryState::Absent
|
||||
} else {
|
||||
DeleteRecoveryState::LegacyAmbiguous
|
||||
};
|
||||
assert_eq!(
|
||||
after.state, expected_state,
|
||||
"{change}: delete-owner semantics remain separate from C classification"
|
||||
);
|
||||
let page = reopened
|
||||
.discover_delete_transactions("recovery", Some("object"), None, 128)
|
||||
.await
|
||||
.expect("discover only delete-recovery work");
|
||||
assert_eq!(
|
||||
page.transactions.iter().any(|id| id.transaction == transaction),
|
||||
expected_state == DeleteRecoveryState::LegacyAmbiguous,
|
||||
"{change}: a live backup must not become a delete blocker"
|
||||
);
|
||||
assert_eq!(fs::read(&backup_path).await.expect("retained backup bytes"), backup_bytes, "{change}");
|
||||
assert_eq!(
|
||||
fs::read(root.join(STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
.expect("retained current bytes"),
|
||||
current_bytes,
|
||||
"{change}"
|
||||
);
|
||||
let backup = FileMeta::load(&backup_bytes).expect("decode retained owner evidence");
|
||||
assert_eq!(
|
||||
backup.find_version(Some(free_id)).expect("retained free-version owner").1,
|
||||
free,
|
||||
"{change}: ordinary-object readability cannot release remote ownership"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_live_external_backup_rejects_partial_version_delete() {
|
||||
let (_temp, disk, mut info, _) = fixture().await;
|
||||
@@ -2744,6 +3015,152 @@ mod tests {
|
||||
assert_eq!(disk.read_all("recovery", "object/xl.meta").await.unwrap().as_ref(), original);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_cancelled_finalize_pins_root_after_last_disk_owner_drops() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use futures::FutureExt;
|
||||
|
||||
struct ReleaseOnDrop(Option<std::sync::mpsc::Sender<()>>);
|
||||
impl Drop for ReleaseOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(release) = self.0.take() {
|
||||
let _ = release.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (temp, native, _info, _original) = fixture().await;
|
||||
let root_file = Arc::downgrade(native.publication_root.directory());
|
||||
assert!(Arc::ptr_eq(native.publication_root.directory(), &native.mount_lease));
|
||||
// The constructor's idle GC task also owns this FD. Drain that existing
|
||||
// task so it cannot mask a missing pin in the Finalize callback.
|
||||
let exit = native.exit_signal.as_ref().expect("fixture cleanup lifecycle");
|
||||
exit.send(()).expect("stop the existing idle cleanup task");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while exit.receiver_count() != 0 || root_file.strong_count() != 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("only LocalDisk's publication and mount fields may retain the initial root FD");
|
||||
let transaction = Uuid::new_v4();
|
||||
let applied = apply(&native, transaction).await;
|
||||
let committed = native
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
applied.fingerprint,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("publish actual delete decision");
|
||||
let finished = native
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
committed.fingerprint,
|
||||
DeleteRecoveryAction::FinishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("finish actual committed deletion");
|
||||
assert_eq!(finished.state, DeleteRecoveryState::CommitFinished);
|
||||
let physical_object = temp.path().join("recovery/object");
|
||||
let physical_rollback = physical_object.join(transaction.to_string());
|
||||
assert!(physical_rollback.join(PREPARED).is_file());
|
||||
assert!(physical_rollback.join(DELETE_QUORUM_COMMITTED_FILE).is_file());
|
||||
let io_root = native.io_root().to_path_buf();
|
||||
assert!(io_root.starts_with("/proc/self/fd"), "exercise the real borrowed Linux FD path");
|
||||
let canonical_root = std::fs::canonicalize(temp.path()).expect("actual root identity");
|
||||
let fd_alias = io_root.components().collect::<PathBuf>();
|
||||
let io_rollback = native
|
||||
.io_get_object_path("recovery", &format!("object/{transaction}"))
|
||||
.expect("actual IO rollback path");
|
||||
let receipt_path = io_rollback.join(DELETE_QUORUM_COMMITTED_FILE);
|
||||
let (entered, reached) = tokio::sync::oneshot::channel();
|
||||
let (release, resumed) = std::sync::mpsc::channel::<()>();
|
||||
let release = ReleaseOnDrop(Some(release));
|
||||
let hook = hooks::install_at(hooks::Stage::Remove, &io_rollback.join(PREPARED), move || {
|
||||
let _ = entered.send(());
|
||||
let _ = resumed.recv();
|
||||
});
|
||||
let mut native = Some(Arc::new(native));
|
||||
let weak = Arc::downgrade(native.as_ref().expect("fixture disk owner"));
|
||||
let finalizing = native.as_ref().expect("fixture disk owner").clone();
|
||||
let mut task = Some(tokio::spawn(async move {
|
||||
finalizing
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
finished.fingerprint,
|
||||
DeleteRecoveryAction::FinalizeCommit,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
let observations = std::panic::AssertUnwindSafe(async {
|
||||
tokio::time::timeout(Duration::from_secs(10), reached)
|
||||
.await
|
||||
.expect("actual Finalize remove callback must pause")
|
||||
.expect("actual physical Remove hook");
|
||||
let cancelling = task.take().expect("live finalize waiter");
|
||||
cancelling.abort();
|
||||
assert!(cancelling.await.expect_err("cancel the real waiter").is_cancelled());
|
||||
drop(native.take());
|
||||
assert!(weak.upgrade().is_none(), "no test or cancelled future may retain a LocalDisk");
|
||||
assert_eq!(
|
||||
root_file.strong_count(),
|
||||
1,
|
||||
"only the actual callback may retain the root FD after disk retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_link(&fd_alias).expect("the physical callback must still own its root FD"),
|
||||
canonical_root,
|
||||
"the borrowed FD alias must keep the same actual disk identity"
|
||||
);
|
||||
assert!(
|
||||
physical_rollback.join(PREPARED).is_file(),
|
||||
"paused callback has not removed the actual prepared record"
|
||||
);
|
||||
assert!(
|
||||
physical_rollback.join(DELETE_QUORUM_COMMITTED_FILE).is_file(),
|
||||
"the actual final witness still exists before resume"
|
||||
);
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
if let Some(task) = task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
drop(native.take());
|
||||
drop(release);
|
||||
drop(hook);
|
||||
// This path-only lease does not hold a root handle. Acquiring it proves
|
||||
// the actual callback, including its inline inspection, has returned.
|
||||
let drained = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
os::acquire_namespace_mutation_lease_with_owner(&receipt_path, None),
|
||||
)
|
||||
.await
|
||||
.map(drop);
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
drained.expect("cancelled physical Finalize callback must finish");
|
||||
for name in [TARGET, PREPARED, FINISHED, DELETE_QUORUM_COMMITTED_FILE] {
|
||||
assert!(
|
||||
!physical_rollback.join(name).exists(),
|
||||
"actual original-volume proof must be reclaimed: {name}"
|
||||
);
|
||||
}
|
||||
assert!(!physical_rollback.exists(), "the real rollback directory is pruned after proof removal");
|
||||
assert!(!physical_object.exists(), "completed deletion leaves no original-volume object residue");
|
||||
assert!(temp.path().join("recovery").is_dir(), "finalization must preserve its original volume");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_activation_is_complete_before_metadata_mutation_even_after_cancel() {
|
||||
@@ -5233,3 +5650,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multipart_scope_tests {
|
||||
include!("multipart_delete_recovery_scope_tests.rs");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
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 {
|
||||
pub async fn root_missing(&self) -> Result<bool> {
|
||||
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 || {
|
||||
match os::pin_multipart_source_directory(&source, &base, &root) {
|
||||
Ok(_) => Ok(false),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
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: self.root_missing().await?,
|
||||
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<()> {
|
||||
if let Some(raw) = read_optional(&self.source.join(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(&self.source, &self.base, &self.root)? {
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
// 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");
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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
+262
-13
@@ -27,8 +27,11 @@ mod io_admission;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod lifecycle_test_io;
|
||||
pub mod local;
|
||||
pub mod multipart_source;
|
||||
pub mod os;
|
||||
|
||||
pub use local::pool_meta_native::{PoolMetaNativeCasCondition, PoolMetaNativeFileIdentity};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use io_admission::DiskIoClass;
|
||||
pub(crate) use io_admission::{
|
||||
@@ -49,6 +52,9 @@ pub const FORMAT_CONFIG_FILE: &str = "format.json";
|
||||
pub const HEALING_MARKER_PATH: &str = "healing.bin";
|
||||
pub const STORAGE_FORMAT_FILE: &str = "xl.meta";
|
||||
pub const STORAGE_FORMAT_FILE_BACKUP: &str = "xl.meta.bkp";
|
||||
/// Original upload authority after it stops admitting active part mutations.
|
||||
pub(crate) const MULTIPART_RECOVERY_DIR: &str = ".abort-authority";
|
||||
pub(crate) const MULTIPART_COMMIT_METADATA_FILE: &str = "xl.meta.complete";
|
||||
pub const PART_TRANSACTION_NEW_META: &str = "new.meta";
|
||||
pub const PART_TRANSACTION_OLD_META: &str = "old.meta";
|
||||
pub const PART_TRANSACTION_ROLLBACK: &str = "rollback";
|
||||
@@ -82,12 +88,8 @@ use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||
use rustfs_madmin::info_commands::DiskMetrics;
|
||||
use rustfs_rio::ChunkReaderBox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
path::PathBuf,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
time::Duration,
|
||||
};
|
||||
use sha2::Digest as _;
|
||||
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use uuid::Uuid;
|
||||
@@ -141,6 +143,73 @@ pub fn is_quota_mutation_fence_path(path: &str) -> bool {
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
/// Objects admitted by the native pool metadata recovery protocol.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PoolMetaNativeObject {
|
||||
PoolBin,
|
||||
}
|
||||
|
||||
pub const POOL_META_NATIVE_CAS_METADATA_SUFFIX: &str = PoolMetaNativeCasCondition::METADATA_SUFFIX;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PoolMetaNativeBackup {
|
||||
pub directory: Uuid,
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PoolMetaNativeInspection {
|
||||
pub disk_id: Uuid,
|
||||
/// Present only when pool.bin.identity is co-located on this disk.
|
||||
/// None describes placement, not a missing identity in its owning set.
|
||||
pub identity: Option<Bytes>,
|
||||
pub current: Option<Bytes>,
|
||||
pub backups: Vec<PoolMetaNativeBackup>,
|
||||
}
|
||||
|
||||
impl PoolMetaNativeInspection {
|
||||
/// Bind a repair to the complete observed inventory, independent of listing order.
|
||||
pub fn inventory_sha256(&self) -> Result<[u8; 32]> {
|
||||
let mut backups = self.backups.iter().collect::<Vec<_>>();
|
||||
backups.sort_unstable_by_key(|backup| backup.directory);
|
||||
if backups.windows(2).any(|pair| pair[0].directory == pair[1].directory) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let encoded =
|
||||
rmp_serde::to_vec_named(&(self.disk_id, &self.identity, self.current.as_ref(), backups)).map_err(Error::other)?;
|
||||
Ok(sha2::Sha256::digest(encoded).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PoolMetaNativeRecoveryRequest {
|
||||
Probe {},
|
||||
InspectIdentity {},
|
||||
Inspect {
|
||||
object: PoolMetaNativeObject,
|
||||
},
|
||||
Apply {
|
||||
object: PoolMetaNativeObject,
|
||||
expected_disk_id: Uuid,
|
||||
expected_current_sha256: Option<[u8; 32]>,
|
||||
expected_backup_inventory_sha256: [u8; 32],
|
||||
replacement: Bytes,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "result", content = "value", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PoolMetaNativeRecoveryResponse {
|
||||
Probed,
|
||||
IdentityInspected { disk_id: Uuid, raw: Bytes },
|
||||
Inspected(PoolMetaNativeInspection),
|
||||
Applied,
|
||||
}
|
||||
|
||||
pub type DiskStore = Arc<Disk>;
|
||||
|
||||
pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
|
||||
@@ -729,6 +798,50 @@ impl DiskAPI for Disk {
|
||||
.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> {
|
||||
match self {
|
||||
Disk::Local(disk) => {
|
||||
disk.rename_data_with_pool_meta_cas(src_volume, src_path, fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
Disk::Remote(disk) => {
|
||||
disk.rename_data_with_pool_meta_cas(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<()> {
|
||||
match self {
|
||||
Disk::Local(disk) => disk.undo_pool_meta_write(volume, path, fi, opts).await,
|
||||
Disk::Remote(disk) => disk.undo_pool_meta_write(volume, path, fi, opts).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(
|
||||
&self,
|
||||
request: multipart_source::MultipartSourceRequest,
|
||||
) -> Result<multipart_source::MultipartSourceResponse> {
|
||||
match self {
|
||||
Disk::Local(disk) => disk.recover_multipart_source(request).await,
|
||||
Disk::Remote(disk) => disk.recover_multipart_source(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn pool_meta_native_recovery(&self, request: PoolMetaNativeRecoveryRequest) -> Result<PoolMetaNativeRecoveryResponse> {
|
||||
match self {
|
||||
Disk::Local(disk) => disk.pool_meta_native_recovery(request).await,
|
||||
Disk::Remote(disk) => disk.pool_meta_native_recovery(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn list_dir(&self, _origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
|
||||
match self {
|
||||
@@ -1420,6 +1533,32 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp>;
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
_src_volume: &str,
|
||||
_src_path: &str,
|
||||
_file_info: FileInfo,
|
||||
_dst_volume: &str,
|
||||
_dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
|
||||
async fn undo_pool_meta_write(&self, _volume: &str, _path: &str, _file_info: FileInfo, _opts: DeleteOptions) -> Result<()> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(
|
||||
&self,
|
||||
_request: multipart_source::MultipartSourceRequest,
|
||||
) -> Result<multipart_source::MultipartSourceResponse> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
|
||||
async fn pool_meta_native_recovery(&self, _request: PoolMetaNativeRecoveryRequest) -> Result<PoolMetaNativeRecoveryResponse> {
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
}
|
||||
|
||||
// File operations.
|
||||
// Read every file and directory within the folder
|
||||
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>>;
|
||||
@@ -1910,12 +2049,6 @@ pub struct WalkDirOptions {
|
||||
// Override the remote stream stall timeout for long background walks.
|
||||
#[serde(default)]
|
||||
pub stall_timeout_ms: Option<u64>,
|
||||
|
||||
/// In-process completion state for bounded local walks. This is skipped
|
||||
/// from RPC serialization; remote peers retain the legacy natural-EOF
|
||||
/// behavior until they support an explicit capability.
|
||||
#[serde(skip)]
|
||||
pub producer_limit_reached: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl WalkDirOptions {
|
||||
@@ -1963,6 +2096,9 @@ pub enum OldCurrentSize {
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RenameDataResp {
|
||||
/// Native-only source ownership result; older peers omit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multipart_source: Option<Box<multipart_source::MultipartPublicationResult>>,
|
||||
/// Dereferenced erasure data directory retained for older coordinators.
|
||||
pub old_data_dir: Option<Uuid>,
|
||||
/// Directory containing rollback metadata. This may be synthetic and must
|
||||
@@ -2760,7 +2896,6 @@ mod tests {
|
||||
skip_total_timeout: false,
|
||||
timeout_ms: Some(10_000),
|
||||
stall_timeout_ms: Some(20_000),
|
||||
producer_limit_reached: None,
|
||||
};
|
||||
|
||||
assert_eq!(opts.bucket, "test-bucket");
|
||||
@@ -2975,6 +3110,7 @@ mod tests {
|
||||
let signature = vec![0x01, 0x02, 0x03];
|
||||
|
||||
let resp = RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: Some(uuid),
|
||||
rollback_data_dir: Some(uuid),
|
||||
cleanup_data_dir: Some(uuid),
|
||||
@@ -2997,6 +3133,7 @@ mod tests {
|
||||
fn test_rename_data_resp_old_current_size_msgpack_roundtrip() {
|
||||
for old_current_size in [None, Some(OldCurrentSize::Absent), Some(OldCurrentSize::Present(1337))] {
|
||||
let resp = 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()),
|
||||
@@ -3584,3 +3721,115 @@ mod orphan_data_dir_protocol_tests {
|
||||
assert!(old.immediate);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pool_meta_native_inventory_tests {
|
||||
use super::*;
|
||||
|
||||
fn inspection() -> PoolMetaNativeInspection {
|
||||
PoolMetaNativeInspection {
|
||||
disk_id: Uuid::from_u128(1),
|
||||
identity: Some(Bytes::from_static(b"canonical-identity")),
|
||||
current: Some(Bytes::from_static(b"current")),
|
||||
backups: vec![
|
||||
PoolMetaNativeBackup {
|
||||
directory: Uuid::from_u128(2),
|
||||
data: Bytes::from_static(b"before-2"),
|
||||
},
|
||||
PoolMetaNativeBackup {
|
||||
directory: Uuid::from_u128(3),
|
||||
data: Bytes::from_static(b"before-3"),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_native_inventory_binds_every_copy_and_ignores_listing_order() {
|
||||
let baseline = inspection();
|
||||
let digest = baseline.inventory_sha256().expect("valid inventory must hash");
|
||||
let mut reordered = baseline.clone();
|
||||
reordered.backups.reverse();
|
||||
assert_eq!(digest, reordered.inventory_sha256().expect("listing order is irrelevant"));
|
||||
for mutate in [
|
||||
|value: &mut PoolMetaNativeInspection| value.disk_id = Uuid::from_u128(4),
|
||||
|value: &mut PoolMetaNativeInspection| value.identity = Some(Bytes::from_static(b"changed-identity")),
|
||||
|value: &mut PoolMetaNativeInspection| value.identity = None,
|
||||
|value: &mut PoolMetaNativeInspection| value.identity = Some(Bytes::new()),
|
||||
|value: &mut PoolMetaNativeInspection| value.current = None,
|
||||
|value: &mut PoolMetaNativeInspection| value.current = Some(Bytes::from_static(b"new-current")),
|
||||
|value: &mut PoolMetaNativeInspection| {
|
||||
value.backups.pop();
|
||||
},
|
||||
|value: &mut PoolMetaNativeInspection| value.backups[0].directory = Uuid::from_u128(5),
|
||||
|value: &mut PoolMetaNativeInspection| value.backups[0].data = Bytes::from_static(b"changed-backup"),
|
||||
] {
|
||||
let mut changed = baseline.clone();
|
||||
mutate(&mut changed);
|
||||
assert_ne!(digest, changed.inventory_sha256().expect("changed inventory must hash"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_native_inventory_rejects_duplicate_backup_directories() {
|
||||
let mut value = inspection();
|
||||
value.backups.push(value.backups[0].clone());
|
||||
assert_eq!(value.inventory_sha256(), Err(DiskError::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_native_recovery_payload_rejects_unknown_scope_and_trailing_bytes() {
|
||||
for request in [
|
||||
PoolMetaNativeRecoveryRequest::Probe {},
|
||||
PoolMetaNativeRecoveryRequest::Inspect {
|
||||
object: PoolMetaNativeObject::PoolBin,
|
||||
},
|
||||
PoolMetaNativeRecoveryRequest::InspectIdentity {},
|
||||
] {
|
||||
if matches!(&request, PoolMetaNativeRecoveryRequest::Probe {}) {
|
||||
assert_eq!(
|
||||
serde_json::to_value(&request).expect("probe must encode"),
|
||||
serde_json::json!({"operation":"probe"})
|
||||
);
|
||||
}
|
||||
if matches!(&request, PoolMetaNativeRecoveryRequest::InspectIdentity {}) {
|
||||
assert_eq!(
|
||||
serde_json::to_value(&request).expect("identity probe must encode"),
|
||||
serde_json::json!({"operation":"inspect_identity"})
|
||||
);
|
||||
}
|
||||
let mut encoded = rmp_serde::to_vec_named(&request).expect("request must encode");
|
||||
let decoded: PoolMetaNativeRecoveryRequest =
|
||||
rustfs_protos::decode_pool_meta_native_recovery_payload(&encoded).expect("valid request must decode");
|
||||
assert_eq!(request, decoded);
|
||||
encoded.push(0xc0);
|
||||
assert!(
|
||||
rustfs_protos::decode_pool_meta_native_recovery_payload::<PoolMetaNativeRecoveryRequest>(&encoded).is_err(),
|
||||
"trailing bytes must be rejected for {request:?}",
|
||||
);
|
||||
}
|
||||
let response = PoolMetaNativeRecoveryResponse::IdentityInspected {
|
||||
disk_id: Uuid::from_u128(7),
|
||||
raw: Bytes::from_static(b"identity-xlmeta"),
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&response).expect("identity response must encode");
|
||||
let decoded: PoolMetaNativeRecoveryResponse =
|
||||
rustfs_protos::decode_pool_meta_native_recovery_payload(&encoded).expect("identity response must decode");
|
||||
assert_eq!(response, decoded);
|
||||
for value in [
|
||||
serde_json::json!({"operation":"inspect","object":"identity"}),
|
||||
serde_json::json!({"operation":"inspect","object":"pool_bin","path":"other"}),
|
||||
serde_json::json!({"operation":"probe","object":"pool_bin"}),
|
||||
serde_json::json!({"operation":"probe","path":"other"}),
|
||||
serde_json::json!({"operation":"inspect_identity","object":"pool_bin"}),
|
||||
serde_json::json!({"operation":"inspect_identity","path":"pool.bin.identity"}),
|
||||
] {
|
||||
let encoded = rmp_serde::to_vec_named(&value).expect("invalid request fixture must encode");
|
||||
let decoded = rustfs_protos::decode_pool_meta_native_recovery_payload::<PoolMetaNativeRecoveryRequest>(&encoded);
|
||||
assert!(
|
||||
decoded.is_err(),
|
||||
"invalid native recovery payload was accepted: {value}; decoded={decoded:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Independent all-participant source closure evidence. It is never an EC vote.
|
||||
use super::*;
|
||||
|
||||
pub const NATIVE_SOURCE_PROTOCOL_SUFFIX: &str = "multipart-source-protocol";
|
||||
const NATIVE_SOURCE_PROTOCOL_VALUE: &str = "native-v1";
|
||||
const NATIVE_SOURCE_INITIAL_VALUE: &str = "native-initial-v1";
|
||||
const ABORT_PROOF_DOMAIN: &[u8] = b"rustfs-mpu-source-abort-proof-v1\0";
|
||||
|
||||
pub fn native_source_protocol(fi: &FileInfo) -> Result<bool> {
|
||||
use rustfs_utils::http::metadata_compat::{contains_key_str, get_consistent_str};
|
||||
match get_consistent_str(&fi.metadata, NATIVE_SOURCE_PROTOCOL_SUFFIX) {
|
||||
Some(NATIVE_SOURCE_PROTOCOL_VALUE | NATIVE_SOURCE_INITIAL_VALUE) => Ok(true),
|
||||
Some(_) => Err(DiskError::FileCorrupt),
|
||||
None if contains_key_str(&fi.metadata, NATIVE_SOURCE_PROTOCOL_SUFFIX) => Err(DiskError::FileCorrupt),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
pub fn mark_native_source(fi: &mut FileInfo) {
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut fi.metadata,
|
||||
NATIVE_SOURCE_PROTOCOL_SUFFIX,
|
||||
NATIVE_SOURCE_PROTOCOL_VALUE.to_owned(),
|
||||
);
|
||||
}
|
||||
/// Only the unique current NewMultipartUpload writes this origin. Legacy
|
||||
/// upgrades keep native-v1, which cannot distinguish a retained completed head.
|
||||
pub fn mark_initial_native_source(fi: &mut FileInfo) {
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut fi.metadata,
|
||||
NATIVE_SOURCE_PROTOCOL_SUFFIX,
|
||||
NATIVE_SOURCE_INITIAL_VALUE.to_owned(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A strict source-only fast-path hint. It never grants publication rights;
|
||||
/// native publication still validates the actual current SourceStore CAS.
|
||||
pub fn is_initial_native_source(fi: &FileInfo) -> Result<bool> {
|
||||
use rustfs_utils::http::metadata_compat::{
|
||||
MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX, get_consistent_str, has_internal_suffix, starts_with_ignore_ascii_case,
|
||||
};
|
||||
native_source_protocol(fi)?;
|
||||
if get_consistent_str(&fi.metadata, NATIVE_SOURCE_PROTOCOL_SUFFIX) != Some(NATIVE_SOURCE_INITIAL_VALUE) {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut rustfs = false;
|
||||
let mut minio = false;
|
||||
for key in fi
|
||||
.metadata
|
||||
.keys()
|
||||
.filter(|key| has_internal_suffix(key, NATIVE_SOURCE_PROTOCOL_SUFFIX))
|
||||
{
|
||||
rustfs |= starts_with_ignore_ascii_case(key, RUSTFS_INTERNAL_PREFIX);
|
||||
minio |= starts_with_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX);
|
||||
}
|
||||
Ok(rustfs && minio && !fi.deleted && !has_nonbirth_native_controls(fi)?)
|
||||
}
|
||||
|
||||
pub fn recognized_native_source_controls(fi: &FileInfo) -> Result<bool> {
|
||||
let marker = native_source_protocol(fi)?;
|
||||
let state = has_nonbirth_native_controls(fi)?;
|
||||
Ok(marker || state)
|
||||
}
|
||||
pub(in crate::disk) fn has_nonbirth_native_controls(fi: &FileInfo) -> Result<bool> {
|
||||
native_source_protocol(fi)?;
|
||||
let condition: Option<SourceConditionV1> = decode_metadata(&fi.metadata, MULTIPART_SOURCE_CONDITION_SUFFIX)?;
|
||||
if let Some(condition) = &condition {
|
||||
condition.validate()?;
|
||||
}
|
||||
let publication: Option<PublicationIdentity> = decode_metadata(&fi.metadata, PUBLICATION_SUFFIX)?;
|
||||
if let Some(publication) = &publication {
|
||||
publication.lineage.validate()?;
|
||||
}
|
||||
let status = source_status(fi)?;
|
||||
Ok(condition.is_some() || publication.is_some() || status.is_some())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartAbortRound {
|
||||
pub lineage: SourceLineage,
|
||||
pub generation: SourceGeneration,
|
||||
pub round: Uuid,
|
||||
}
|
||||
impl MultipartAbortRound {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.lineage.validate()?;
|
||||
if self.round.is_nil()
|
||||
|| self.generation.epoch == 0
|
||||
|| self.generation.nonce.is_nil()
|
||||
|| self.generation.nonce == self.lineage.original_data_dir
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MultipartAbortProofKind {
|
||||
Prepared,
|
||||
Committed,
|
||||
Deleted,
|
||||
CleanupTarget,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartAbortProof {
|
||||
pub kind: MultipartAbortProofKind,
|
||||
pub challenge: Uuid,
|
||||
pub round: MultipartAbortRound,
|
||||
pub snapshot: MultipartSourceSnapshot,
|
||||
pub certificate_sha256: Option<[u8; 32]>,
|
||||
pub publication: Option<PublicationIdentity>,
|
||||
authentication: ProofAuthentication,
|
||||
#[serde(skip)]
|
||||
native_origin: Option<Arc<NativeOriginWitness>>,
|
||||
}
|
||||
impl PartialEq for MultipartAbortProof {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.kind == other.kind
|
||||
&& self.challenge == other.challenge
|
||||
&& self.round == other.round
|
||||
&& self.snapshot == other.snapshot
|
||||
&& self.certificate_sha256 == other.certificate_sha256
|
||||
&& self.publication == other.publication
|
||||
&& self.authentication == other.authentication
|
||||
}
|
||||
}
|
||||
impl Eq for MultipartAbortProof {}
|
||||
impl MultipartAbortProof {
|
||||
fn body(&self) -> Result<Vec<u8>> {
|
||||
let mut body = ABORT_PROOF_DOMAIN.to_vec();
|
||||
body.extend_from_slice(
|
||||
&rmp_serde::to_vec_named(&(
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
self.kind,
|
||||
self.challenge,
|
||||
&self.round,
|
||||
&self.snapshot,
|
||||
self.certificate_sha256,
|
||||
&self.publication,
|
||||
))
|
||||
.map_err(super::super::Error::other)?,
|
||||
);
|
||||
Ok(body)
|
||||
}
|
||||
fn validate(&self) -> Result<()> {
|
||||
self.round.validate()?;
|
||||
let snapshot = &self.snapshot;
|
||||
if snapshot.disk_id.is_nil()
|
||||
|| snapshot.lineage != self.round.lineage
|
||||
|| snapshot.physical_slot >= snapshot.lineage.geometry.disks()
|
||||
|| self.challenge.is_nil()
|
||||
|| snapshot.phase != MultipartSourcePhase::Aborting
|
||||
|| snapshot.head_sha256.is_some()
|
||||
|| snapshot.capability_sha256.is_some()
|
||||
|| snapshot.generation.epoch < self.round.generation.epoch
|
||||
|| (snapshot.generation.epoch == self.round.generation.epoch && snapshot.generation != self.round.generation)
|
||||
|| (self.kind == MultipartAbortProofKind::Prepared
|
||||
&& (self.challenge != self.round.round
|
||||
|| snapshot.generation != self.round.generation
|
||||
|| self.certificate_sha256.is_some()
|
||||
|| self.publication.is_some()))
|
||||
|| (matches!(self.kind, MultipartAbortProofKind::Committed | MultipartAbortProofKind::Deleted)
|
||||
&& (self.challenge != self.round.round || self.certificate_sha256.is_none() || self.publication.is_some()))
|
||||
|| (self.kind == MultipartAbortProofKind::CleanupTarget
|
||||
&& (self.certificate_sha256.is_none()
|
||||
|| self
|
||||
.publication
|
||||
.as_ref()
|
||||
.is_none_or(|publication| publication.lineage != self.round.lineage)))
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
self.verify_with_authority(&ProofVerificationContext::RpcOnly)
|
||||
}
|
||||
pub fn verify_with_authority(&self, authority: &ProofVerificationContext) -> Result<()> {
|
||||
self.validate()?;
|
||||
let body = self.body()?;
|
||||
match &self.authentication {
|
||||
ProofAuthentication::ClusterHmac(mac) => {
|
||||
crate::cluster::rpc::verify_tonic_rpc_response_proof(&body, mac).map_err(DiskError::from)
|
||||
}
|
||||
ProofAuthentication::NativeLocal => {
|
||||
let ProofVerificationContext::Local(expected) = authority else {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
};
|
||||
let origin = self.native_origin.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
if !Arc::ptr_eq(expected, &origin.authority) || origin.body_sha256 != <[u8; 32]>::from(Sha256::digest(body)) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(in crate::disk) fn issue(
|
||||
authority: &ProofVerificationContext,
|
||||
kind: MultipartAbortProofKind,
|
||||
challenge: Uuid,
|
||||
round: MultipartAbortRound,
|
||||
snapshot: MultipartSourceSnapshot,
|
||||
certificate_sha256: Option<[u8; 32]>,
|
||||
publication: Option<PublicationIdentity>,
|
||||
) -> Result<Self> {
|
||||
let mut proof = Self {
|
||||
kind,
|
||||
challenge,
|
||||
round,
|
||||
snapshot,
|
||||
certificate_sha256,
|
||||
publication,
|
||||
authentication: ProofAuthentication::NativeLocal,
|
||||
native_origin: None,
|
||||
};
|
||||
proof.validate()?;
|
||||
match authority {
|
||||
ProofVerificationContext::Local(local) => proof.attach_origin(local)?,
|
||||
ProofVerificationContext::RpcOnly => {
|
||||
proof.authentication = ProofAuthentication::ClusterHmac(
|
||||
crate::cluster::rpc::sign_tonic_rpc_response_proof(&proof.body()?)
|
||||
.map_err(DiskError::from)?
|
||||
.try_into()
|
||||
.map_err(|_| DiskError::FileCorrupt)?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Ok(proof)
|
||||
}
|
||||
fn attach_origin(&mut self, authority: &Arc<LocalMultipartProofAuthority>) -> Result<()> {
|
||||
self.validate()?;
|
||||
self.native_origin = Some(Arc::new(NativeOriginWitness {
|
||||
authority: authority.clone(),
|
||||
body_sha256: Sha256::digest(self.body()?).into(),
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn prepare_wire(&mut self) -> Result<()> {
|
||||
self.validate()?;
|
||||
if let ProofAuthentication::NativeLocal = self.authentication {
|
||||
let origin = self.native_origin.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
self.verify_with_authority(&ProofVerificationContext::Local(origin.authority.clone()))?;
|
||||
self.authentication = ProofAuthentication::ClusterHmac(
|
||||
crate::cluster::rpc::sign_tonic_rpc_response_proof(&self.body()?)
|
||||
.map_err(DiskError::from)?
|
||||
.try_into()
|
||||
.map_err(|_| DiskError::FileCorrupt)?,
|
||||
);
|
||||
self.native_origin = None;
|
||||
}
|
||||
self.verify()
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MultipartAbortCertificate {
|
||||
pub round: MultipartAbortRound,
|
||||
pub disk_ids: Vec<Uuid>,
|
||||
pub proofs: Vec<MultipartAbortProof>,
|
||||
}
|
||||
impl MultipartAbortCertificate {
|
||||
pub fn digest(&self) -> Result<[u8; 32]> {
|
||||
Ok(Sha256::digest(rmp_serde::to_vec_named(self).map_err(super::super::Error::other)?).into())
|
||||
}
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
self.verify_with_authority(&ProofVerificationContext::RpcOnly)
|
||||
}
|
||||
pub fn verify_with_authority(&self, authority: &ProofVerificationContext) -> Result<()> {
|
||||
self.validate_shape()?;
|
||||
for proof in &self.proofs {
|
||||
proof.verify_with_authority(authority)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_shape(&self) -> Result<()> {
|
||||
self.round.validate()?;
|
||||
let n = self.round.lineage.geometry.disks();
|
||||
if self.disk_ids.len() != n || self.proofs.len() != n || self.disk_ids.iter().any(Uuid::is_nil) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let mut ids = self.disk_ids.clone();
|
||||
ids.sort_unstable();
|
||||
if ids.windows(2).any(|pair| pair[0] == pair[1]) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let mut slots = [false; 16];
|
||||
for (expected_slot, proof) in self.proofs.iter().enumerate() {
|
||||
proof.validate()?;
|
||||
let slot = proof.snapshot.physical_slot;
|
||||
if slot != expected_slot
|
||||
|| proof.kind != MultipartAbortProofKind::Prepared
|
||||
|| proof.round != self.round
|
||||
|| self.disk_ids.get(slot) != Some(&proof.snapshot.disk_id)
|
||||
|| std::mem::replace(&mut slots[slot], true)
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn prepare_wire(&mut self) -> Result<()> {
|
||||
if self.proofs.len() > 16 {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
for proof in &mut self.proofs {
|
||||
proof.prepare_wire()?;
|
||||
}
|
||||
self.verify()
|
||||
}
|
||||
pub(super) fn validate_wire(&self) -> Result<()> {
|
||||
self.verify()
|
||||
}
|
||||
pub(in crate::disk) fn hydrate_owned(
|
||||
&mut self,
|
||||
authority: &ProofVerificationContext,
|
||||
validated: &super::super::local::multipart_source_abort::ValidatedAbortCertificate,
|
||||
) -> Result<()> {
|
||||
validated.validate_certificate(self)?;
|
||||
if let ProofVerificationContext::Local(local) = authority {
|
||||
for proof in &mut self.proofs {
|
||||
match proof.authentication {
|
||||
ProofAuthentication::NativeLocal => proof.attach_origin(local)?,
|
||||
ProofAuthentication::ClusterHmac(_) => proof.verify()?,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.verify_with_authority(authority)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::multipart_proof_authority_tests::{fixture, local_authority};
|
||||
use super::*;
|
||||
fn certificate() -> (ProofVerificationContext, MultipartAbortCertificate) {
|
||||
let authority = local_authority();
|
||||
let (recovery, mut snapshot) = fixture();
|
||||
let round = MultipartAbortRound {
|
||||
lineage: recovery.lineage,
|
||||
generation: SourceGeneration {
|
||||
epoch: 10,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
};
|
||||
snapshot.phase = MultipartSourcePhase::Aborting;
|
||||
snapshot.generation = round.generation;
|
||||
snapshot.head_sha256 = None;
|
||||
snapshot.capability_sha256 = None;
|
||||
snapshot.publication = None;
|
||||
let disk_ids = (0..4).map(|_| Uuid::new_v4()).collect::<Vec<_>>();
|
||||
let proofs = disk_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, id)| {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.disk_id = *id;
|
||||
snapshot.physical_slot = slot;
|
||||
MultipartAbortProof::issue(
|
||||
&authority,
|
||||
MultipartAbortProofKind::Prepared,
|
||||
round.round,
|
||||
round.clone(),
|
||||
snapshot,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
(authority, MultipartAbortCertificate { round, disk_ids, proofs })
|
||||
}
|
||||
#[test]
|
||||
fn abort_certificate_requires_all_actual_ordered_participants_and_native_provenance() {
|
||||
let (authority, certificate) = certificate();
|
||||
certificate.verify_with_authority(&authority).unwrap();
|
||||
let mut partial = certificate.clone();
|
||||
partial.proofs.pop();
|
||||
assert!(partial.verify_with_authority(&authority).is_err());
|
||||
let mut reordered = certificate.clone();
|
||||
reordered.proofs.swap(0, 1);
|
||||
assert!(reordered.verify_with_authority(&authority).is_err());
|
||||
let mut duplicate = certificate.clone();
|
||||
duplicate.disk_ids[1] = duplicate.disk_ids[0];
|
||||
assert!(duplicate.verify_with_authority(&authority).is_err());
|
||||
let raw = rmp_serde::to_vec_named(&certificate).unwrap();
|
||||
let decoded: MultipartAbortCertificate = rmp_serde::from_slice(&raw).unwrap();
|
||||
assert_eq!(certificate.digest().unwrap(), decoded.digest().unwrap());
|
||||
assert!(
|
||||
decoded.verify_with_authority(&authority).is_err(),
|
||||
"bare local bytes cannot issue closure authority"
|
||||
);
|
||||
let mut forged = certificate.clone();
|
||||
forged.round.generation.epoch += 1;
|
||||
assert!(forged.verify_with_authority(&authority).is_err());
|
||||
assert!(certificate.verify_with_authority(&local_authority()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn abort_target_proof_requires_committed_certificate_and_binds_historical_target() {
|
||||
let (authority, certificate) = certificate();
|
||||
let mut snapshot = certificate.proofs[0].snapshot.clone();
|
||||
snapshot.generation.epoch += 1;
|
||||
snapshot.generation.nonce = Uuid::new_v4();
|
||||
let publication = PublicationIdentity {
|
||||
lineage: certificate.round.lineage.clone(),
|
||||
source_generation: SourceGeneration {
|
||||
epoch: 0,
|
||||
nonce: certificate.round.lineage.original_data_dir,
|
||||
},
|
||||
attempt: Uuid::new_v4(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
final_data_dir: Uuid::new_v4(),
|
||||
modified_unix_nanos: 1,
|
||||
completion_identity: [7; 32],
|
||||
};
|
||||
let challenge = Uuid::new_v4();
|
||||
assert!(
|
||||
MultipartAbortProof::issue(
|
||||
&authority,
|
||||
MultipartAbortProofKind::CleanupTarget,
|
||||
challenge,
|
||||
certificate.round.clone(),
|
||||
snapshot.clone(),
|
||||
None,
|
||||
Some(publication.clone())
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let proof = MultipartAbortProof::issue(
|
||||
&authority,
|
||||
MultipartAbortProofKind::CleanupTarget,
|
||||
challenge,
|
||||
certificate.round.clone(),
|
||||
snapshot,
|
||||
Some(certificate.digest().unwrap()),
|
||||
Some(publication),
|
||||
)
|
||||
.unwrap();
|
||||
proof.verify_with_authority(&authority).unwrap();
|
||||
let mut rebound = proof.clone();
|
||||
rebound.publication.as_mut().unwrap().final_data_dir = Uuid::new_v4();
|
||||
assert!(rebound.verify_with_authority(&authority).is_err());
|
||||
let mut replay = proof;
|
||||
replay.challenge = Uuid::new_v4();
|
||||
assert!(replay.verify_with_authority(&authority).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn birth_marker_is_strict_dual_family_source_only_and_does_not_change_lineage() {
|
||||
use rustfs_utils::http::metadata_compat::{get_consistent_str, insert_str};
|
||||
let mut source = FileInfo::default();
|
||||
assert!(!native_source_protocol(&source).unwrap());
|
||||
let before = digest_value(&normalized_info(&source, false)).unwrap();
|
||||
mark_native_source(&mut source);
|
||||
assert!(native_source_protocol(&source).unwrap());
|
||||
assert_eq!(
|
||||
get_consistent_str(&source.metadata, NATIVE_SOURCE_PROTOCOL_SUFFIX),
|
||||
Some(NATIVE_SOURCE_PROTOCOL_VALUE)
|
||||
);
|
||||
assert_eq!(before, digest_value(&normalized_info(&source, false)).unwrap());
|
||||
insert_str(&mut source.metadata, "user-visible-identity", "changed".into());
|
||||
assert_ne!(
|
||||
before,
|
||||
digest_value(&normalized_info(&source, false)).unwrap(),
|
||||
"only the source protocol control is projected"
|
||||
);
|
||||
source.metadata.insert(
|
||||
format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, NATIVE_SOURCE_PROTOCOL_SUFFIX),
|
||||
"native-v2".into(),
|
||||
);
|
||||
assert!(native_source_protocol(&source).is_err());
|
||||
assert!(recognized_native_source_controls(&source).is_err());
|
||||
mark_native_source(&mut source);
|
||||
strip_native_controls(&mut source.metadata);
|
||||
assert!(!native_source_protocol(&source).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn only_explicit_dual_family_initial_origins_without_status_can_skip_replay() {
|
||||
use rustfs_utils::http::metadata_compat::{MINIO_INTERNAL_PREFIX, RUSTFS_INTERNAL_PREFIX};
|
||||
let mut source = FileInfo::default();
|
||||
mark_native_source(&mut source);
|
||||
assert!(native_source_protocol(&source).unwrap());
|
||||
assert!(
|
||||
!is_initial_native_source(&source).unwrap(),
|
||||
"existing and upgraded v1 heads remain ambiguous"
|
||||
);
|
||||
mark_initial_native_source(&mut source);
|
||||
assert!(is_initial_native_source(&source).unwrap());
|
||||
source
|
||||
.metadata
|
||||
.remove(&format!("{MINIO_INTERNAL_PREFIX}{NATIVE_SOURCE_PROTOCOL_SUFFIX}"));
|
||||
assert!(
|
||||
!is_initial_native_source(&source).unwrap(),
|
||||
"a single compatibility alias is not a fresh-origin proof"
|
||||
);
|
||||
mark_initial_native_source(&mut source);
|
||||
source.metadata.insert(
|
||||
format!("{RUSTFS_INTERNAL_PREFIX}{NATIVE_SOURCE_PROTOCOL_SUFFIX}"),
|
||||
NATIVE_SOURCE_PROTOCOL_VALUE.into(),
|
||||
);
|
||||
assert!(
|
||||
is_initial_native_source(&source).is_err(),
|
||||
"conflicting origins never authorize the fast path"
|
||||
);
|
||||
mark_initial_native_source(&mut source);
|
||||
let generation = SourceGeneration {
|
||||
epoch: 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
};
|
||||
set_source_status(
|
||||
&mut source,
|
||||
&SourceStatus {
|
||||
generation,
|
||||
recovering: false,
|
||||
round: Some(Uuid::new_v4()),
|
||||
ready_sha256: Some([3; 32]),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_active_source(&source).unwrap(), "the restored fixture is an ordinary ACTIVE source");
|
||||
assert!(
|
||||
!is_initial_native_source(&source).unwrap(),
|
||||
"an atomically restored ACTIVE head must still probe prior targets"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_abort_fields_preserve_existing_inspection_encoding() {
|
||||
let inspection = 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 json = serde_json::to_value(&inspection).unwrap();
|
||||
assert!(json.get("root_missing").is_none());
|
||||
assert!(json.get("abort_round").is_none());
|
||||
assert!(json.get("abort_certificate").is_none());
|
||||
let decoded: MultipartSourceInspection = serde_json::from_value(json).unwrap();
|
||||
assert!(!decoded.root_missing);
|
||||
assert!(decoded.abort_certificate.is_none());
|
||||
}
|
||||
}
|
||||
+2334
-124
File diff suppressed because it is too large
Load Diff
@@ -203,6 +203,29 @@ pub(crate) async fn make_local_set_disks_with_ctx(
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
|
||||
// This constructor owns a complete real local cohort, including every
|
||||
// formatted physical slot. It bypasses ECStore's normal startup binding.
|
||||
let topology = crate::layout::endpoints::EndpointServerPools(vec![crate::layout::endpoints::PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: drive_count,
|
||||
endpoints: endpoints.clone().into(),
|
||||
cmd_line: String::new(),
|
||||
platform: "test".into(),
|
||||
}]);
|
||||
let authority = ctx
|
||||
.bind_multipart_proof_topology(&topology)
|
||||
.expect("blackbox fixture must register its complete local topology");
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
|
||||
.expect("blackbox native handles must belong to the registered instance");
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
assert_eq!(
|
||||
disk.as_ref().expect("blackbox fixture disk").get_disk_id().await.unwrap(),
|
||||
Some(format.erasure.sets[0][slot]),
|
||||
"blackbox fixture must bind the actual complete formatted cohort"
|
||||
);
|
||||
}
|
||||
|
||||
let set_disks = SetDisks::new_with_instance_ctx(
|
||||
"ecstore-validation-blackbox".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
|
||||
@@ -4473,3 +4473,7 @@ mod tests {
|
||||
.expect_err("a lying frame-layout marker must fail closed, not serve plaintext");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "readers/compression_index_compatibility_tests.rs"]
|
||||
mod compression_index_compatibility_tests;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
use super::*;
|
||||
use rustfs_utils::http::metadata_compat::{SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, insert_str};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const STREAM: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/compression-index/legacy-lz4-stream.bin"
|
||||
));
|
||||
const HISTORICAL_INDEX: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/compression-index/pre-zigzag-index.bin"
|
||||
));
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
const CURRENT_INDEX: &[u8] =
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/compression-index/zigzag-index.bin"));
|
||||
const PLAINTEXT_LEN: usize = 3 * 1024 * 1024 + 123;
|
||||
const PLAINTEXT_SHA256: &str = "68f9edf6f9600395d6d2b5517d97570fecd2a0c6dbe6130617ef415a3b48d89c";
|
||||
|
||||
fn fixture_info(index: &[u8]) -> ObjectInfo {
|
||||
let mut metadata = HashMap::new();
|
||||
insert_str(&mut metadata, SUFFIX_COMPRESSION, "lz4".to_owned());
|
||||
insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, PLAINTEXT_LEN.to_string());
|
||||
ObjectInfo {
|
||||
size: i64::try_from(STREAM.len()).expect("fixed fixture size fits i64"),
|
||||
parts: Arc::new(vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
size: STREAM.len(),
|
||||
actual_size: i64::try_from(PLAINTEXT_LEN).expect("fixed plaintext length fits i64"),
|
||||
index: Some(Bytes::copy_from_slice(index)),
|
||||
..Default::default()
|
||||
}]),
|
||||
user_defined: Arc::new(metadata),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_plaintext() -> Vec<u8> {
|
||||
(0..PLAINTEXT_LEN)
|
||||
.map(|offset| {
|
||||
let chunk = offset / 1024;
|
||||
let lane = offset % 1024;
|
||||
if lane < 8 {
|
||||
u64::try_from(chunk).expect("fixed chunk number fits u64").to_le_bytes()[lane]
|
||||
} else {
|
||||
b"abcdefghijklmnopqrstuvwxyz"[(offset + chunk) % 26]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_index_preserves_the_complete_compressed_body() {
|
||||
let info = fixture_info(HISTORICAL_INDEX);
|
||||
let (mut reader, storage_offset, _) = GetObjectReader::new(
|
||||
Box::new(Cursor::new(STREAM.to_vec())),
|
||||
None,
|
||||
&info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("construct reader for fixed historical index");
|
||||
let mut actual = Vec::new();
|
||||
reader.read_to_end(&mut actual).await.expect("read complete historical body");
|
||||
assert_eq!(storage_offset, 0);
|
||||
assert_eq!(actual.len(), PLAINTEXT_LEN);
|
||||
assert_eq!(
|
||||
hex_simd::encode_to_string(Sha256::digest(&actual), hex_simd::AsciiCase::Lower),
|
||||
PLAINTEXT_SHA256
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_index_preserves_ranges_across_compression_blocks() {
|
||||
let info = fixture_info(HISTORICAL_INDEX);
|
||||
let expected = fixture_plaintext();
|
||||
for start in [7, 1024 * 1024 - 17, 2 * 1024 * 1024 + 31, PLAINTEXT_LEN - 97] {
|
||||
let range = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: i64::try_from(start).expect("fixed range start fits i64"),
|
||||
end: i64::try_from(start + 63).expect("fixed range end fits i64"),
|
||||
};
|
||||
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
|
||||
.await
|
||||
.expect("plan historical compressed range");
|
||||
let physical = plan.storage_offset;
|
||||
let input = STREAM.get(physical..).expect("planned offset stays within the fixed stream");
|
||||
let (mut reader, _, _) = GetObjectReader::new(
|
||||
Box::new(Cursor::new(input.to_vec())),
|
||||
Some(range),
|
||||
&info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("construct historical range reader");
|
||||
let mut actual = Vec::new();
|
||||
reader.read_to_end(&mut actual).await.expect("read historical range");
|
||||
assert_eq!(actual, expected[start..start + 64], "range starting at {start}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
#[tokio::test]
|
||||
async fn current_index_selects_a_fixed_compressed_block_for_a_range() {
|
||||
let info = fixture_info(CURRENT_INDEX);
|
||||
let start = 2 * 1024 * 1024 + 31;
|
||||
let range = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: i64::try_from(start).expect("fixed range start fits i64"),
|
||||
end: i64::try_from(start + 63).expect("fixed range end fits i64"),
|
||||
};
|
||||
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
|
||||
.await
|
||||
.expect("plan range with fixed current index");
|
||||
assert_eq!(plan.storage_offset, 34_663, "use the recorded second block boundary");
|
||||
let physical = plan.storage_offset;
|
||||
let (mut reader, _, _) = GetObjectReader::new(
|
||||
Box::new(Cursor::new(STREAM[physical..].to_vec())),
|
||||
Some(range),
|
||||
&info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("construct indexed range reader");
|
||||
let mut actual = Vec::new();
|
||||
reader.read_to_end(&mut actual).await.expect("read indexed range");
|
||||
assert_eq!(actual, fixture_plaintext()[start..start + 64]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncated_index_falls_back_without_truncating_the_range() {
|
||||
let info = fixture_info(&HISTORICAL_INDEX[..HISTORICAL_INDEX.len() - 3]);
|
||||
let start = 2 * 1024 * 1024 + 31;
|
||||
let range = HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: i64::try_from(start).expect("fixed range start fits i64"),
|
||||
end: i64::try_from(start + 96).expect("fixed range end fits i64"),
|
||||
};
|
||||
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
|
||||
.await
|
||||
.expect("invalid index still permits a sequential range plan");
|
||||
assert_eq!(plan.storage_offset, 0, "truncated index must not select a storage offset");
|
||||
let (mut reader, _, _) = GetObjectReader::new(
|
||||
Box::new(Cursor::new(STREAM.to_vec())),
|
||||
Some(range),
|
||||
&info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("construct sequential fallback reader");
|
||||
let mut actual = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("read range without the truncated index");
|
||||
assert_eq!(actual, fixture_plaintext()[start..start + 97]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_index_fallback_does_not_hide_a_body_checksum_failure() {
|
||||
let info = fixture_info(HISTORICAL_INDEX);
|
||||
let mut corrupted = STREAM.to_vec();
|
||||
corrupted[4] ^= 1; // Corrupt the first block's stored CRC, leaving its payload intact.
|
||||
let (mut reader, _, _) = GetObjectReader::new(
|
||||
Box::new(Cursor::new(corrupted)),
|
||||
None,
|
||||
&info,
|
||||
&ObjectOptions::default(),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("construct reader before body checksum verification");
|
||||
let mut actual = Vec::new();
|
||||
let error = reader
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect_err("bad body CRC cannot become successful EOF");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(actual.len() < PLAINTEXT_LEN);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionStat
|
||||
use crate::bucket::metadata_sys::BucketMetadataSys;
|
||||
use crate::bucket::replication::{DynReplicationPool, ReplicationStats};
|
||||
use crate::disk::DiskStore;
|
||||
use crate::disk::multipart_source::ProofVerificationContext;
|
||||
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
||||
use crate::object_api::ObjectEncryptionResolver;
|
||||
use crate::services::event_notification::EventNotifier;
|
||||
@@ -222,6 +223,8 @@ pub struct InstanceContext {
|
||||
/// Write-once: storage startup publishes the server pools exactly once.
|
||||
/// Replaces the process-global endpoints static.
|
||||
endpoints: OnceLock<EndpointServerPools>,
|
||||
/// Only a complete validated topology may enable native local proof origin.
|
||||
multipart_proof_topology: OnceLock<([u8; 32], ProofVerificationContext)>,
|
||||
/// This instance's tier configuration manager (Phase 5 Slice 7, backlog#939).
|
||||
///
|
||||
/// Lazily materialized on first access so `InstanceContext::new()` stays
|
||||
@@ -363,6 +366,7 @@ impl InstanceContext {
|
||||
region: OnceLock::new(),
|
||||
deployment_id: OnceLock::new(),
|
||||
endpoints: OnceLock::new(),
|
||||
multipart_proof_topology: OnceLock::new(),
|
||||
tier_config_mgr: OnceLock::new(),
|
||||
event_notifier: OnceLock::new(),
|
||||
expiry_state: OnceLock::new(),
|
||||
@@ -808,6 +812,38 @@ impl InstanceContext {
|
||||
self.endpoints.get().cloned()
|
||||
}
|
||||
|
||||
/// Keep one authority identity across complete local registry reinitializations.
|
||||
/// Changing the instance or crossing the local/RPC mode boundary is forbidden.
|
||||
pub(crate) fn bind_multipart_proof_topology(
|
||||
&self,
|
||||
endpoints: &EndpointServerPools,
|
||||
) -> crate::disk::error::Result<ProofVerificationContext> {
|
||||
// Registry vectors are indexed by pool_idx. Validate every index before
|
||||
// adding endpoint permissions or allowing a caller to clear the registry.
|
||||
if endpoints.as_ref().iter().enumerate().any(|(pool, entry)| {
|
||||
entry
|
||||
.endpoints
|
||||
.as_ref()
|
||||
.iter()
|
||||
.any(|endpoint| usize::try_from(endpoint.pool_idx).ok() != Some(pool))
|
||||
}) {
|
||||
return Err(crate::disk::error::DiskError::InconsistentDisk);
|
||||
}
|
||||
let (identity, authority) = ProofVerificationContext::for_topology(endpoints)?;
|
||||
let stored = self.multipart_proof_topology.get_or_init(|| (identity, authority.clone()));
|
||||
if stored.0 != identity {
|
||||
stored.1.register_topology(&authority)?;
|
||||
}
|
||||
Ok(stored.1.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn multipart_proof_verification_context(&self) -> ProofVerificationContext {
|
||||
self.multipart_proof_topology
|
||||
.get()
|
||||
.map(|(_, authority)| authority.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// This instance's tier configuration manager, materializing it on first
|
||||
/// access. Shared for the lifetime of the instance.
|
||||
pub fn tier_config_mgr(&self) -> Arc<RwLock<TierConfigMgr>> {
|
||||
@@ -1704,3 +1740,132 @@ mod tests {
|
||||
assert!(b.background_cancel_token().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multipart_instance_authority_tests {
|
||||
use super::*;
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::PoolEndpoints;
|
||||
|
||||
fn local_topology(root: &str) -> EndpointServerPools {
|
||||
let mut endpoint = Endpoint::try_from(root).expect("local endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 1,
|
||||
endpoints: vec![endpoint].into(),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
}])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_instance_authority_is_explicit_stable_across_local_topologies() {
|
||||
let ctx = InstanceContext::new();
|
||||
assert!(matches!(ctx.multipart_proof_verification_context(), ProofVerificationContext::RpcOnly));
|
||||
let topology = local_topology("/tmp/multipart-instance-a");
|
||||
let first = ctx
|
||||
.bind_multipart_proof_topology(&topology)
|
||||
.expect("complete local topology should bind");
|
||||
let again = ctx
|
||||
.bind_multipart_proof_topology(&topology)
|
||||
.expect("reopening the same topology should retain authority");
|
||||
assert!(first.same_authority(&again));
|
||||
assert!(first.same_authority(&ctx.multipart_proof_verification_context()));
|
||||
let replacement = local_topology("/tmp/multipart-instance-b");
|
||||
let extended = ctx
|
||||
.bind_multipart_proof_topology(&replacement)
|
||||
.expect("a complete local reinitialization should retain the native owner");
|
||||
assert!(first.same_authority(&extended));
|
||||
first.validate_endpoint(&topology.0[0].endpoints.as_ref()[0]).unwrap();
|
||||
first.validate_endpoint(&replacement.0[0].endpoints.as_ref()[0]).unwrap();
|
||||
assert!(first.same_authority(&ctx.multipart_proof_verification_context()));
|
||||
let independent = InstanceContext::new().bind_multipart_proof_topology(&topology).unwrap();
|
||||
assert!(!first.same_authority(&independent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_distributed_instance_never_becomes_local_after_reinitialization() {
|
||||
let ctx = InstanceContext::new();
|
||||
let mut topology = local_topology("/tmp/multipart-remote-instance");
|
||||
topology.0[0].endpoints.as_mut()[0].is_local = false;
|
||||
assert!(matches!(
|
||||
ctx.bind_multipart_proof_topology(&topology).unwrap(),
|
||||
ProofVerificationContext::RpcOnly
|
||||
));
|
||||
topology.0[0].endpoints.as_mut()[0].is_local = true;
|
||||
assert!(ctx.bind_multipart_proof_topology(&topology).is_err());
|
||||
assert!(matches!(ctx.multipart_proof_verification_context(), ProofVerificationContext::RpcOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_parallel_topology_bind_retains_one_instance_authority() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let topologies = (0..4)
|
||||
.map(|slot| local_topology(&format!("/tmp/multipart-parallel-instance-{slot}")))
|
||||
.collect::<Vec<_>>();
|
||||
let authorities = std::thread::scope(|scope| {
|
||||
let mut tasks = Vec::with_capacity(topologies.len());
|
||||
for topology in &topologies {
|
||||
let ctx = ctx.clone();
|
||||
tasks.push(scope.spawn(move || ctx.bind_multipart_proof_topology(topology).unwrap()));
|
||||
}
|
||||
tasks.into_iter().map(|task| task.join().unwrap()).collect::<Vec<_>>()
|
||||
});
|
||||
assert!(authorities.iter().all(|authority| authority.same_authority(&authorities[0])));
|
||||
for topology in topologies {
|
||||
authorities[0]
|
||||
.validate_endpoint(&topology.0[0].endpoints.as_ref()[0])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn multipart_topology_registration_rejects_invalid_indices_and_mode_switch_without_granting_endpoints() {
|
||||
let ctx = InstanceContext::new();
|
||||
let original = local_topology("/tmp/multipart-original-registry");
|
||||
let first = ctx.bind_multipart_proof_topology(&original).unwrap();
|
||||
let candidate = local_topology("/tmp/multipart-unregistered-candidate");
|
||||
let mut outside = candidate.clone();
|
||||
outside.0[0].endpoints.as_mut()[0].set_pool_index(1);
|
||||
assert!(
|
||||
ctx.bind_multipart_proof_topology(&outside).is_err(),
|
||||
"one pool cannot address registry slot one"
|
||||
);
|
||||
assert!(first.validate_endpoint(&outside.0[0].endpoints.as_ref()[0]).is_err());
|
||||
let fresh = InstanceContext::new();
|
||||
assert!(fresh.bind_multipart_proof_topology(&outside).is_err());
|
||||
assert!(
|
||||
fresh.multipart_proof_topology.get().is_none(),
|
||||
"invalid initial registration must have no authority effect"
|
||||
);
|
||||
|
||||
let mut sparse = candidate.clone();
|
||||
let mut second = local_topology("/tmp/multipart-unregistered-second").0.remove(0);
|
||||
second.endpoints.as_mut()[0].set_pool_index(2);
|
||||
sparse.0.push(second);
|
||||
assert!(ctx.bind_multipart_proof_topology(&sparse).is_err());
|
||||
assert!(
|
||||
first.validate_endpoint(&candidate.0[0].endpoints.as_ref()[0]).is_err(),
|
||||
"a valid early pool cannot be partially granted"
|
||||
);
|
||||
let mut mixed = candidate.clone();
|
||||
let mut second = local_topology("/tmp/multipart-remote-later-pool").0.remove(0);
|
||||
second.endpoints.as_mut()[0].set_pool_index(1);
|
||||
second.endpoints.as_mut()[0].is_local = false;
|
||||
mixed.0.push(second);
|
||||
assert!(
|
||||
ctx.bind_multipart_proof_topology(&mixed).is_err(),
|
||||
"local authority cannot switch mode for a remote later pool"
|
||||
);
|
||||
assert!(first.validate_endpoint(&candidate.0[0].endpoints.as_ref()[0]).is_err());
|
||||
let mut incomplete = candidate.clone();
|
||||
incomplete.0[0].drives_per_set = 2;
|
||||
assert!(ctx.bind_multipart_proof_topology(&incomplete).is_err());
|
||||
assert!(first.validate_endpoint(&candidate.0[0].endpoints.as_ref()[0]).is_err());
|
||||
assert!(first.same_authority(&ctx.multipart_proof_verification_context()));
|
||||
first.validate_endpoint(&original.0[0].endpoints.as_ref()[0]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +467,24 @@ pub(crate) async fn reconcile_local_disk_ids(
|
||||
}
|
||||
|
||||
pub(crate) async fn quarantine_local_disks(instance_ctx: &InstanceContext, endpoints: &[Endpoint]) -> Result<()> {
|
||||
quarantine_local_disks_preserving_bootstrap_routes(instance_ctx, endpoints, &[]).await
|
||||
}
|
||||
|
||||
/// Keep an already registered path available to its in-flight format writer.
|
||||
/// All runtime voting slots are still cleared; no route is admitted or revived.
|
||||
/// Only the format loader's explicit local unformatted observations may use it.
|
||||
pub(crate) async fn quarantine_local_disks_preserving_bootstrap_routes(
|
||||
instance_ctx: &InstanceContext,
|
||||
endpoints: &[Endpoint],
|
||||
unformatted_bootstrap_routes: &[Endpoint],
|
||||
) -> Result<()> {
|
||||
if unformatted_bootstrap_routes
|
||||
.iter()
|
||||
.any(|route| !route.is_local || !endpoints.contains(route))
|
||||
{
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
let bootstrap_routes = unformatted_bootstrap_routes.iter().collect::<HashSet<_>>();
|
||||
let slots = endpoints
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
@@ -481,7 +499,9 @@ pub(crate) async fn quarantine_local_disks(instance_ctx: &InstanceContext, endpo
|
||||
let local_disk_map = instance_ctx.local_disk_map();
|
||||
let mut local_disks = local_disk_map.write().await;
|
||||
for endpoint in endpoints {
|
||||
local_disks.insert(endpoint.to_string(), None);
|
||||
if !bootstrap_routes.contains(endpoint) {
|
||||
local_disks.insert(endpoint.to_string(), None);
|
||||
}
|
||||
}
|
||||
drop(local_disks);
|
||||
|
||||
@@ -552,6 +572,7 @@ pub(crate) async fn initialize_local_disk_maps(
|
||||
endpoint_pools: EndpointServerPools,
|
||||
opt: &DiskOption,
|
||||
) -> Result<()> {
|
||||
let multipart_authority = instance_ctx.bind_multipart_proof_topology(&endpoint_pools)?;
|
||||
// Every caller passes the FULL topology, so (re)initialization must replace
|
||||
// any previous registration wholesale: appending would leave the pool/set
|
||||
// vectors sized for a stale topology and panic on wider disk indices (seen
|
||||
@@ -579,6 +600,9 @@ pub(crate) async fn initialize_local_disk_maps(
|
||||
}
|
||||
|
||||
let disk = new_disk(ep, opt).await?;
|
||||
if let crate::disk::Disk::Local(wrapper) = disk.as_ref() {
|
||||
wrapper.get_disk().bind_multipart_proof_authority(&multipart_authority)?;
|
||||
}
|
||||
let path = disk.endpoint().to_string();
|
||||
let pool_idx = usize::try_from(ep.pool_idx).map_err(|err| {
|
||||
crate::error::Error::other(format!("store init failed to convert pool index `{}`: {err}", ep.pool_idx))
|
||||
@@ -621,7 +645,7 @@ mod tests {
|
||||
notify_scanner_dirty_usage_mutation, reconcile_local_disk_ids, replace_local_disk_id, set_local_node_name,
|
||||
set_scanner_dirty_usage_mutation_observer,
|
||||
};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::{DiskAPI, endpoint::Endpoint};
|
||||
use rustfs_lock::{LocalClient, LockClient};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -825,9 +849,11 @@ mod tests {
|
||||
super::initialize_local_disk_maps(&instance_ctx, build_pools("narrow", 2), &opt)
|
||||
.await
|
||||
.expect("narrow topology should initialize");
|
||||
let first_authority = instance_ctx.multipart_proof_verification_context();
|
||||
super::initialize_local_disk_maps(&instance_ctx, build_pools("wide", 4), &opt)
|
||||
.await
|
||||
.expect("re-initializing with a wider topology must not panic or fail");
|
||||
assert!(first_authority.same_authority(&instance_ctx.multipart_proof_verification_context()));
|
||||
|
||||
let set_drives = instance_ctx.local_disk_set_drives();
|
||||
let set_drives = set_drives.read().await;
|
||||
@@ -848,4 +874,59 @@ mod tests {
|
||||
disk_map.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn multipart_invalid_registry_pool_index_has_no_authority_or_registry_effect() {
|
||||
use crate::disk::DiskOption;
|
||||
use crate::layout::endpoints::{EndpointServerPools, PoolEndpoints};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let make_topology = |label: &str, pool_index: usize| {
|
||||
let path = directory.path().join(label);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let mut endpoint = Endpoint::try_from(path.to_str().unwrap()).unwrap();
|
||||
endpoint.set_pool_index(pool_index);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 1,
|
||||
endpoints: vec![endpoint].into(),
|
||||
cmd_line: label.into(),
|
||||
platform: "test".into(),
|
||||
}])
|
||||
};
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let valid = make_topology("registered", 0);
|
||||
let options = DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
};
|
||||
super::initialize_local_disk_maps(&ctx, valid.clone(), &options)
|
||||
.await
|
||||
.unwrap();
|
||||
let authority = ctx.multipart_proof_verification_context();
|
||||
let before = ctx.local_disk_map().read().await.clone();
|
||||
let invalid = make_topology("invalid", 1);
|
||||
let endpoint = &invalid.0[0].endpoints.as_ref()[0];
|
||||
assert!(
|
||||
super::initialize_local_disk_maps(&ctx, invalid.clone(), &options)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(authority.same_authority(&ctx.multipart_proof_verification_context()));
|
||||
assert!(authority.validate_endpoint(endpoint).is_err());
|
||||
assert!(
|
||||
!std::path::Path::new(&endpoint.get_file_path()).join(".rustfs.sys").exists(),
|
||||
"invalid registration must fail before disk initialization"
|
||||
);
|
||||
let after = ctx.local_disk_map().read().await.clone();
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (path, disk) in before {
|
||||
assert!(Arc::ptr_eq(disk.as_ref().unwrap(), after.get(&path).unwrap().as_ref().unwrap()));
|
||||
}
|
||||
let drives = ctx.local_disk_set_drives();
|
||||
let drives = drives.read().await;
|
||||
assert_eq!((drives.len(), drives[0].len(), drives[0][0].len()), (1, 1, 1));
|
||||
assert_eq!(drives[0][0][0].as_ref().unwrap().endpoint(), valid.0[0].endpoints.as_ref()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
||||
const MRF_LIFECYCLE_WRITER_FORMAT_VERSION: u8 = 2;
|
||||
/// Fallback poll cadence while startup has not yet published the notification
|
||||
/// system. IAM finalization starts the probe before `init_notification_runtime`
|
||||
/// runs, so the first pass always fails closed; waiting a full probe interval
|
||||
@@ -307,6 +308,7 @@ struct FleetCapabilityProofState {
|
||||
proof: Option<FleetCapabilityProof>,
|
||||
draining_generation: Option<Arc<FleetCapabilityProofGeneration>>,
|
||||
topology_conflict: bool,
|
||||
published: Arc<Notify>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
@@ -357,6 +359,8 @@ static DECOMMISSION_TARGET_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCa
|
||||
static LEGACY_TRANSITION_STATE_RECONCILE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static ILM_RECOVERY_EXPORT_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static TRANSITION_TRANSACTION_COMPACTION_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static MRF_LIFECYCLE_WRITER_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static MRF_LIFECYCLE_READER_LOCAL_PROCESS_EPOCH: LazyLock<Uuid> = LazyLock::new(Uuid::new_v4);
|
||||
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
|
||||
/// Signalled once `GLOBAL_NOTIFICATION_SYS` is published so the fleet probe can
|
||||
/// run its first real pass without waiting for the bootstrap poll.
|
||||
@@ -391,7 +395,7 @@ fn transition_transaction_compaction_fleet_proof_slot() -> &'static std::sync::R
|
||||
TRANSITION_TRANSACTION_COMPACTION_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||
}
|
||||
|
||||
fn all_fleet_capability_proof_slots() -> [&'static std::sync::RwLock<FleetCapabilityProofState>; 7] {
|
||||
fn all_fleet_capability_proof_slots() -> [&'static std::sync::RwLock<FleetCapabilityProofState>; 8] {
|
||||
[
|
||||
remote_version_state_fleet_proof_slot(),
|
||||
cross_pool_fence_fleet_proof_slot(),
|
||||
@@ -400,6 +404,7 @@ fn all_fleet_capability_proof_slots() -> [&'static std::sync::RwLock<FleetCapabi
|
||||
legacy_transition_state_reconcile_fleet_proof_slot(),
|
||||
ilm_recovery_export_fleet_proof_slot(),
|
||||
transition_transaction_compaction_fleet_proof_slot(),
|
||||
mrf_lifecycle_writer_fleet_proof_slot(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -451,6 +456,7 @@ fn publish_fleet_capability_probe_result(
|
||||
.filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs)
|
||||
{
|
||||
current.expires_at = observed_at + REMOTE_VERSION_STATE_PROOF_TTL;
|
||||
state.published.notify_waiters();
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -477,6 +483,7 @@ fn publish_fleet_capability_probe_result(
|
||||
Arc::new(peer_epochs),
|
||||
observed_at + REMOTE_VERSION_STATE_PROOF_TTL,
|
||||
));
|
||||
state.published.notify_waiters();
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -558,6 +565,43 @@ pub fn acquire_cross_pool_fence_fleet_proof() -> Option<CrossPoolFenceFleetProof
|
||||
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(CrossPoolFenceFleetProofToken)
|
||||
}
|
||||
|
||||
async fn wait_for_fleet_capability_proof(
|
||||
rx: &CancellationToken,
|
||||
published: &Notify,
|
||||
mut available: impl FnMut() -> bool,
|
||||
) -> bool {
|
||||
loop {
|
||||
let publication = published.notified();
|
||||
tokio::pin!(publication);
|
||||
// Register before checking authority: a publication between the check
|
||||
// and the await must wake us even when no further probe is needed.
|
||||
publication.as_mut().enable();
|
||||
if rx.is_cancelled() {
|
||||
return false;
|
||||
}
|
||||
if available() {
|
||||
return true;
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = rx.cancelled() => return false,
|
||||
_ = publication => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait only for readiness; callers must reacquire and validate authority at
|
||||
/// their operation boundary because a published proof may already be revoked.
|
||||
pub(crate) async fn wait_for_cross_pool_fence_fleet_proof(rx: CancellationToken) -> bool {
|
||||
let published = Arc::clone(
|
||||
&cross_pool_fence_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.published,
|
||||
);
|
||||
wait_for_fleet_capability_proof(&rx, &published, || acquire_cross_pool_fence_fleet_proof().is_some()).await
|
||||
}
|
||||
|
||||
/// Startup layout identity remains available when peers or the live fleet proof are unavailable.
|
||||
pub fn configured_fleet_topology_fingerprint() -> Option<&'static str> {
|
||||
let state = cross_pool_fence_fleet_proof_slot()
|
||||
@@ -1286,6 +1330,161 @@ fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, pe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authority for one MRF lifecycle format transition. The permit remains
|
||||
/// owned by the publisher until every admitted disk operation has finished.
|
||||
pub struct MrfLifecycleWriterFleetProofToken {
|
||||
token: FleetCapabilityProofToken,
|
||||
_permit: FleetCapabilityProofPermit,
|
||||
local_reader_max_format: u8,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static MRF_LIFECYCLE_PUBLICATION: Arc<MrfLifecycleWriterFleetProofToken>;
|
||||
}
|
||||
|
||||
/// Carry one publication's authority into local disk executors. Native CAS
|
||||
/// workers retain the same permit after an async timeout or cancellation.
|
||||
pub async fn with_mrf_lifecycle_writer_fleet_proof<F: std::future::Future>(
|
||||
proof: Arc<MrfLifecycleWriterFleetProofToken>,
|
||||
future: F,
|
||||
) -> F::Output {
|
||||
MRF_LIFECYCLE_PUBLICATION.scope(proof, future).await
|
||||
}
|
||||
|
||||
pub(crate) fn current_mrf_lifecycle_publication() -> Option<Arc<MrfLifecycleWriterFleetProofToken>> {
|
||||
MRF_LIFECYCLE_PUBLICATION.try_with(Arc::clone).ok()
|
||||
}
|
||||
|
||||
pub fn mrf_lifecycle_writer_fleet_proof_current(proof: &MrfLifecycleWriterFleetProofToken) -> bool {
|
||||
let Some(topology) = configured_fleet_topology_fingerprint() else {
|
||||
return false;
|
||||
};
|
||||
let state = mrf_lifecycle_writer_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
mrf_lifecycle_writer_fleet_proof_matches_at(&state, proof, topology, Instant::now())
|
||||
}
|
||||
|
||||
fn mrf_lifecycle_writer_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||
MRF_LIFECYCLE_WRITER_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||
}
|
||||
|
||||
pub fn mrf_lifecycle_reader_local_process_epoch() -> Uuid {
|
||||
*MRF_LIFECYCLE_READER_LOCAL_PROCESS_EPOCH
|
||||
}
|
||||
|
||||
/// The caller supplies its decoder's actual maximum, not a deployment setting.
|
||||
/// Operator opt-in and rollback qualification remain the caller's responsibility.
|
||||
pub async fn acquire_mrf_lifecycle_writer_fleet_proof(local_reader_max_format: u8) -> Option<MrfLifecycleWriterFleetProofToken> {
|
||||
let expected_topology = configured_fleet_topology_fingerprint()?;
|
||||
let observed_at = Instant::now();
|
||||
let observed = observe_mrf_lifecycle_reader_fleet(expected_topology, local_reader_max_format).await;
|
||||
if publish_fleet_capability_probe_result(mrf_lifecycle_writer_fleet_proof_slot(), expected_topology, observed, observed_at)
|
||||
.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let state = mrf_lifecycle_writer_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
acquire_mrf_lifecycle_writer_fleet_proof_from(&state, expected_topology, local_reader_max_format, Instant::now())
|
||||
}
|
||||
|
||||
fn acquire_mrf_lifecycle_writer_fleet_proof_from(
|
||||
state: &FleetCapabilityProofState,
|
||||
expected_topology: &str,
|
||||
local_reader_max_format: u8,
|
||||
now: Instant,
|
||||
) -> Option<MrfLifecycleWriterFleetProofToken> {
|
||||
if local_reader_max_format < MRF_LIFECYCLE_WRITER_FORMAT_VERSION {
|
||||
return None;
|
||||
}
|
||||
let token = acquire_fleet_capability_proof_from(state, expected_topology, now)?;
|
||||
let permit = state.proof.as_ref()?.generation.try_acquire()?;
|
||||
Some(MrfLifecycleWriterFleetProofToken {
|
||||
token,
|
||||
_permit: permit,
|
||||
local_reader_max_format,
|
||||
expires_at: state.proof.as_ref()?.expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn mrf_lifecycle_writer_fleet_proof_matches_at(
|
||||
state: &FleetCapabilityProofState,
|
||||
proof: &MrfLifecycleWriterFleetProofToken,
|
||||
expected_topology: &str,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
now < proof.expires_at
|
||||
&& proof._permit.generation.is_accepting()
|
||||
&& fleet_capability_proof_matches_at(state, &proof.token, expected_topology, now)
|
||||
&& state
|
||||
.proof
|
||||
.as_ref()
|
||||
.is_some_and(|current| Arc::ptr_eq(¤t.generation, &proof._permit.generation))
|
||||
}
|
||||
|
||||
/// Reobserve every member before a manifest commit. No proof-slot lock is held
|
||||
/// during network I/O, and any failed or changed observation revokes admission.
|
||||
pub async fn mrf_lifecycle_writer_fleet_proof_matches(proof: &MrfLifecycleWriterFleetProofToken) -> bool {
|
||||
let Some(expected_topology) = configured_fleet_topology_fingerprint() else {
|
||||
return false;
|
||||
};
|
||||
{
|
||||
let state = mrf_lifecycle_writer_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !mrf_lifecycle_writer_fleet_proof_matches_at(&state, proof, expected_topology, Instant::now()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let observed = observe_mrf_lifecycle_reader_fleet(expected_topology, proof.local_reader_max_format).await;
|
||||
let mut state = mrf_lifecycle_writer_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !observed.is_ok_and(|observed| observed == *proof.token.peer_epochs) {
|
||||
revoke_fleet_capability_proof_state(&mut state);
|
||||
return false;
|
||||
}
|
||||
mrf_lifecycle_writer_fleet_proof_matches_at(&state, proof, expected_topology, Instant::now())
|
||||
}
|
||||
|
||||
async fn observe_mrf_lifecycle_reader_fleet(
|
||||
topology_fingerprint: &str,
|
||||
local_reader_max_format: u8,
|
||||
) -> Result<BTreeMap<String, Uuid>> {
|
||||
let notification_sys =
|
||||
get_global_notification_sys().ok_or_else(|| Error::other("MRF lifecycle reader fleet is not initialized"))?;
|
||||
timeout(
|
||||
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
||||
notification_sys.probe_mrf_lifecycle_reader_fleet(topology_fingerprint, local_reader_max_format),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::other("MRF lifecycle reader fleet probe timed out"))?
|
||||
}
|
||||
|
||||
fn mrf_lifecycle_reader_epoch_map(
|
||||
expected_remote_members: &[String],
|
||||
local_member: String,
|
||||
local_reader_max_format: u8,
|
||||
remote_readers: Vec<(String, u8, Uuid)>,
|
||||
) -> Result<BTreeMap<String, Uuid>> {
|
||||
if local_member.trim().is_empty() || local_reader_max_format < MRF_LIFECYCLE_WRITER_FORMAT_VERSION {
|
||||
return Err(Error::other("MRF lifecycle local reader does not support the writer format"));
|
||||
}
|
||||
let mut peer_epochs = BTreeMap::new();
|
||||
insert_remote_version_state_peer(&mut peer_epochs, local_member.clone(), mrf_lifecycle_reader_local_process_epoch())?;
|
||||
for (member, maximum_format, epoch) in remote_readers {
|
||||
if maximum_format < MRF_LIFECYCLE_WRITER_FORMAT_VERSION {
|
||||
return Err(Error::other("MRF lifecycle peer reader does not support the writer format"));
|
||||
}
|
||||
insert_remote_version_state_peer(&mut peer_epochs, member, epoch)?;
|
||||
}
|
||||
validate_capability_fleet_members(expected_remote_members, &local_member, &peer_epochs)?;
|
||||
Ok(peer_epochs)
|
||||
}
|
||||
|
||||
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
|
||||
@@ -1606,6 +1805,25 @@ impl NotificationSys {
|
||||
Ok((peer_epochs, minimum_version))
|
||||
}
|
||||
|
||||
async fn probe_mrf_lifecycle_reader_fleet(
|
||||
&self,
|
||||
topology_fingerprint: &str,
|
||||
local_reader_max_format: u8,
|
||||
) -> Result<BTreeMap<String, Uuid>> {
|
||||
if self.peer_clients.len() != self.peer_topology_hosts.len() {
|
||||
return Err(Error::other("MRF lifecycle reader fleet membership is incomplete"));
|
||||
}
|
||||
let local_member = runtime_sources::local_node_name().await;
|
||||
let probes = self.peer_clients.iter().map(|client| async {
|
||||
let client = client
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("MRF lifecycle reader peer is unreachable"))?;
|
||||
client.probe_mrf_lifecycle_reader(topology_fingerprint.to_string()).await
|
||||
});
|
||||
let readers = join_all(probes).await.into_iter().collect::<Result<Vec<_>>>()?;
|
||||
mrf_lifecycle_reader_epoch_map(&self.peer_topology_hosts, local_member, local_reader_max_format, readers)
|
||||
}
|
||||
|
||||
async fn probe_ilm_recovery_export_fleet(&self, topology_fingerprint: &str) -> Result<BTreeMap<String, Uuid>> {
|
||||
if self.peer_clients.len() != self.peer_topology_hosts.len() {
|
||||
return Err(Error::other("ILM recovery export capability fleet membership is incomplete"));
|
||||
@@ -1626,12 +1844,12 @@ impl NotificationSys {
|
||||
let (peer, epoch) = result?;
|
||||
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
|
||||
}
|
||||
validate_ilm_recovery_export_members(&self.peer_topology_hosts, &local_member, &peer_epochs)?;
|
||||
validate_capability_fleet_members(&self.peer_topology_hosts, &local_member, &peer_epochs)?;
|
||||
Ok(peer_epochs)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ilm_recovery_export_members(
|
||||
fn validate_capability_fleet_members(
|
||||
expected_remote_members: &[String],
|
||||
local_member: &str,
|
||||
observed: &BTreeMap<String, Uuid>,
|
||||
@@ -1642,7 +1860,7 @@ fn validate_ilm_recovery_export_members(
|
||||
.chain(std::iter::once(local_member.to_string()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
if expected.len() != expected_remote_members.len().saturating_add(1) || observed.keys().ne(expected.iter()) {
|
||||
return Err(Error::other("ILM recovery export capability fleet membership does not match topology"));
|
||||
return Err(Error::other("fleet capability membership does not match topology"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4112,6 +4330,228 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_lifecycle_writer_requires_every_reader_and_the_local_decoder() {
|
||||
let expected = vec!["peer-a".to_owned(), "peer-b".to_owned()];
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
let readers = vec![("peer-a".to_owned(), 2, a), ("peer-b".to_owned(), 3, b)];
|
||||
let observed = mrf_lifecycle_reader_epoch_map(&expected, "local".to_owned(), 2, readers.clone())
|
||||
.expect("all members have compatible readers");
|
||||
assert_eq!(observed.len(), 3);
|
||||
assert_eq!(observed["local"], mrf_lifecycle_reader_local_process_epoch());
|
||||
assert_eq!(observed["peer-a"], a);
|
||||
for version in [0, 1] {
|
||||
assert!(mrf_lifecycle_reader_epoch_map(&expected, "local".to_owned(), version, readers.clone()).is_err());
|
||||
let mut old_peer = readers.clone();
|
||||
old_peer[0].1 = version;
|
||||
assert!(mrf_lifecycle_reader_epoch_map(&expected, "local".to_owned(), 2, old_peer).is_err());
|
||||
}
|
||||
for invalid in [
|
||||
vec![readers[0].clone()],
|
||||
vec![("unknown".to_owned(), 2, a), readers[1].clone()],
|
||||
vec![readers[0].clone(), ("peer-a".to_owned(), 2, b)],
|
||||
vec![readers[0].clone(), ("peer-b".to_owned(), 2, a)],
|
||||
vec![readers[0].clone(), ("peer-b".to_owned(), 2, Uuid::nil())],
|
||||
vec![
|
||||
readers[0].clone(),
|
||||
("peer-b".to_owned(), 2, mrf_lifecycle_reader_local_process_epoch()),
|
||||
],
|
||||
] {
|
||||
assert!(mrf_lifecycle_reader_epoch_map(&expected, "local".to_owned(), 2, invalid).is_err());
|
||||
}
|
||||
assert!(mrf_lifecycle_reader_epoch_map(&expected, "".to_owned(), 2, readers).is_err());
|
||||
let single = mrf_lifecycle_reader_epoch_map(&[], "local".to_owned(), 2, Vec::new())
|
||||
.expect("a single node must still prove its local decoder and epoch");
|
||||
assert_eq!(single.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_lifecycle_writer_rejects_expired_failed_and_conflicting_proofs() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let now = Instant::now();
|
||||
let peers = BTreeMap::from([("local".to_owned(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Ok(peers), now).is_none());
|
||||
let token = {
|
||||
let state = slot.read().expect("proof state");
|
||||
assert!(acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 1, now).is_none());
|
||||
assert!(acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "other-topology", 2, now).is_none());
|
||||
let token = acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 2, now)
|
||||
.expect("compatible current observation");
|
||||
assert!(mrf_lifecycle_writer_fleet_proof_matches_at(&state, &token, "topology", now));
|
||||
assert!(!mrf_lifecycle_writer_fleet_proof_matches_at(
|
||||
&state,
|
||||
&token,
|
||||
"topology",
|
||||
now + REMOTE_VERSION_STATE_PROOF_TTL,
|
||||
));
|
||||
token
|
||||
};
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Err(Error::other("old reader")), now).is_some());
|
||||
let state = slot.read().expect("revoked proof state");
|
||||
assert!(!mrf_lifecycle_writer_fleet_proof_matches_at(&state, &token, "topology", now));
|
||||
assert!(acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 2, now).is_none());
|
||||
assert!(state.draining_generation.is_some());
|
||||
drop(state);
|
||||
drop(token);
|
||||
mark_fleet_capability_topology_conflict(&slot);
|
||||
let state = slot.read().expect("conflicting topology state");
|
||||
assert!(acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 2, now).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_lifecycle_writer_refresh_cannot_extend_an_existing_publication() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let now = Instant::now();
|
||||
let peers = BTreeMap::from([("local".to_owned(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Ok(peers.clone()), now).is_none());
|
||||
let old = acquire_mrf_lifecycle_writer_fleet_proof_from(&slot.read().expect("proof state"), "topology", 2, now)
|
||||
.expect("first publication");
|
||||
let refreshed_at = now + REMOTE_VERSION_STATE_PROOF_TTL;
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Ok(peers), refreshed_at).is_none());
|
||||
let state = slot.read().expect("refreshed proof state");
|
||||
let current = acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 2, refreshed_at)
|
||||
.expect("a new publication can use the fresh observation");
|
||||
assert!(mrf_lifecycle_writer_fleet_proof_matches_at(&state, ¤t, "topology", refreshed_at));
|
||||
assert!(!mrf_lifecycle_writer_fleet_proof_matches_at(&state, &old, "topology", refreshed_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_lifecycle_writer_restart_waits_for_the_previous_publication() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let now = Instant::now();
|
||||
let peers = BTreeMap::from([("local".to_owned(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Ok(peers), now).is_none());
|
||||
let token = acquire_mrf_lifecycle_writer_fleet_proof_from(&slot.read().expect("proof state"), "topology", 2, now)
|
||||
.expect("first publication permit");
|
||||
let restarted = BTreeMap::from([("local".to_owned(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Ok(restarted.clone()), now).is_some());
|
||||
{
|
||||
let state = slot.read().expect("draining proof state");
|
||||
assert!(!mrf_lifecycle_writer_fleet_proof_matches_at(&state, &token, "topology", now));
|
||||
assert!(acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 2, now).is_none());
|
||||
}
|
||||
drop(token);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology", Ok(restarted.clone()), now).is_none());
|
||||
let state = slot.read().expect("successor proof state");
|
||||
let successor = acquire_mrf_lifecycle_writer_fleet_proof_from(&state, "topology", 2, now)
|
||||
.expect("successor may be admitted after the old disk operations finish");
|
||||
assert_eq!(*successor.token.peer_epochs, restarted);
|
||||
assert!(mrf_lifecycle_writer_fleet_proof_matches_at(&state, &successor, "topology", now));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrf_lifecycle_writer_probe_rejects_missing_or_unreachable_members() {
|
||||
for peer_clients in [Vec::new(), vec![None]] {
|
||||
let notification_sys = NotificationSys {
|
||||
peer_clients,
|
||||
all_peer_clients: vec![None],
|
||||
peer_topology_hosts: vec!["peer-a".to_owned()],
|
||||
peer_admin_caches: Vec::new(),
|
||||
tier_config_reload_workers: Default::default(),
|
||||
};
|
||||
assert!(
|
||||
notification_sys
|
||||
.probe_mrf_lifecycle_reader_fleet("topology", 2)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn mrf_lifecycle_writer_native_cas_retains_authority_after_timeout_or_cancellation() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use crate::disk::{RUSTFS_META_BUCKET, disk_store::LocalDiskWrapper, local::LocalDisk};
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("60"))], async {
|
||||
let topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get_or_init(|| "mrf-cas-test".to_owned());
|
||||
let slot = mrf_lifecycle_writer_fleet_proof_slot();
|
||||
for cancel_waiter in [true, false] {
|
||||
let root = tempfile::tempdir().expect("isolated CAS fixture");
|
||||
let endpoint = Endpoint::try_from(root.path().to_str().expect("fixture path")).expect("local endpoint");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk"));
|
||||
let volume = disk.make_volume(RUSTFS_META_BUCKET).await;
|
||||
assert!(
|
||||
matches!(volume, Ok(()) | Err(crate::disk::error::Error::VolumeExists)),
|
||||
"metadata volume: {volume:?}"
|
||||
);
|
||||
let relative = "mrf-authority";
|
||||
let path = disk
|
||||
.get_object_path_for_io(RUSTFS_META_BUCKET, relative)
|
||||
.expect("native CAS publication path");
|
||||
std::fs::write(&path, b"old").expect("old committed file");
|
||||
let peers = BTreeMap::from([("local".to_owned(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(slot, topology, Ok(peers), Instant::now()).is_none());
|
||||
let proof = Arc::new(
|
||||
acquire_mrf_lifecycle_writer_fleet_proof_from(
|
||||
&slot.read().expect("proof state"),
|
||||
topology,
|
||||
2,
|
||||
Instant::now(),
|
||||
)
|
||||
.expect("publication permit"),
|
||||
);
|
||||
let weak = Arc::downgrade(&proof);
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let _hook = hooks::install_at(hooks::Stage::Rename, &path, move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let wrapper = LocalDiskWrapper::new(disk, false);
|
||||
let mut write = Box::pin(with_mrf_lifecycle_writer_fleet_proof(
|
||||
proof,
|
||||
wrapper.compare_and_update_file(
|
||||
RUSTFS_META_BUCKET,
|
||||
relative,
|
||||
Some(Bytes::from_static(b"old")),
|
||||
Some(Bytes::from_static(b"new")),
|
||||
),
|
||||
));
|
||||
timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
result = entered_rx => result.expect("native CAS reached publication"),
|
||||
result = write.as_mut() => panic!("CAS returned before publication: {result:?}"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("native CAS boundary");
|
||||
if cancel_waiter {
|
||||
drop(write);
|
||||
} else {
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(61)).await;
|
||||
tokio::time::resume();
|
||||
let error = timeout(Duration::from_secs(5), write)
|
||||
.await
|
||||
.expect("disk deadline is bounded")
|
||||
.expect_err("blocked CAS must time out");
|
||||
assert_eq!(error, crate::disk::error::Error::Timeout);
|
||||
}
|
||||
assert!(weak.upgrade().is_some(), "the native worker still owns the permit");
|
||||
let restarted = BTreeMap::from([("local".to_owned(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(slot, topology, Ok(restarted.clone()), Instant::now()).is_some());
|
||||
assert_eq!(std::fs::read(&path).expect("old file retained while blocked"), b"old");
|
||||
drop(release_tx);
|
||||
timeout(Duration::from_secs(5), async {
|
||||
while weak.upgrade().is_some() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("native worker drains");
|
||||
assert_eq!(std::fs::read(&path).expect("revoked writer cannot publish"), b"old");
|
||||
assert!(publish_fleet_capability_probe_result(slot, topology, Ok(restarted), Instant::now()).is_none());
|
||||
revoke_fleet_capability_proof(slot);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_rejects_stale_or_mismatched_membership() {
|
||||
let now = Instant::now();
|
||||
@@ -4125,6 +4565,134 @@ mod tests {
|
||||
assert!(!fleet_capability_proof_valid_at(None, "topology-a", now));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fleet_proof_wait_observes_publication_before_waiting() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(BTreeMap::new()), Instant::now()).is_none());
|
||||
let published = Arc::clone(&slot.read().expect("proof state should be readable").published);
|
||||
let started_at = tokio::time::Instant::now();
|
||||
assert!(
|
||||
wait_for_fleet_capability_proof(&CancellationToken::new(), &published, || {
|
||||
acquire_fleet_capability_proof_from(
|
||||
&slot.read().expect("proof state should be readable"),
|
||||
"topology-a",
|
||||
Instant::now(),
|
||||
)
|
||||
.is_some()
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(tokio::time::Instant::now(), started_at);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fleet_proof_wait_cannot_lose_publication_between_registration_and_check() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let published = Arc::clone(&slot.read().expect("proof state should be readable").published);
|
||||
let mut checks = 0;
|
||||
let ready = timeout(
|
||||
Duration::from_secs(1),
|
||||
wait_for_fleet_capability_proof(&CancellationToken::new(), &published, || {
|
||||
checks += 1;
|
||||
if checks == 1 {
|
||||
// The authority check saw no proof immediately before the
|
||||
// publisher ran. There will be no second publication.
|
||||
assert!(
|
||||
publish_fleet_capability_probe_result(&slot, "topology-a", Ok(BTreeMap::new()), Instant::now()).is_none()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
acquire_fleet_capability_proof_from(
|
||||
&slot.read().expect("proof state should be readable"),
|
||||
"topology-a",
|
||||
Instant::now(),
|
||||
)
|
||||
.is_some()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("the single publication must wake the registered waiter");
|
||||
assert!(ready);
|
||||
assert_eq!(checks, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fleet_proof_wait_revalidates_authority_without_polling() {
|
||||
let slot = Arc::new(std::sync::RwLock::new(FleetCapabilityProofState::default()));
|
||||
let published = Arc::clone(&slot.read().expect("proof state should be readable").published);
|
||||
let checks = Arc::new(AtomicUsize::new(0));
|
||||
let rx = CancellationToken::new();
|
||||
let task = tokio::spawn({
|
||||
let slot = slot.clone();
|
||||
let published = published.clone();
|
||||
let checks = checks.clone();
|
||||
async move {
|
||||
wait_for_fleet_capability_proof(&rx, &published, || {
|
||||
checks.fetch_add(1, Ordering::SeqCst);
|
||||
acquire_fleet_capability_proof_from(
|
||||
&slot.read().expect("proof state should be readable"),
|
||||
"topology-a",
|
||||
Instant::now(),
|
||||
)
|
||||
.is_some()
|
||||
})
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 1);
|
||||
tokio::time::advance(Duration::from_secs(300)).await;
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 1, "missing proof must sleep until publication");
|
||||
|
||||
assert!(
|
||||
publish_fleet_capability_probe_result(
|
||||
&slot,
|
||||
"topology-a",
|
||||
Ok(BTreeMap::new()),
|
||||
Instant::now() - REMOTE_VERSION_STATE_PROOF_TTL,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 2);
|
||||
assert!(!task.is_finished(), "a published but expired proof must not authorize recovery");
|
||||
|
||||
slot.write().expect("proof state should be writable").topology_conflict = true;
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(BTreeMap::new()), Instant::now()).is_none());
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 3);
|
||||
assert!(!task.is_finished(), "a publication must not bypass conflicting topology");
|
||||
|
||||
slot.write().expect("proof state should be writable").topology_conflict = false;
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(BTreeMap::new()), Instant::now()).is_none());
|
||||
assert!(task.await.expect("a renewed valid proof should release the waiter"));
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fleet_proof_wait_cancels_without_publication() {
|
||||
let rx = CancellationToken::new();
|
||||
let checks = Arc::new(AtomicUsize::new(0));
|
||||
let task = tokio::spawn({
|
||||
let rx = rx.clone();
|
||||
let checks = checks.clone();
|
||||
async move {
|
||||
wait_for_fleet_capability_proof(&rx, &Notify::new(), || {
|
||||
checks.fetch_add(1, Ordering::SeqCst);
|
||||
false
|
||||
})
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 1);
|
||||
let cancelled_at = tokio::time::Instant::now();
|
||||
rx.cancel();
|
||||
assert!(!task.await.expect("proof wait must observe cancellation"));
|
||||
assert_eq!(checks.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tokio::time::Instant::now(), cancelled_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_rejects_nil_process_epoch() {
|
||||
let mut peer_epochs = BTreeMap::new();
|
||||
@@ -4193,12 +4761,12 @@ mod tests {
|
||||
(local.to_string(), Uuid::new_v4()),
|
||||
(expected_remote[0].clone(), Uuid::new_v4()),
|
||||
]);
|
||||
assert!(validate_ilm_recovery_export_members(&expected_remote, local, &complete).is_ok());
|
||||
assert!(validate_capability_fleet_members(&expected_remote, local, &complete).is_ok());
|
||||
|
||||
let unexpected = BTreeMap::from([(local.to_string(), Uuid::new_v4()), ("node-c".to_string(), Uuid::new_v4())]);
|
||||
assert!(validate_ilm_recovery_export_members(&expected_remote, local, &unexpected).is_err());
|
||||
assert!(validate_capability_fleet_members(&expected_remote, local, &unexpected).is_err());
|
||||
assert!(
|
||||
validate_ilm_recovery_export_members(&[local.to_string()], local, &complete).is_err(),
|
||||
validate_capability_fleet_members(&[local.to_string()], local, &complete).is_err(),
|
||||
"the configured remote set cannot repeat the local member"
|
||||
);
|
||||
}
|
||||
@@ -4678,6 +5246,7 @@ mod tests {
|
||||
)),
|
||||
draining_generation: None,
|
||||
topology_conflict: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_some());
|
||||
|
||||
|
||||
@@ -1450,12 +1450,13 @@ impl ECStore {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::com::delete_config;
|
||||
use crate::config::com::read_config_no_lock_preserve_empty_with_metadata;
|
||||
use crate::core::pools::{
|
||||
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_NAME, PoolActivationDurableSaveBarrier,
|
||||
PoolActivationStartKind, PoolActivationStartProbe, PoolMetaWriteState, persist_pool_meta_identity_for_startup,
|
||||
set_decommission_capacity_info_overrides_for_test,
|
||||
};
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::object_api::NamespaceLockFence;
|
||||
use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause, hermetic_set_disks_isolated};
|
||||
|
||||
@@ -1679,10 +1680,38 @@ mod tests {
|
||||
.await
|
||||
.expect("initialized pool metadata identity should persist");
|
||||
*store.pool_meta_save_gate.lock().await = write_state;
|
||||
// Simulate physical loss; raw deletion of authoritative pool.bin is
|
||||
// deliberately rejected by the native metadata mutation guard.
|
||||
for pool in &store.pools {
|
||||
delete_config(pool.clone(), POOL_META_NAME)
|
||||
.await
|
||||
.expect("every pool metadata replica should be removed");
|
||||
let (original_identity, _) =
|
||||
read_config_no_lock_preserve_empty_with_metadata(pool.clone(), crate::core::pools::POOL_META_IDENTITY_NAME)
|
||||
.await
|
||||
.expect("initialized identity before physical pool metadata loss");
|
||||
let metadata_set = pool.get_disks_by_key(POOL_META_NAME);
|
||||
let disks = metadata_set.disks.read().await.clone();
|
||||
assert!(disks.iter().all(Option::is_some), "metadata-loss fixture requires every replica online");
|
||||
for disk in disks.iter().flatten() {
|
||||
assert!(disk.is_local(), "metadata-loss fixture requires local disks");
|
||||
let metadata = std::path::PathBuf::from(disk.endpoint().get_file_path())
|
||||
.join(crate::disk::RUSTFS_META_BUCKET)
|
||||
.join(POOL_META_NAME);
|
||||
tokio::fs::remove_dir_all(&metadata)
|
||||
.await
|
||||
.expect("remove the actual pool metadata shard");
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&metadata)
|
||||
.await
|
||||
.expect("inspect physically absent metadata")
|
||||
);
|
||||
}
|
||||
let (identity_after, _) =
|
||||
read_config_no_lock_preserve_empty_with_metadata(pool.clone(), crate::core::pools::POOL_META_IDENTITY_NAME)
|
||||
.await
|
||||
.expect("initialized identity survives physical metadata loss");
|
||||
assert!(
|
||||
identity_after == original_identity,
|
||||
"fault injection must preserve the initialized identity bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1782,6 +1782,141 @@ mod tests {
|
||||
fixture.assert_moved().await;
|
||||
}
|
||||
|
||||
/// An `ExpiredObjectAllVersions` expiry is a prefix delete that takes the
|
||||
/// bucket lifecycle write lock, while the entry holds that lock for read
|
||||
/// through its bucket incarnation fence. Applied inline, it waited out the
|
||||
/// lock timeout, stalled every lifecycle read in the bucket behind it, and
|
||||
/// failed the entry. The version moves instead and lifecycle expires it on
|
||||
/// the target (rustfs/backlog#2873).
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn real_rebalance_moves_a_version_expired_by_a_delete_all_rule() {
|
||||
run_large_stack_async_test("rebalance-delete-all-expiry", run_delete_all_expiry_case);
|
||||
}
|
||||
|
||||
async fn run_delete_all_expiry_case() {
|
||||
const REBALANCE_ID: &str = "rebalance-delete-all-expiry";
|
||||
let (_temp_dirs, store, _peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None).await;
|
||||
let bucket = "rebalance-delete-all";
|
||||
let object = "expired.bin";
|
||||
// An active run blocks the data-usage publication bucket creation needs.
|
||||
Box::pin(store.make_bucket(
|
||||
bucket,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
|
||||
let rebalance_meta = active_rebalance_meta(REBALANCE_ID);
|
||||
rebalance_meta
|
||||
.save(Arc::clone(&store.pools[0]))
|
||||
.await
|
||||
.expect("persist the active rebalance run");
|
||||
*store.rebalance_meta.write().await = Some(rebalance_meta);
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
let payload = b"expired by an all-versions rule".to_vec();
|
||||
let written = Box::pin(source_set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(payload.clone()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(10)),
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.await
|
||||
.expect("seed the expired source version");
|
||||
let lifecycle = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
expired_object_all_versions: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("delete-all".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
};
|
||||
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
|
||||
|
||||
let lock_timeout = crate::set_disk::get_lock_acquire_timeout();
|
||||
let started = std::time::Instant::now();
|
||||
let outcome = tokio::time::timeout(
|
||||
lock_timeout,
|
||||
Box::pin(Arc::clone(&store).rebalance_entry(
|
||||
RebalanceEntryTarget {
|
||||
bucket: bucket.to_string(),
|
||||
pool_index: 0,
|
||||
},
|
||||
entry,
|
||||
Arc::clone(&source_set),
|
||||
Arc::new(RebalanceBucketConfigs {
|
||||
bucket_incarnation_id: Some(incarnation),
|
||||
lifecycle_config: Some(lifecycle),
|
||||
..Default::default()
|
||||
}),
|
||||
Arc::from(REBALANCE_ID),
|
||||
CancellationToken::new(),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("the entry must not wait out the bucket lifecycle lock timeout")
|
||||
.expect("rebalance entry");
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(outcome, RebalanceEntryOutcome::Completed);
|
||||
assert!(
|
||||
elapsed < lock_timeout / 2,
|
||||
"the entry must finish well under the {lock_timeout:?} lock timeout, took {elapsed:?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
source_set
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("read source versions")
|
||||
.is_none(),
|
||||
"the source entry must be removed once the version moved"
|
||||
);
|
||||
let target = store.pools[1]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("read target versions")
|
||||
.expect("the version must reach the target pool");
|
||||
assert_eq!(target.versions.len(), 1, "{:?}", target.versions);
|
||||
assert_eq!(target.versions[0].version_id, written.version_id);
|
||||
let mut reader = store.pools[1]
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: written.version_id.map(|version| version.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the moved version should be readable");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("drain the moved version");
|
||||
assert_eq!(body, payload);
|
||||
}
|
||||
|
||||
/// Overwriting after versioning was enabled leaves a newer version above
|
||||
/// the null version. The old `None` read selected that newer version, the
|
||||
/// target accepted it as an equivalent copy, and cleanup then deleted the
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
@@ -76,6 +76,21 @@ pub async fn test_store_with_persisted_rebalance_meta(
|
||||
.await
|
||||
.expect("rebalance test metadata should be persisted");
|
||||
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = vec![pool.endpoints.clone()].into();
|
||||
let authority = ctx
|
||||
.bind_multipart_proof_topology(&endpoint_pools)
|
||||
.expect("single-pool fixture must register its complete local topology");
|
||||
for set in &pool.disk_set {
|
||||
let disks = set.disks.read().await;
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
|
||||
.expect("single-pool native handles must bind to their actual instance");
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
assert_eq!(
|
||||
disk.as_ref().expect("single-pool fixture disk").get_disk_id().await.unwrap(),
|
||||
Some(pool.format.erasure.sets[set.set_index][slot]),
|
||||
"single-pool fixture must bind every formatted physical slot"
|
||||
);
|
||||
}
|
||||
}
|
||||
let store = std::sync::Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: std::collections::HashMap::new(),
|
||||
@@ -239,18 +254,75 @@ async fn test_pool_stores_with_contexts(
|
||||
}
|
||||
let endpoint_pools: EndpointServerPools = pools.iter().map(|pool| pool.endpoints.clone()).collect::<Vec<_>>().into();
|
||||
ctx.set_endpoints(endpoint_pools.clone());
|
||||
let multipart_authority = ctx
|
||||
.bind_multipart_proof_topology(&endpoint_pools)
|
||||
.expect("the fixture's complete topology should contain only its local disks");
|
||||
for pool in &pools {
|
||||
for set in &pool.disk_set {
|
||||
let disks = set.disks.read().await;
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &multipart_authority)
|
||||
.expect("fixture disks should bind to their complete instance topology");
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
assert_eq!(
|
||||
disk.as_ref().expect("fixture disk").get_disk_id().await.unwrap(),
|
||||
Some(pool.format.erasure.sets[set.set_index][slot]),
|
||||
"fixture must bind its actual formatted cohort"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let other_ctx = if isolate_node_contexts {
|
||||
let other_ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
other_ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
other_ctx.set_deployment_id(deployment_id);
|
||||
*other_ctx.local_disk_map().write().await = ctx.local_disk_map().read().await.clone();
|
||||
other_ctx.set_endpoints(endpoint_pools.clone());
|
||||
other_ctx
|
||||
} else {
|
||||
std::sync::Arc::clone(&ctx)
|
||||
};
|
||||
let make_store = |store_ctx: std::sync::Arc<crate::runtime::instance::InstanceContext>| {
|
||||
let mut store_pools = pools.clone();
|
||||
let other_pools = if isolate_node_contexts {
|
||||
let peer_authority = other_ctx
|
||||
.bind_multipart_proof_topology(&endpoint_pools)
|
||||
.expect("the simulated peer should validate the same complete physical topology");
|
||||
let mut peer_pools = pools.clone();
|
||||
let mut peer_disk_map = std::collections::HashMap::new();
|
||||
for pool in &mut peer_pools {
|
||||
let pool = std::sync::Arc::make_mut(pool);
|
||||
pool.set_instance_ctx_for_test(std::sync::Arc::clone(&other_ctx));
|
||||
for set in &mut pool.disk_set {
|
||||
let set = std::sync::Arc::make_mut(set);
|
||||
let mut peer_disks = Vec::with_capacity(set.set_endpoints.len());
|
||||
let source_disks = set.disks.read().await;
|
||||
for (slot, endpoint) in set.set_endpoints.iter().enumerate() {
|
||||
// Share durable contents and lock clients, while each node
|
||||
// context owns its independently opened native handles.
|
||||
let disk = crate::disk::new_disk(
|
||||
endpoint,
|
||||
&crate::disk::DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("open the peer's independent local disk handle");
|
||||
assert!(!std::sync::Arc::ptr_eq(&disk, source_disks[slot].as_ref().expect("source fixture disk")));
|
||||
assert_eq!(disk.get_disk_id().await.unwrap(), Some(pool.format.erasure.sets[set.set_index][slot]));
|
||||
peer_disk_map.insert(endpoint.to_string(), Some(disk.clone()));
|
||||
peer_disks.push(Some(disk));
|
||||
}
|
||||
drop(source_disks);
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&peer_disks, &peer_authority)
|
||||
.expect("independent peer handles should bind only to their own instance");
|
||||
set.disks = std::sync::Arc::new(tokio::sync::RwLock::new(peer_disks));
|
||||
}
|
||||
}
|
||||
*other_ctx.local_disk_map().write().await = peer_disk_map;
|
||||
peer_pools
|
||||
} else {
|
||||
pools.clone()
|
||||
};
|
||||
let make_store = |store_ctx: std::sync::Arc<crate::runtime::instance::InstanceContext>,
|
||||
mut store_pools: Vec<std::sync::Arc<crate::core::sets::Sets>>| {
|
||||
for pool in &mut store_pools {
|
||||
std::sync::Arc::make_mut(pool).set_instance_ctx_for_test(std::sync::Arc::clone(&store_ctx));
|
||||
}
|
||||
@@ -268,8 +340,8 @@ async fn test_pool_stores_with_contexts(
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
})
|
||||
};
|
||||
let store = make_store(ctx);
|
||||
let other_store = make_store(other_ctx);
|
||||
let store = make_store(ctx, pools);
|
||||
let other_store = make_store(other_ctx, other_pools);
|
||||
if isolate_node_contexts {
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(std::sync::Arc::clone(&store), Vec::new()).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(std::sync::Arc::clone(&other_store), Vec::new()).await;
|
||||
|
||||
@@ -603,6 +603,10 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
|
||||
request.object_version_id = version_id.filter(|value| !value.is_empty()).map(str::to_string);
|
||||
// A degraded read's surviving minority is not a recreate instruction; the
|
||||
// locked storage read decides whether the target is absent (backlog#2900).
|
||||
// Reader failures include checksum damage that metadata-only healing cannot detect.
|
||||
if matches!(reason, "missing_shards" | "decode_error") {
|
||||
request.scan_mode = Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep);
|
||||
}
|
||||
|
||||
let request_id = request.id.clone();
|
||||
let bucket = bucket.to_string();
|
||||
@@ -3051,6 +3055,123 @@ impl RenameRollbackReceipt {
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical callbacks retain this holder, never independent logical-guard
|
||||
/// references. A verified quorum success can release those references without
|
||||
/// revoking native mutation leases, accounting or resource ownership.
|
||||
pub(in crate::set_disk) struct MultipartLogicalGuards {
|
||||
payload: parking_lot::Mutex<Option<Box<dyn Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl MultipartLogicalGuards {
|
||||
pub(in crate::set_disk) fn new(payload: impl Send + Sync + 'static) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
payload: parking_lot::Mutex::new(Some(Box::new(payload))),
|
||||
})
|
||||
}
|
||||
|
||||
fn release_after_native_quorum(&self) {
|
||||
let payload = self.payload.lock().take();
|
||||
drop(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// The only owner of the original native publication capabilities. The full
|
||||
/// non-early-ACK fanout has no producer after `join_all`; consuming this owner
|
||||
/// makes the success decision irreversible even if a later quota/tail step fails.
|
||||
struct PublishingMultipartCapabilities {
|
||||
publications: Vec<Option<crate::disk::multipart_source::MultipartPublicationResult>>,
|
||||
authority: crate::disk::multipart_source::ProofVerificationContext,
|
||||
commit_owner: Option<Arc<dyn Send + Sync>>,
|
||||
logical_guards: Option<Arc<MultipartLogicalGuards>>,
|
||||
}
|
||||
|
||||
impl PublishingMultipartCapabilities {
|
||||
fn new(
|
||||
slots: usize,
|
||||
authority: crate::disk::multipart_source::ProofVerificationContext,
|
||||
commit_owner: Option<Arc<dyn Send + Sync>>,
|
||||
logical_guards: Option<Arc<MultipartLogicalGuards>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
publications: (0..slots).map(|_| None).collect(),
|
||||
authority,
|
||||
commit_owner,
|
||||
logical_guards,
|
||||
}
|
||||
}
|
||||
|
||||
fn succeeded(self) {
|
||||
// This consumes the only failed-owner capability collection before
|
||||
// logical locks can become available to a subsequent operation.
|
||||
drop(self.publications);
|
||||
if let Some(logical_guards) = self.logical_guards {
|
||||
logical_guards.release_after_native_quorum();
|
||||
}
|
||||
}
|
||||
|
||||
async fn failed_owned(
|
||||
self,
|
||||
disks: &[Option<DiskStore>],
|
||||
file_infos: &[FileInfo],
|
||||
source: &str,
|
||||
target: (&str, &str),
|
||||
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||
) {
|
||||
let Some(info) = disks.iter().zip(file_infos).find_map(|(disk, info)| {
|
||||
(disk.is_some() && info.has_valid_erasure_geometry() && info.erasure.distribution.len() == disks.len())
|
||||
.then_some(info)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let has_capability = self.publications.iter().any(Option::is_some);
|
||||
let mut capabilities = self.publications;
|
||||
let mut physical_disks = vec![None; disks.len()];
|
||||
let mut physical_capabilities = (0..disks.len()).map(|_| None).collect::<Vec<_>>();
|
||||
for (physical, logical) in info.erasure.distribution.iter().copied().enumerate() {
|
||||
let Some(logical) = logical.checked_sub(1).filter(|slot| *slot < disks.len()) else { return };
|
||||
physical_disks[physical] = disks[logical].clone();
|
||||
physical_capabilities[physical] = capabilities[logical].take();
|
||||
}
|
||||
let source = source.to_owned();
|
||||
let target = (target.0.to_owned(), target.1.to_owned());
|
||||
let authority = self.authority;
|
||||
let namespace_owner = self
|
||||
.commit_owner
|
||||
.or_else(|| namespace_commit_guard.map(|owner| owner as Arc<dyn Send + Sync>));
|
||||
let recovery = tokio::spawn(crate::disk::with_optional_resource_unit(
|
||||
crate::disk::current_resource_unit(),
|
||||
async move {
|
||||
// Own the capabilities, accounting and physical recovery tails
|
||||
// independently of both the request and its undo waiter.
|
||||
let _namespace_owner = namespace_owner.clone();
|
||||
let _ = super::multipart_source::recover_source(
|
||||
&physical_disks,
|
||||
&target.0,
|
||||
&target.1,
|
||||
&source,
|
||||
Some(physical_capabilities),
|
||||
&authority,
|
||||
namespace_owner,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
));
|
||||
if has_capability {
|
||||
let timeout = crate::disk::disk_store::get_max_timeout_duration();
|
||||
if timeout.is_zero() {
|
||||
let _ = recovery.await;
|
||||
} else {
|
||||
let _ = tokio::time::timeout(timeout, recovery).await;
|
||||
}
|
||||
}
|
||||
// Zero acknowledged capabilities cannot authorize synchronous Undo.
|
||||
// Inspecting unknown outcomes must not add another full disk deadline
|
||||
// to the original publication timeout. Dropping the JoinHandle only
|
||||
// detaches its owner; a later retry can confirm a completed target.
|
||||
// The original write-quorum error remains the API result in every case.
|
||||
}
|
||||
}
|
||||
|
||||
struct RenameRollbackOwnership {
|
||||
receipt: Option<RenameRollbackReceipt>,
|
||||
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||
@@ -3286,6 +3407,8 @@ pub(in crate::set_disk) struct RenameDataFenceOptions<'a> {
|
||||
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
|
||||
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
|
||||
rollback_receipt: Option<RenameRollbackReceipt>,
|
||||
multipart_proof_authority: Option<crate::disk::multipart_source::ProofVerificationContext>,
|
||||
multipart_commit_owner: Option<Arc<MultipartLogicalGuards>>,
|
||||
}
|
||||
|
||||
impl<'a> RenameDataFenceOptions<'a> {
|
||||
@@ -3299,9 +3422,24 @@ impl<'a> RenameDataFenceOptions<'a> {
|
||||
scanner_publication_commit_scope: None,
|
||||
namespace_commit_guard: None,
|
||||
rollback_receipt: None,
|
||||
multipart_proof_authority: None,
|
||||
multipart_commit_owner: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn with_multipart_commit_owner(mut self, owner: Arc<MultipartLogicalGuards>) -> Self {
|
||||
self.multipart_commit_owner = Some(owner);
|
||||
self
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn with_multipart_proof_authority(
|
||||
mut self,
|
||||
authority: crate::disk::multipart_source::ProofVerificationContext,
|
||||
) -> Self {
|
||||
self.multipart_proof_authority = Some(authority);
|
||||
self
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn with_rollback_receipt(mut self, receipt: RenameRollbackReceipt) -> Self {
|
||||
self.rollback_receipt = Some(receipt);
|
||||
self
|
||||
@@ -3775,6 +3913,8 @@ impl SetDisks {
|
||||
scanner_publication_commit_scope: _scanner_publication_commit_scope,
|
||||
namespace_commit_guard,
|
||||
rollback_receipt,
|
||||
multipart_proof_authority: _,
|
||||
multipart_commit_owner: _,
|
||||
} = fence_options;
|
||||
if let Some(file_info) = disks
|
||||
.iter()
|
||||
@@ -4164,6 +4304,16 @@ impl SetDisks {
|
||||
) -> disk::error::Result<RenameDataCommit> {
|
||||
let (src_bucket, src_object) = src;
|
||||
let (dst_bucket, dst_object) = dst;
|
||||
let protected_multipart = src_bucket == RUSTFS_META_MULTIPART_BUCKET
|
||||
&& file_infos.iter().any(|info| {
|
||||
rustfs_utils::http::contains_key_str(
|
||||
&info.metadata,
|
||||
crate::disk::multipart_source::MULTIPART_SOURCE_CONDITION_SUFFIX,
|
||||
)
|
||||
});
|
||||
if protected_multipart && allow_early_ack {
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
if allow_early_ack && put_rename_early_ack_enabled() {
|
||||
return Self::rename_data_owned_early_ack_with_fence(
|
||||
disks,
|
||||
@@ -4182,6 +4332,8 @@ impl SetDisks {
|
||||
scanner_publication_commit_scope,
|
||||
namespace_commit_guard,
|
||||
rollback_receipt,
|
||||
multipart_proof_authority,
|
||||
multipart_commit_owner,
|
||||
} = fence_options;
|
||||
if let Some(file_info) = disks
|
||||
.iter()
|
||||
@@ -4198,6 +4350,11 @@ impl SetDisks {
|
||||
file_info.validate_for_erasure_write()?;
|
||||
}
|
||||
}
|
||||
let multipart_proof_authority = if protected_multipart {
|
||||
Some(multipart_proof_authority.ok_or(DiskError::UnsupportedDisk)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut errs = Vec::with_capacity(disks.len());
|
||||
|
||||
let src_bucket = Arc::new(src_bucket.to_string());
|
||||
@@ -4214,12 +4371,19 @@ impl SetDisks {
|
||||
let fanout_dst_bucket = dst_bucket.clone();
|
||||
let fanout_dst_object = dst_object.clone();
|
||||
let fanout_publication_scope = scanner_publication_commit_scope.clone();
|
||||
let logical_guards = multipart_commit_owner;
|
||||
let multipart_commit_owner = logical_guards
|
||||
.as_ref()
|
||||
.map(|owner| Arc::new((namespace_commit_guard.clone(), owner.clone())) as Arc<dyn Send + Sync>);
|
||||
let fanout_namespace_owner = multipart_commit_owner
|
||||
.clone()
|
||||
.or_else(|| namespace_commit_guard.clone().map(|owner| owner as Arc<dyn Send + Sync>));
|
||||
let fanout_namespace_commit_guard = namespace_commit_guard.clone();
|
||||
// Keep one coordinator task so a cancelled caller cannot drop partially
|
||||
// completed disk mutations. Per-disk futures stay ordered in `join_all`,
|
||||
// preserving slot-indexed quorum and convergence accounting without a
|
||||
// scheduler task for every disk.
|
||||
let fanout = tokio::spawn(async move {
|
||||
let fanout_future = async move {
|
||||
// Keep the storage-owned movement permit attached to the actual
|
||||
// fan-out owner, even if the caller future is cancelled.
|
||||
let _fanout_publication_scope = fanout_publication_scope;
|
||||
@@ -4238,7 +4402,7 @@ impl SetDisks {
|
||||
let dst_bucket = fanout_dst_bucket.clone();
|
||||
let successful_rename_completion_rank = successful_rename_completion_rank.clone();
|
||||
let publication_scope = scanner_publication_commit_scope.clone();
|
||||
let namespace_commit_guard = fanout_namespace_commit_guard.clone();
|
||||
let namespace_owner = fanout_namespace_owner.clone();
|
||||
|
||||
async move {
|
||||
let mut dispatch_state = RenameDispatchState::NotDispatched;
|
||||
@@ -4299,9 +4463,7 @@ impl SetDisks {
|
||||
&dst_object,
|
||||
disk::RenameDataGuards {
|
||||
scanner_publication_lease_token,
|
||||
namespace_owner: namespace_commit_guard
|
||||
.clone()
|
||||
.map(|owner| owner as Arc<dyn Send + Sync>),
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -4348,7 +4510,15 @@ impl SetDisks {
|
||||
#[cfg(test)]
|
||||
rollback_fault_injection::after_fanout(&fanout_dst_object);
|
||||
(results, fanout_file_infos)
|
||||
});
|
||||
};
|
||||
let fanout = if protected_multipart {
|
||||
tokio::spawn(crate::disk::with_optional_resource_unit(
|
||||
crate::disk::current_resource_unit(),
|
||||
fanout_future,
|
||||
))
|
||||
} else {
|
||||
tokio::spawn(fanout_future)
|
||||
};
|
||||
|
||||
let mut disk_versions = vec![None; disk_count];
|
||||
let mut data_dirs = vec![None; disk_count];
|
||||
@@ -4395,21 +4565,54 @@ impl SetDisks {
|
||||
);
|
||||
}
|
||||
|
||||
let mut multipart_capabilities = multipart_proof_authority.map(|authority| {
|
||||
PublishingMultipartCapabilities::new(results.len(), authority, multipart_commit_owner, logical_guards)
|
||||
});
|
||||
let mut dispatch_states = Vec::with_capacity(results.len());
|
||||
for (idx, (dispatch_state, result)) in results.iter().enumerate() {
|
||||
dispatch_states.push(*dispatch_state);
|
||||
for (idx, (dispatch_state, result)) in results.into_iter().enumerate() {
|
||||
dispatch_states.push(dispatch_state);
|
||||
match result {
|
||||
Ok(Ok(res)) => {
|
||||
Ok(Ok(mut res)) => {
|
||||
if let Some(owner) = multipart_capabilities.as_mut() {
|
||||
let publication = res.multipart_source.take().map(|result| *result);
|
||||
let valid = publication.as_ref().is_some_and(|publication| {
|
||||
publication.verify_with_authority(&owner.authority).is_ok()
|
||||
&& publication.proof.snapshot.lineage == publication.publication.lineage
|
||||
&& publication.proof.snapshot.generation == publication.publication.source_generation
|
||||
&& publication.proof.snapshot.phase
|
||||
== crate::disk::multipart_source::MultipartSourcePhase::ConsumedUnsealed
|
||||
&& !owner
|
||||
.publications
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|existing| existing.proof.snapshot.disk_id == publication.proof.snapshot.disk_id)
|
||||
&& crate::disk::multipart_source::publication_identity(&file_infos[idx])
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_ref()
|
||||
== Some(&publication.publication)
|
||||
&& file_infos[idx]
|
||||
.erasure
|
||||
.distribution
|
||||
.get(publication.proof.snapshot.physical_slot)
|
||||
== Some(&(idx + 1))
|
||||
});
|
||||
if !valid {
|
||||
errs.push(Some(DiskError::FileCorrupt));
|
||||
continue;
|
||||
}
|
||||
owner.publications[idx] = publication;
|
||||
}
|
||||
data_dirs[idx] = res.rollback_data_dir.or(res.old_data_dir);
|
||||
cleanup_data_dirs[idx] = res.cleanup_data_dir;
|
||||
disk_versions[idx].clone_from(&res.sign);
|
||||
disk_versions[idx] = res.sign;
|
||||
old_current_sizes[idx] = res.old_current_size;
|
||||
old_current_source_checked[idx] = res.old_current_source_checked;
|
||||
old_current_sources[idx] = res.old_current_source.clone();
|
||||
old_current_sources[idx] = res.old_current_source;
|
||||
errs.push(None);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
errs.push(Some(e.clone()));
|
||||
errs.push(Some(e));
|
||||
}
|
||||
Err(_) => {
|
||||
errs.push(Some(DiskError::Unexpected));
|
||||
@@ -4461,21 +4664,32 @@ impl SetDisks {
|
||||
);
|
||||
}
|
||||
|
||||
rollback_failed_rename(
|
||||
disks,
|
||||
file_infos,
|
||||
&errs,
|
||||
&dispatch_states,
|
||||
&data_dirs,
|
||||
(&dst_bucket, &dst_object),
|
||||
RenameRollbackOwnership {
|
||||
receipt: rollback_receipt,
|
||||
namespace_commit_guard,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Some(owner) = multipart_capabilities {
|
||||
// Destination-only Undo cannot restore a consumed MPU head.
|
||||
// Only this failed owner can spend the original native caps.
|
||||
owner
|
||||
.failed_owned(disks, &file_infos, &src_object, (&dst_bucket, &dst_object), namespace_commit_guard)
|
||||
.await;
|
||||
} else {
|
||||
rollback_failed_rename(
|
||||
disks,
|
||||
file_infos,
|
||||
&errs,
|
||||
&dispatch_states,
|
||||
&data_dirs,
|
||||
(&dst_bucket, &dst_object),
|
||||
RenameRollbackOwnership {
|
||||
receipt: rollback_receipt,
|
||||
namespace_commit_guard,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Err(ret_err);
|
||||
}
|
||||
if let Some(owner) = multipart_capabilities {
|
||||
owner.succeeded();
|
||||
}
|
||||
|
||||
// The write is authoritatively committed, so the per-disk rollback
|
||||
// backup (`object/<rollback_dir>/xl.meta.bkp`) is dead weight now.
|
||||
@@ -4556,7 +4770,10 @@ impl SetDisks {
|
||||
};
|
||||
let online_disks = Self::eval_disks(disks, &errs);
|
||||
let committed_slot = online_disks.iter().position(Option::is_some).ok_or(DiskError::Unexpected)?;
|
||||
let committed_file_info = std::mem::take(&mut file_infos[committed_slot]);
|
||||
let mut committed_file_info = std::mem::take(&mut file_infos[committed_slot]);
|
||||
if protected_multipart {
|
||||
crate::disk::multipart_source::strip_transport_condition(&mut committed_file_info.metadata);
|
||||
}
|
||||
let cleanup_disks = if let Some(data_dir) = data_dir {
|
||||
disks
|
||||
.iter()
|
||||
@@ -11341,7 +11558,7 @@ mod tests {
|
||||
let mut hooks = Vec::new();
|
||||
let mut entered = Vec::new();
|
||||
let mut releases = Vec::new();
|
||||
let mut publication_paths = Vec::new();
|
||||
let mut mutation_keys = Vec::new();
|
||||
for (disk, info) in disks.iter().flatten().zip(&mut infos) {
|
||||
disk.write_metadata(bucket, bucket, object, old.clone())
|
||||
.await
|
||||
@@ -11352,11 +11569,16 @@ mod tests {
|
||||
let disk::Disk::Local(local) = disk.as_ref() else {
|
||||
panic!("physical publication fixture requires local disks");
|
||||
};
|
||||
// Linux IO paths use a mount FD, which is also the namespace lock key.
|
||||
// Hooks use pinned I/O paths; serialization uses the configured
|
||||
// object key shared by independently reopened disk handles.
|
||||
let destination = local
|
||||
.get_disk()
|
||||
.get_object_path_for_io(bucket, object)
|
||||
.expect("the publication path must resolve through the disk's mount lease");
|
||||
let mutation_key = local
|
||||
.get_disk()
|
||||
.get_object_path(bucket, object)
|
||||
.expect("the configured publication mutation key must resolve");
|
||||
let (entered_tx, entered_rx) = oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
hooks.push(os::prepared_publication_test_hooks::install(
|
||||
@@ -11369,7 +11591,7 @@ mod tests {
|
||||
));
|
||||
entered.push(entered_rx);
|
||||
releases.push(release_tx);
|
||||
publication_paths.push(destination);
|
||||
mutation_keys.push(mutation_key);
|
||||
}
|
||||
let namespace_owner = ctx.begin_namespace_commit();
|
||||
let namespace_probe = Arc::downgrade(&namespace_owner);
|
||||
@@ -11416,10 +11638,10 @@ mod tests {
|
||||
let pending_before_release = ctx.namespace_commits_pending();
|
||||
let owner_alive_before_release = namespace_probe.upgrade().is_some();
|
||||
let old_snapshot_generation = ctx.namespace_commit_generation();
|
||||
for (disk, destination) in disks.iter().flatten().zip(&publication_paths) {
|
||||
for (disk, mutation_key) in disks.iter().flatten().zip(&mutation_keys) {
|
||||
let root = disk.path();
|
||||
assert!(
|
||||
os::acquire_rename_data_mutation_lease(&root, bucket, destination)
|
||||
os::acquire_rename_data_mutation_lease(&root, bucket, mutation_key)
|
||||
.now_or_never()
|
||||
.is_none(),
|
||||
"the physical publication must still own object serialization after the async timeout"
|
||||
@@ -11450,11 +11672,11 @@ mod tests {
|
||||
|
||||
// Drain real syscalls before checking the regression, including on the RED run.
|
||||
drop(releases);
|
||||
for (disk, destination) in disks.iter().flatten().zip(&publication_paths) {
|
||||
for (disk, mutation_key) in disks.iter().flatten().zip(&mutation_keys) {
|
||||
let root = disk.path();
|
||||
let lease = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
os::acquire_rename_data_mutation_lease(&root, bucket, destination),
|
||||
os::acquire_rename_data_mutation_lease(&root, bucket, mutation_key),
|
||||
)
|
||||
.await
|
||||
.expect("released physical publishers must drain");
|
||||
@@ -13362,6 +13584,61 @@ mod tests {
|
||||
release_read_repair_heal_reservation(&key).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_repair_reader_failure_requests_deep_scan_for_the_exact_version() {
|
||||
fn captured() -> &'static std::sync::Mutex<Option<rustfs_heal_contracts::heal_channel::HealChannelRequest>> {
|
||||
static REQUEST: OnceLock<std::sync::Mutex<Option<rustfs_heal_contracts::heal_channel::HealChannelRequest>>> =
|
||||
OnceLock::new();
|
||||
REQUEST.get_or_init(|| std::sync::Mutex::new(None))
|
||||
}
|
||||
fn capture(request: rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
|
||||
*captured().lock().expect("read-repair request capture must be available") = Some(request);
|
||||
Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) })
|
||||
}
|
||||
|
||||
for (reason, expected_scan) in [
|
||||
("missing_shards", Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep)),
|
||||
("decode_error", Some(rustfs_heal_contracts::heal_channel::HealScanMode::Deep)),
|
||||
("metadata_read_error", None),
|
||||
] {
|
||||
let object = format!("reader-failure-{}", Uuid::new_v4());
|
||||
let version = Uuid::new_v4().to_string();
|
||||
submit_read_repair_heal_with_submitter(
|
||||
ReadRepairHealSubmission {
|
||||
bucket: "reader-failure",
|
||||
object: &object,
|
||||
version_id: Some(&version),
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
part_number: Some(1),
|
||||
reason,
|
||||
mrf_intent: None,
|
||||
},
|
||||
capture,
|
||||
)
|
||||
.await;
|
||||
let request = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(request) = captured().lock().expect("captured request must be readable").take() {
|
||||
break request;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the actual asynchronous admission must be captured");
|
||||
assert_eq!(request.scan_mode, expected_scan, "reader damage needs payload verification");
|
||||
assert_eq!(request.source, HealRequestSource::ReadRepair);
|
||||
assert_eq!(request.bucket, "reader-failure");
|
||||
assert_eq!(request.object_prefix.as_deref(), Some(object.as_str()));
|
||||
assert_eq!(request.object_version_id.as_deref(), Some(version.as_str()));
|
||||
assert_eq!((request.pool_index, request.set_index), (Some(1), Some(2)));
|
||||
assert_eq!(request.recreate_missing, None, "storage evidence decides absence");
|
||||
let key = ReadRepairHealCacheKey::new("reader-failure", &object, Some(&version), 1, 2);
|
||||
release_read_repair_heal_reservation(&key).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// backlog#898 — groups A/C: pure old-data-dir cleanup classification.
|
||||
// ========================================================================
|
||||
|
||||
@@ -20,3 +20,4 @@
|
||||
pub(crate) mod io_primitives;
|
||||
pub(in crate::set_disk) mod metadata_quorum;
|
||||
pub(in crate::set_disk) mod metadata_read;
|
||||
pub(in crate::set_disk) mod multipart_source;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ use crate::runtime::sources as runtime_sources;
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations;
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
|
||||
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
|
||||
list::{StorageObjectInfoOrErr, StorageWalkOptions},
|
||||
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectToDelete},
|
||||
@@ -310,8 +310,6 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
||||
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
type InlineBitrotReader = coding::BitrotReader<crate::io_support::bitrot::ShardReader>;
|
||||
@@ -891,6 +889,8 @@ pub(crate) use ops::heal::AbsenceProofRequest;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::heal::DanglingDeleteFailure;
|
||||
pub(crate) use ops::heal::HealedObjectAbsence;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::heal::ReadRepairPauseScope;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::hermetic_set_disks_isolated;
|
||||
@@ -4512,6 +4512,9 @@ pub struct SetDisks {
|
||||
/// listings of such a prefix are frequent and the scan reads the whole
|
||||
/// subtree, so it is not repeated within `ORPHAN_PURGE_BACKOFF` (#6898).
|
||||
orphan_purge_backoff: Arc<std::sync::Mutex<HashMap<String, std::time::Instant>>>,
|
||||
/// Hermetic sets may retain shared services while owning isolated proof scopes.
|
||||
#[cfg(test)]
|
||||
multipart_proof_authority_override: Arc<std::sync::RwLock<Option<crate::disk::multipart_source::ProofVerificationContext>>>,
|
||||
#[cfg(test)]
|
||||
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
|
||||
#[cfg(test)]
|
||||
@@ -5459,6 +5462,8 @@ impl SetDisks {
|
||||
tier_free_version_rename_data_source_capable: Arc::new(AtomicBool::new(false)),
|
||||
orphan_purge_backoff: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
#[cfg(test)]
|
||||
multipart_proof_authority_override: Arc::new(std::sync::RwLock::new(None)),
|
||||
#[cfg(test)]
|
||||
storage_class_config_override: Arc::new(std::sync::RwLock::new(None)),
|
||||
#[cfg(test)]
|
||||
rename_tail_heal_capture: Arc::new(std::sync::Mutex::new(None)),
|
||||
@@ -5471,6 +5476,30 @@ impl SetDisks {
|
||||
&self.ctx
|
||||
}
|
||||
|
||||
pub(crate) fn multipart_proof_verification_context(&self) -> crate::disk::multipart_source::ProofVerificationContext {
|
||||
#[cfg(test)]
|
||||
if let Some(authority) = self
|
||||
.multipart_proof_authority_override
|
||||
.read()
|
||||
.expect("hermetic multipart authority lock should not be poisoned")
|
||||
.clone()
|
||||
{
|
||||
return authority;
|
||||
}
|
||||
self.ctx.multipart_proof_verification_context()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_multipart_proof_authority_for_test(
|
||||
&self,
|
||||
authority: crate::disk::multipart_source::ProofVerificationContext,
|
||||
) {
|
||||
*self
|
||||
.multipart_proof_authority_override
|
||||
.write()
|
||||
.expect("hermetic multipart authority lock should not be poisoned") = Some(authority);
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn tier_free_version_rename_data_source_capable(&self) -> bool {
|
||||
self.tier_free_version_rename_data_source_capable.load(Ordering::Acquire)
|
||||
}
|
||||
@@ -8026,8 +8055,7 @@ mod tests {
|
||||
use crate::set_disk::core::io_primitives::rename_fanout_barrier;
|
||||
use crate::set_disk::ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
use crate::storage_api_contracts::{
|
||||
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
|
||||
object::ObjectOperations as _,
|
||||
heal::HealOperations as _, lifecycle::TransitionedObject, multipart::CompletePart, object::ObjectOperations as _,
|
||||
};
|
||||
use crate::store::init_format::save_format_file;
|
||||
use crate::store::list_objects::ListPathOptions;
|
||||
@@ -12753,7 +12781,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[tokio::test]
|
||||
async fn test_get_disks_info_probes_drives_concurrently() {
|
||||
use crate::disk::disk_store::DISK_INFO_PROBE_DELAY_FOR_TEST;
|
||||
|
||||
@@ -12768,6 +12796,8 @@ mod tests {
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
|
||||
// Complete real filesystem setup before measuring virtual probe delays.
|
||||
tokio::time::pause();
|
||||
let probe_delay = std::time::Duration::from_secs(2);
|
||||
let started = tokio::time::Instant::now();
|
||||
let info = DISK_INFO_PROBE_DELAY_FOR_TEST
|
||||
@@ -15525,6 +15555,26 @@ mod tests {
|
||||
|
||||
let instance_ctx = Arc::new(InstanceContext::new());
|
||||
instance_ctx.update_erasure_type(SetupType::Erasure).await;
|
||||
let topology = crate::layout::endpoints::EndpointServerPools(vec![crate::layout::endpoints::PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: drive_count,
|
||||
endpoints: endpoints.clone().into(),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
}]);
|
||||
let authority = instance_ctx
|
||||
.bind_multipart_proof_topology(&topology)
|
||||
.expect("standalone set fixture owns the complete local topology");
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
|
||||
.expect("standalone fixture native handles should bind to its own instance");
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
assert_eq!(
|
||||
disk.as_ref().expect("fixture disk").get_disk_id().await.unwrap(),
|
||||
Some(format.erasure.sets[0][slot]),
|
||||
"fixture must use the full actual formatted cohort"
|
||||
);
|
||||
}
|
||||
let set_disks = SetDisks::new_with_instance_ctx(
|
||||
"test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
@@ -15647,22 +15697,6 @@ mod tests {
|
||||
.await
|
||||
.expect("object should be written");
|
||||
|
||||
let list_result = set_disks
|
||||
.clone()
|
||||
.list_objects_v2(bucket, "", None, None, 1000, false, None, false)
|
||||
.await
|
||||
.expect("set-level list_objects_v2 should succeed");
|
||||
assert_eq!(list_result.objects.len(), 1);
|
||||
assert_eq!(list_result.objects[0].name, "object");
|
||||
|
||||
let versions_result = set_disks
|
||||
.clone()
|
||||
.list_object_versions(bucket, "", None, None, None, 1000)
|
||||
.await
|
||||
.expect("set-level list_object_versions should succeed");
|
||||
assert_eq!(versions_result.objects.len(), 1);
|
||||
assert_eq!(versions_result.objects[0].name, "object");
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
set_disks
|
||||
.clone()
|
||||
|
||||
@@ -113,6 +113,7 @@ pub(crate) struct DeleteRecoveryContext<'a> {
|
||||
pub repair_marker_source: bool,
|
||||
pub namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
pub recovery_ticket: Option<crate::store::delete_recovery_runtime::DeleteRecoveryTicket>,
|
||||
pub published_hint: Option<(Uuid, &'a [Result<DeleteRecoveryInspection>])>,
|
||||
pub can_commit: &'a (dyn Fn() -> bool + Sync),
|
||||
}
|
||||
|
||||
@@ -125,6 +126,7 @@ impl<'a> DeleteRecoveryContext<'a> {
|
||||
repair_marker_source: false,
|
||||
namespace_owner: None,
|
||||
recovery_ticket: None,
|
||||
published_hint: None,
|
||||
can_commit,
|
||||
}
|
||||
}
|
||||
@@ -421,7 +423,9 @@ impl SetDisks {
|
||||
let cleanup = loop {
|
||||
#[cfg(test)]
|
||||
test_hooks::record_cleanup_attempt(bucket, object);
|
||||
match self.recover_delete_key(bucket, object, context.clone()).await {
|
||||
let mut attempt_context = context.clone();
|
||||
attempt_context.published_hint = (attempt == 1).then_some((transaction, published.as_slice()));
|
||||
match self.recover_delete_key(bucket, object, attempt_context).await {
|
||||
// A stale settlement fingerprint only means the snapshot raced
|
||||
// another writer; each attempt re-inspects every participant.
|
||||
Err(DiskError::OutdatedXLMeta) if attempt < PUBLISHED_CLEANUP_ATTEMPTS && can_commit() => attempt += 1,
|
||||
@@ -551,6 +555,7 @@ impl SetDisks {
|
||||
explicit_delete,
|
||||
repair_marker_source,
|
||||
namespace_owner,
|
||||
published_hint,
|
||||
can_commit,
|
||||
recovery_ticket: _,
|
||||
} = context;
|
||||
@@ -589,7 +594,18 @@ impl SetDisks {
|
||||
let mut all_states = BTreeMap::new();
|
||||
for transaction in transactions {
|
||||
let mut complete = Vec::with_capacity(disks.len());
|
||||
for state in inspect(&disks, bucket, object, transaction).await {
|
||||
let inspections = if let Some((published_tx, published_states)) = published_hint
|
||||
&& !repair_marker_source
|
||||
&& !dry_run
|
||||
&& published_tx == transaction
|
||||
&& published_states.len() == disks.len()
|
||||
&& published_states.iter().all(Result::is_ok)
|
||||
{
|
||||
published_states.to_vec()
|
||||
} else {
|
||||
inspect(&disks, bucket, object, transaction).await
|
||||
};
|
||||
for state in inspections {
|
||||
match state {
|
||||
Ok(state) => {
|
||||
if let Some(journal) = &state.journal {
|
||||
@@ -832,6 +848,9 @@ impl SetDisks {
|
||||
bucket,
|
||||
copies,
|
||||
|copy| {
|
||||
if receipt(copy.state) && copy.commit_data_safe {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
if copy.state == DeleteRecoveryState::Absent
|
||||
|| (certified_same_transaction
|
||||
@@ -857,7 +876,10 @@ impl SetDisks {
|
||||
&disks,
|
||||
bucket,
|
||||
copies,
|
||||
|_| Some(DeleteRecoveryAction::FinishCommit),
|
||||
|copy| {
|
||||
(copy.state != DeleteRecoveryState::CommitFinished || !copy.commit_data_safe)
|
||||
.then_some(DeleteRecoveryAction::FinishCommit)
|
||||
},
|
||||
can_commit,
|
||||
namespace_owner.as_ref(),
|
||||
&mut summary.removed_versions,
|
||||
@@ -922,13 +944,25 @@ impl SetDisks {
|
||||
// GC is a key-wide barrier: every predecessor must have settled
|
||||
// before the last surviving commit witness can disappear.
|
||||
let mut settled = Vec::with_capacity(all_states.len());
|
||||
for transaction in all_states.keys() {
|
||||
let fresh = inspect(&disks, bucket, object, *transaction).await;
|
||||
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
|
||||
if all_states.len() == 1 {
|
||||
let copies = all_states.into_values().next().ok_or(DiskError::FileCorrupt)?;
|
||||
if copies
|
||||
.iter()
|
||||
.any(|state| !matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))
|
||||
{
|
||||
summary.pending += 1;
|
||||
return Ok(summary);
|
||||
}
|
||||
settled.push(fresh.into_iter().collect::<Result<Vec<_>>>()?);
|
||||
settled.push(copies);
|
||||
} else {
|
||||
for transaction in all_states.keys() {
|
||||
let fresh = inspect(&disks, bucket, object, *transaction).await;
|
||||
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
|
||||
summary.pending += 1;
|
||||
return Ok(summary);
|
||||
}
|
||||
settled.push(fresh.into_iter().collect::<Result<Vec<_>>>()?);
|
||||
}
|
||||
}
|
||||
for (index, mut copies) in settled.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
@@ -2607,100 +2641,328 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn heal_mrf_inline_overwrite_does_not_strand_rollback_backup() {
|
||||
use crate::disk::STORAGE_FORMAT_FILE_BACKUP;
|
||||
use crate::disk::local::inline_metadata_rollback_dir;
|
||||
use crate::disk::local::{DurabilityMode, durability_mode, effective_durability, inline_metadata_rollback_dir};
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::layout::format::FormatV3;
|
||||
use crate::object_api::ShardIntegrityWriteMode;
|
||||
use crate::store::init_format::load_format_erasure;
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_heal_contracts::heal_channel::{HealScanMode, LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL};
|
||||
|
||||
for incoming_inline in [true, false] {
|
||||
let bucket = if incoming_inline {
|
||||
"heal-inline-rollback"
|
||||
} else {
|
||||
"heal-inline-to-external-rollback"
|
||||
};
|
||||
let (dirs, store, disks) = fixture(bucket).await;
|
||||
let incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("bucket identity");
|
||||
let synthetic = inline_metadata_rollback_dir(Uuid::nil(), &FileMeta::new());
|
||||
let object = "object";
|
||||
let child = format!("{object}/{synthetic}");
|
||||
let child_body = b"child bytes must survive parent repair".to_vec();
|
||||
let write_options = ObjectOptions {
|
||||
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
for (key, body) in [(child.as_str(), child_body.as_slice()), (object, b"inline-gen-1".as_slice())] {
|
||||
store
|
||||
.put_object(bucket, key, &mut PutObjReader::from_vec(body.to_vec()), &write_options)
|
||||
.await
|
||||
.expect("seed initial inline objects");
|
||||
}
|
||||
let read_options = ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
};
|
||||
let old_child = disks[3]
|
||||
.read_version("", bucket, &child, "", &read_options)
|
||||
.await
|
||||
.expect("physical child before repair");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
set.disks.write().await[3] = None;
|
||||
let payload = if incoming_inline {
|
||||
b"inline-gen-2-updated".to_vec()
|
||||
} else {
|
||||
vec![0x53; 1024 * 1024]
|
||||
};
|
||||
store
|
||||
.put_object(bucket, object, &mut PutObjReader::from_vec(payload.clone()), &write_options)
|
||||
.await
|
||||
.expect("degraded overwrite with a real stale target");
|
||||
set.disks.write().await[3] = Some(disks[3].clone());
|
||||
let donor = disks[0]
|
||||
.read_version("", bucket, object, "", &read_options)
|
||||
.await
|
||||
.expect("current donor generation");
|
||||
assert_eq!(donor.data.is_some(), incoming_inline, "fixture must use the intended commit branch");
|
||||
let heal_opts = HealOpts {
|
||||
scan_mode: HealScanMode::Normal,
|
||||
..Default::default()
|
||||
};
|
||||
let first = store
|
||||
.heal_mrf_object_at_incarnation(bucket, object, "", incarnation, &heal_opts)
|
||||
.await
|
||||
.expect("first MRF repair");
|
||||
assert!(first.error.is_none(), "first MRF repair: {:?}", first.error);
|
||||
assert_eq!(first.item.drives_healed(), Some(1));
|
||||
assert!(!first.item.repair_verified);
|
||||
assert_eq!(first.item.detail, LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL);
|
||||
let target = disks[3]
|
||||
.read_version("", bucket, object, "", &read_options)
|
||||
.await
|
||||
.expect("repaired target generation");
|
||||
assert_eq!(target.mod_time, donor.mod_time);
|
||||
assert_eq!(target.data_dir, donor.data_dir);
|
||||
assert_eq!(target.size, donor.size);
|
||||
assert_eq!(target.version_id, donor.version_id);
|
||||
let child_after = disks[3]
|
||||
.read_version("", bucket, &child, "", &read_options)
|
||||
.await
|
||||
.expect("physical child survives parent cleanup");
|
||||
assert_eq!(child_after.data, old_child.data);
|
||||
assert!(child_after.data.is_some(), "child fixture must carry its physical inline bytes");
|
||||
let child_dir = dirs[3].path().join(bucket).join(object).join(synthetic.to_string());
|
||||
assert!(!child_dir.join(STORAGE_FORMAT_FILE_BACKUP).exists());
|
||||
assert!(child_dir.join(STORAGE_FORMAT_FILE).exists());
|
||||
let second = store
|
||||
.heal_mrf_object_at_incarnation(bucket, object, "", incarnation, &heal_opts)
|
||||
.await
|
||||
.expect("follow-up MRF check must not return SlowDown");
|
||||
assert!(second.error.is_none(), "second MRF check: {:?}", second.error);
|
||||
assert_eq!(second.item.drives_healed(), Some(0));
|
||||
assert_eq!(second.item.detail, LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL);
|
||||
const CHILD_MANIFEST: &str = "RUSTFS_HEAL_MRF_RESTART_MANIFEST";
|
||||
const CHILD_PHASE: &str = "RUSTFS_HEAL_MRF_RESTART_PHASE";
|
||||
const TEST_NAME: &str =
|
||||
"set_disk::ops::delete_recovery::tests::heal_mrf_inline_overwrite_does_not_strand_rollback_backup";
|
||||
|
||||
// EC2+2: force the repaired shard to participate in the exact read quorum.
|
||||
fn object_tree(root: &std::path::Path) -> BTreeMap<std::path::PathBuf, Option<Vec<u8>>> {
|
||||
let mut tree = BTreeMap::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(directory) = pending.pop() {
|
||||
for entry in std::fs::read_dir(directory).expect("read object tree") {
|
||||
let path = entry.expect("object tree entry").path();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.expect("object tree stays below its root")
|
||||
.to_path_buf();
|
||||
if path.is_dir() {
|
||||
tree.insert(relative, None);
|
||||
pending.push(path);
|
||||
} else {
|
||||
tree.insert(relative, Some(std::fs::read(path).expect("read original object bytes")));
|
||||
}
|
||||
}
|
||||
}
|
||||
tree
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct RestartDisk {
|
||||
root: std::path::PathBuf,
|
||||
slot: [i32; 3],
|
||||
disk_id: Uuid,
|
||||
format: FormatV3,
|
||||
transaction: Uuid,
|
||||
evidence: DeleteRecoveryInspection,
|
||||
tree: BTreeMap<std::path::PathBuf, Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct RestartFixture {
|
||||
writer_pid: u32,
|
||||
receipt_nonce: Uuid,
|
||||
incoming_inline: bool,
|
||||
legacy: bool,
|
||||
set_count: usize,
|
||||
drives_per_set: usize,
|
||||
cmd_line: String,
|
||||
platform: String,
|
||||
bucket: String,
|
||||
object: String,
|
||||
incarnation: Uuid,
|
||||
reads: Vec<(String, Option<String>, Vec<u8>)>,
|
||||
disks: Vec<RestartDisk>,
|
||||
}
|
||||
|
||||
fn read_restart_fixture(path: &std::path::Path) -> RestartFixture {
|
||||
rmp_serde::from_slice(&std::fs::read(path).expect("read persisted MRF fixture"))
|
||||
.expect("decode persisted MRF fixture")
|
||||
}
|
||||
|
||||
fn assert_owned_disk_root(owned_root: &std::path::Path, disk_root: &std::path::Path) {
|
||||
let owned_root = owned_root.canonicalize().expect("supervisor root must exist");
|
||||
let disk_root = disk_root.canonicalize().expect("original disk directory must exist");
|
||||
assert_ne!(disk_root, owned_root, "each disk must have its own directory");
|
||||
assert!(
|
||||
disk_root.starts_with(&owned_root),
|
||||
"restart disk must remain below the supervisor-owned root"
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_restart_process(phase: &str, owned_root: &std::path::Path, manifest_path: &std::path::Path) -> u32 {
|
||||
let child_phase = phase.to_string();
|
||||
let owned_root = owned_root.to_path_buf();
|
||||
let manifest_path = manifest_path.to_path_buf();
|
||||
let (child_pid, output, timed_out) = tokio::task::spawn_blocking(move || {
|
||||
let mut child = std::process::Command::new(std::env::current_exe().expect("current test executable"))
|
||||
.args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"])
|
||||
.env(CHILD_PHASE, child_phase)
|
||||
.env(CHILD_MANIFEST, manifest_path)
|
||||
.env("TMPDIR", &owned_root)
|
||||
.env("TMP", &owned_root)
|
||||
.env("TEMP", &owned_root)
|
||||
.env("RUSTFS_DURABILITY_MODE", "strict")
|
||||
.env("RUSTFS_NEW_BUCKET_DURABILITY_MODE", "strict")
|
||||
.env("RUSTFS_EXPERIMENTAL_DST_DIR_FSYNC_GROUP_COMMIT_ENABLE", "false")
|
||||
.env("RUSTFS_EXPERIMENTAL_FILE_FDATASYNC_GROUP_COMMIT_ENABLE", "false")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("start independent MRF restart process");
|
||||
let child_pid = child.id();
|
||||
let mut stdout = child.stdout.take().expect("captured child stdout");
|
||||
let stdout = std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::Read::read_to_end(&mut stdout, &mut bytes).expect("drain child stdout");
|
||||
bytes
|
||||
});
|
||||
let mut stderr = child.stderr.take().expect("captured child stderr");
|
||||
let stderr = std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
std::io::Read::read_to_end(&mut stderr, &mut bytes).expect("drain child stderr");
|
||||
bytes
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let (status, timed_out) = loop {
|
||||
if let Some(status) = child.try_wait().expect("observe restart process") {
|
||||
break (status, false);
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
break (child.wait().expect("reap timed-out restart process"), true);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
};
|
||||
(
|
||||
child_pid,
|
||||
std::process::Output {
|
||||
status,
|
||||
stdout: stdout.join().expect("child stdout reader"),
|
||||
stderr: stderr.join().expect("child stderr reader"),
|
||||
},
|
||||
timed_out,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("restart process supervisor");
|
||||
println!("MRF {phase} child {child_pid}:\n{}", String::from_utf8_lossy(&output.stdout));
|
||||
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!timed_out, "MRF {phase} process exceeded its 60-second deadline");
|
||||
assert!(output.status.success(), "MRF {phase} process failed: {}", output.status);
|
||||
assert_ne!(child_pid, std::process::id(), "fixture must execute in a separate process");
|
||||
child_pid
|
||||
}
|
||||
|
||||
let Some(phase) = std::env::var_os(CHILD_PHASE) else {
|
||||
for (phase, incoming_inline) in [("writer-inline", true), ("writer-external", false)] {
|
||||
let restart_dir = tempfile::tempdir().expect("supervisor-owned restart directory");
|
||||
let owned_root = restart_dir.path().to_path_buf();
|
||||
let manifest_path = owned_root.join("fixture.msgpack");
|
||||
assert!(!manifest_path.exists(), "each writer must produce a fresh manifest");
|
||||
let writer_pid = run_restart_process(phase, &owned_root, &manifest_path).await;
|
||||
let persisted = read_restart_fixture(&manifest_path);
|
||||
assert_eq!(persisted.writer_pid, writer_pid, "the manifest must come from the exited writer");
|
||||
assert_eq!(persisted.incoming_inline, incoming_inline, "each destination branch must run");
|
||||
for disk in &persisted.disks {
|
||||
assert_owned_disk_root(&owned_root, &disk.root);
|
||||
}
|
||||
println!("MRF {phase} writer {writer_pid} exited successfully before verifier start");
|
||||
let verifier_pid = run_restart_process("verify", &owned_root, &manifest_path).await;
|
||||
assert_ne!(verifier_pid, writer_pid, "the verifier must not be the exited writer");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(manifest_path.with_extension("verified"))
|
||||
.expect("verifier must finish the selected test and write its unique receipt"),
|
||||
format!("{}:{writer_pid}:{verifier_pid}", persisted.receipt_nonce)
|
||||
);
|
||||
for disk in &persisted.disks {
|
||||
assert_eq!(
|
||||
object_tree(&disk.root.join(&persisted.bucket).join(&persisted.object)),
|
||||
disk.tree,
|
||||
"writer exit and verifier exit must leave every original object file and directory unchanged"
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"MRF_RESTART_RECEIPT {}",
|
||||
serde_json::json!({
|
||||
"case": phase,
|
||||
"writer_pid": writer_pid,
|
||||
"writer_exit": "success",
|
||||
"writer_exit_before_verifier_start": true,
|
||||
"verifier_pid": verifier_pid,
|
||||
"verifier_exit": "success",
|
||||
"nonce": persisted.receipt_nonce,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let phase = phase.to_str().expect("restart phase must be UTF-8");
|
||||
let manifest_path =
|
||||
std::path::PathBuf::from(std::env::var_os(CHILD_MANIFEST).expect("restart child requires its supervisor manifest"));
|
||||
let owned_root = manifest_path
|
||||
.parent()
|
||||
.expect("manifest must have a supervisor-owned parent")
|
||||
.to_path_buf();
|
||||
assert!(owned_root.is_dir(), "supervisor root must exist");
|
||||
|
||||
if phase == "verify" {
|
||||
let persisted = read_restart_fixture(&manifest_path);
|
||||
for disk in &persisted.disks {
|
||||
assert_owned_disk_root(&owned_root, &disk.root);
|
||||
}
|
||||
assert_ne!(persisted.writer_pid, std::process::id(), "reopen must use a fresh process");
|
||||
assert_eq!(durability_mode(), DurabilityMode::Strict, "restarted process must actually use strict");
|
||||
let endpoints = EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: persisted.legacy,
|
||||
set_count: persisted.set_count,
|
||||
drives_per_set: persisted.drives_per_set,
|
||||
endpoints: Endpoints::from(
|
||||
persisted
|
||||
.disks
|
||||
.iter()
|
||||
.map(|disk| {
|
||||
let mut endpoint = Endpoint::try_from(disk.root.to_str().expect("original UTF-8 disk path"))
|
||||
.expect("original local endpoint");
|
||||
endpoint.set_pool_index(usize::try_from(disk.slot[0]).expect("original pool index"));
|
||||
endpoint.set_set_index(usize::try_from(disk.slot[1]).expect("original set index"));
|
||||
endpoint.set_disk_index(usize::try_from(disk.slot[2]).expect("original disk index"));
|
||||
endpoint
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
cmd_line: persisted.cmd_line.clone(),
|
||||
platform: persisted.platform.clone(),
|
||||
}]);
|
||||
let context = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
crate::store::init_local_disks_with_instance_ctx(&context, endpoints.clone())
|
||||
.await
|
||||
.expect("reopen persisted disks with original pool, set and disk indices");
|
||||
let reopened = ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().expect("test address"),
|
||||
endpoints.clone(),
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.expect("reopen persisted store in a fresh process");
|
||||
reopened
|
||||
.background_cancel_token()
|
||||
.expect("reopened background token")
|
||||
.cancel();
|
||||
tokio::task::yield_now().await;
|
||||
init_bucket_metadata_sys(reopened.clone(), Vec::new()).await;
|
||||
let bucket = persisted.bucket.as_str();
|
||||
let object = persisted.object.as_str();
|
||||
assert_eq!(
|
||||
reopened
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("reopened bucket identity"),
|
||||
persisted.incarnation
|
||||
);
|
||||
assert_eq!(durability_mode(), DurabilityMode::Strict, "reopened process must remain strict");
|
||||
assert_eq!(effective_durability(bucket), DurabilityMode::Strict, "reopened bucket must remain strict");
|
||||
let set = &reopened.pools[0].disk_set[0];
|
||||
let disks: Vec<_> = set
|
||||
.disk_inventory()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|disk| disk.expect("reopened disk"))
|
||||
.collect();
|
||||
assert_eq!(disks.len(), persisted.disks.len());
|
||||
for (index, (disk, expected)) in disks.iter().zip(&persisted.disks).enumerate() {
|
||||
assert_eq!(disk.get_disk_id().await.expect("reopened disk identity"), Some(expected.disk_id));
|
||||
assert_eq!(disk.endpoint(), endpoints.0[0].endpoints.as_ref()[index]);
|
||||
assert_eq!(
|
||||
load_format_erasure(disk, false)
|
||||
.await
|
||||
.expect("reopened persisted disk format"),
|
||||
expected.format,
|
||||
"restart must retain the original deployment and set membership"
|
||||
);
|
||||
let evidence = disk
|
||||
.inspect_delete_transaction(bucket, object, expected.transaction)
|
||||
.await
|
||||
.expect("query retained C-class backup by its exact UUID");
|
||||
assert_eq!(evidence, expected.evidence, "startup must preserve the original C fingerprint");
|
||||
assert_eq!(
|
||||
evidence.state,
|
||||
DeleteRecoveryState::Absent,
|
||||
"live backup must not become a delete blocker"
|
||||
);
|
||||
assert!(
|
||||
evidence.journal.is_none() && evidence.backup_digest.is_some(),
|
||||
"no historical commit authority"
|
||||
);
|
||||
assert!(!evidence.explicit_delete_safe, "historical versions cannot grant fresh legacy discard");
|
||||
assert_eq!(
|
||||
object_tree(&expected.root.join(bucket).join(object)),
|
||||
expected.tree,
|
||||
"startup and inspection preserve the entire original object tree"
|
||||
);
|
||||
}
|
||||
let healthy = reopened
|
||||
.heal_mrf_object_at_incarnation(
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
persisted.incarnation,
|
||||
&HealOpts {
|
||||
scan_mode: HealScanMode::Normal,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("restarted MRF check must not create another backup");
|
||||
assert!(healthy.error.is_none(), "restarted MRF check: {:?}", healthy.error);
|
||||
assert_eq!(healthy.item.drives_healed(), Some(0));
|
||||
for (disk, expected) in disks.iter().zip(&persisted.disks) {
|
||||
assert_eq!(
|
||||
disk.inspect_delete_transaction(bucket, object, expected.transaction)
|
||||
.await
|
||||
.expect("C evidence after restarted MRF"),
|
||||
expected.evidence,
|
||||
"follow-up MRF must preserve the exact C fingerprint"
|
||||
);
|
||||
assert_eq!(
|
||||
object_tree(&expected.root.join(bucket).join(object)),
|
||||
expected.tree,
|
||||
"follow-up MRF must not create or remove any backup"
|
||||
);
|
||||
}
|
||||
let inventory = set.disks.read().await.clone();
|
||||
*set.disks.write().await = vec![None, None, Some(disks[2].clone()), Some(disks[3].clone())];
|
||||
for (key, expected) in [(object, payload), (child.as_str(), child_body)] {
|
||||
for (key, version_id, expected) in &persisted.reads {
|
||||
let mut reader = set
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
@@ -2708,19 +2970,289 @@ mod tests {
|
||||
None,
|
||||
Default::default(),
|
||||
&ObjectOptions {
|
||||
version_id: version_id.clone(),
|
||||
no_lock: true,
|
||||
suppress_read_repair: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("exact-quorum GET using repaired target");
|
||||
let mut body = Vec::new();
|
||||
reader.read_to_end(&mut body).await.expect("read complete parent/child body");
|
||||
assert_eq!(body, expected, "exact-quorum body for {key}");
|
||||
.expect("restarted exact-quorum GET using the repaired disk");
|
||||
let mut actual = Vec::new();
|
||||
reader.read_to_end(&mut actual).await.expect("read complete persisted object");
|
||||
assert_eq!(&actual, expected, "restarted content for {key}");
|
||||
}
|
||||
*set.disks.write().await = inventory;
|
||||
std::fs::write(
|
||||
manifest_path.with_extension("verified"),
|
||||
format!("{}:{}:{}", persisted.receipt_nonce, persisted.writer_pid, std::process::id()),
|
||||
)
|
||||
.expect("write child completion receipt after every assertion");
|
||||
return;
|
||||
}
|
||||
|
||||
let incoming_inline = match phase {
|
||||
"writer-inline" => true,
|
||||
"writer-external" => false,
|
||||
_ => panic!("unexpected MRF restart phase: {phase}"),
|
||||
};
|
||||
assert_eq!(durability_mode(), DurabilityMode::Strict, "writer process must actually use strict");
|
||||
let bucket = if incoming_inline {
|
||||
"heal-inline-rollback"
|
||||
} else {
|
||||
"heal-inline-to-external-rollback"
|
||||
};
|
||||
let (dirs, store, disks) = temp_env::async_with_vars(
|
||||
[
|
||||
("RUSTFS_DURABILITY_MODE", Some("strict")),
|
||||
("RUSTFS_NEW_BUCKET_DURABILITY_MODE", Some("strict")),
|
||||
],
|
||||
fixture(bucket),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(durability_mode(), DurabilityMode::Strict, "process durability must be strict");
|
||||
assert_eq!(effective_durability(bucket), DurabilityMode::Strict, "bucket durability must be strict");
|
||||
let incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("bucket identity");
|
||||
let synthetic = inline_metadata_rollback_dir(Uuid::nil(), &FileMeta::new());
|
||||
let object = "object";
|
||||
let child = format!("{object}/{synthetic}");
|
||||
let child_body = b"child bytes must survive parent repair".to_vec();
|
||||
let write_options = ObjectOptions {
|
||||
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
let historical_body = vec![0x41; 1024 * 1024];
|
||||
let historical_version = store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(historical_body.clone()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..write_options.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed old external version")
|
||||
.version_id
|
||||
.expect("historical version has a UUID");
|
||||
for (key, body) in [(child.as_str(), child_body.as_slice()), (object, b"inline-gen-1".as_slice())] {
|
||||
store
|
||||
.put_object(bucket, key, &mut PutObjReader::from_vec(body.to_vec()), &write_options)
|
||||
.await
|
||||
.expect("seed initial inline objects");
|
||||
}
|
||||
let mut historical_backups = Vec::new();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
let historical = disk
|
||||
.read_version("", bucket, object, &historical_version.to_string(), &ReadOptions::default())
|
||||
.await
|
||||
.expect("read historical external version");
|
||||
assert!(!historical.inline_data(), "historical version must use real data shards");
|
||||
let data_dir = historical.data_dir.expect("historical data directory");
|
||||
let mut backup = FileMeta::new();
|
||||
backup.add_version(historical).expect("retain historical version evidence");
|
||||
let bytes = backup.marshal_msg().expect("encode historical backup");
|
||||
let backup_path = dirs[index]
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join(object)
|
||||
.join(data_dir.to_string())
|
||||
.join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
tokio::fs::write(&backup_path, &bytes)
|
||||
.await
|
||||
.expect("seed unreceipted historical backup");
|
||||
historical_backups.push((data_dir, bytes));
|
||||
}
|
||||
let read_options = ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
};
|
||||
let old_child = disks[3]
|
||||
.read_version("", bucket, &child, "", &read_options)
|
||||
.await
|
||||
.expect("physical child before repair");
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
set.disks.write().await[3] = None;
|
||||
let payload = if incoming_inline {
|
||||
b"inline-gen-2-updated".to_vec()
|
||||
} else {
|
||||
vec![0x53; 1024 * 1024]
|
||||
};
|
||||
store
|
||||
.put_object(bucket, object, &mut PutObjReader::from_vec(payload.clone()), &write_options)
|
||||
.await
|
||||
.expect("degraded overwrite with a real stale target");
|
||||
set.disks.write().await[3] = Some(disks[3].clone());
|
||||
let donor = disks[0]
|
||||
.read_version("", bucket, object, "", &read_options)
|
||||
.await
|
||||
.expect("current donor generation");
|
||||
assert_eq!(donor.data.is_some(), incoming_inline, "fixture must use the intended commit branch");
|
||||
let heal_opts = HealOpts {
|
||||
scan_mode: HealScanMode::Normal,
|
||||
..Default::default()
|
||||
};
|
||||
let first = store
|
||||
.heal_mrf_object_at_incarnation(bucket, object, "", incarnation, &heal_opts)
|
||||
.await
|
||||
.expect("first MRF repair");
|
||||
assert!(first.error.is_none(), "first MRF repair: {:?}", first.error);
|
||||
assert_eq!(first.item.drives_healed(), Some(1));
|
||||
assert!(!first.item.repair_verified);
|
||||
assert_eq!(first.item.detail, LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL);
|
||||
let target = disks[3]
|
||||
.read_version("", bucket, object, "", &read_options)
|
||||
.await
|
||||
.expect("repaired target generation");
|
||||
assert_eq!(target.mod_time, donor.mod_time);
|
||||
assert_eq!(target.data_dir, donor.data_dir);
|
||||
assert_eq!(target.size, donor.size);
|
||||
assert_eq!(target.version_id, donor.version_id);
|
||||
let child_after = disks[3]
|
||||
.read_version("", bucket, &child, "", &read_options)
|
||||
.await
|
||||
.expect("physical child survives parent cleanup");
|
||||
assert_eq!(child_after.data, old_child.data);
|
||||
assert!(child_after.data.is_some(), "child fixture must carry its physical inline bytes");
|
||||
let child_dir = dirs[3].path().join(bucket).join(object).join(synthetic.to_string());
|
||||
assert!(!child_dir.join(STORAGE_FORMAT_FILE_BACKUP).exists());
|
||||
assert!(child_dir.join(STORAGE_FORMAT_FILE).exists());
|
||||
let second = store
|
||||
.heal_mrf_object_at_incarnation(bucket, object, "", incarnation, &heal_opts)
|
||||
.await
|
||||
.expect("follow-up MRF check must not return SlowDown");
|
||||
assert!(second.error.is_none(), "second MRF check: {:?}", second.error);
|
||||
assert_eq!(second.item.drives_healed(), Some(0));
|
||||
assert_eq!(second.item.detail, LEGACY_OBJECT_IDENTITY_UNVERIFIED_DETAIL);
|
||||
|
||||
// EC2+2: force the repaired shard to participate in the exact read quorum.
|
||||
let inventory = set.disks.read().await.clone();
|
||||
*set.disks.write().await = vec![None, None, Some(disks[2].clone()), Some(disks[3].clone())];
|
||||
for (key, expected) in [(object, payload.clone()), (child.as_str(), child_body.clone())] {
|
||||
let mut reader = set
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
key,
|
||||
None,
|
||||
Default::default(),
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
suppress_read_repair: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("exact-quorum GET using repaired target");
|
||||
let mut body = Vec::new();
|
||||
reader.read_to_end(&mut body).await.expect("read complete parent/child body");
|
||||
assert_eq!(body, expected, "exact-quorum body for {key}");
|
||||
}
|
||||
*set.disks.write().await = inventory;
|
||||
|
||||
set.disks.write().await[3] = None;
|
||||
let final_payload = if incoming_inline {
|
||||
b"inline-gen-3-updated-again".to_vec()
|
||||
} else {
|
||||
vec![0x54; 1024 * 1024]
|
||||
};
|
||||
store
|
||||
.put_object(bucket, object, &mut PutObjReader::from_vec(final_payload.clone()), &write_options)
|
||||
.await
|
||||
.expect("second degraded overwrite");
|
||||
set.disks.write().await[3] = Some(disks[3].clone());
|
||||
let second_repair = store
|
||||
.heal_mrf_object_at_incarnation(bucket, object, "", incarnation, &heal_opts)
|
||||
.await
|
||||
.expect("second actual MRF repair");
|
||||
assert!(second_repair.error.is_none(), "second repair: {:?}", second_repair.error);
|
||||
assert_eq!(second_repair.item.drives_healed(), Some(1));
|
||||
|
||||
assert_eq!(store.pools.len(), 1, "restart fixture retains its original single pool");
|
||||
let endpoints = EndpointServerPools(store.pools.iter().map(|pool| pool.endpoints.clone()).collect());
|
||||
let disk_ids = join_all(disks.iter().map(|disk| disk.get_disk_id()))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.expect("original disk identities");
|
||||
let formats = join_all(disks.iter().map(|disk| load_format_erasure(disk, false)))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.expect("original persisted disk formats");
|
||||
let mut restart_disks = Vec::with_capacity(disks.len());
|
||||
for (index, (disk, format)) in disks.iter().zip(&formats).enumerate() {
|
||||
let endpoint = disk.endpoint();
|
||||
let disk_id = disk_ids[index].expect("formatted disk has a UUID");
|
||||
assert_eq!(endpoint, endpoints.0[0].endpoints.as_ref()[index]);
|
||||
assert!(endpoint.is_local, "restart must reopen the original local disk");
|
||||
assert_eq!(std::path::Path::new(&endpoint.get_file_path()), dirs[index].path());
|
||||
assert_eq!(format.id, store.pools[0].format.id);
|
||||
assert_eq!(format.erasure.sets, store.pools[0].format.erasure.sets);
|
||||
assert_eq!(format.erasure.this, disk_id);
|
||||
let set_index = usize::try_from(endpoint.set_idx).expect("original endpoint set index");
|
||||
let disk_index = usize::try_from(endpoint.disk_idx).expect("original endpoint disk index");
|
||||
assert_eq!(format.erasure.sets[set_index][disk_index], disk_id);
|
||||
let (transaction, backup) = &historical_backups[index];
|
||||
let tree = object_tree(&dirs[index].path().join(bucket).join(object));
|
||||
assert_eq!(
|
||||
tree.get(&std::path::PathBuf::from(format!("{transaction}/{STORAGE_FORMAT_FILE_BACKUP}")))
|
||||
.and_then(Option::as_ref),
|
||||
Some(backup),
|
||||
"both repairs must retain the original historical backup"
|
||||
);
|
||||
let evidence = disk
|
||||
.inspect_delete_transaction(bucket, object, *transaction)
|
||||
.await
|
||||
.expect("capture exact pre-restart C evidence");
|
||||
assert_eq!(evidence.state, DeleteRecoveryState::Absent);
|
||||
assert!(evidence.journal.is_none() && evidence.backup_digest.is_some());
|
||||
assert!(!evidence.explicit_delete_safe);
|
||||
assert_eq!(object_tree(&dirs[index].path().join(bucket).join(object)), tree);
|
||||
restart_disks.push(RestartDisk {
|
||||
root: dirs[index].path().to_path_buf(),
|
||||
slot: [endpoint.pool_idx, endpoint.set_idx, endpoint.disk_idx],
|
||||
disk_id,
|
||||
format: format.clone(),
|
||||
transaction: *transaction,
|
||||
evidence,
|
||||
tree,
|
||||
});
|
||||
}
|
||||
let pool = &endpoints.0[0];
|
||||
let receipt_nonce = Uuid::new_v4();
|
||||
let persisted = RestartFixture {
|
||||
writer_pid: std::process::id(),
|
||||
receipt_nonce,
|
||||
incoming_inline,
|
||||
legacy: pool.legacy,
|
||||
set_count: pool.set_count,
|
||||
drives_per_set: pool.drives_per_set,
|
||||
cmd_line: pool.cmd_line.clone(),
|
||||
platform: pool.platform.clone(),
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
incarnation,
|
||||
reads: vec![
|
||||
(object.to_string(), None, final_payload),
|
||||
(child, None, child_body),
|
||||
(object.to_string(), Some(historical_version.to_string()), historical_body),
|
||||
],
|
||||
disks: restart_disks,
|
||||
};
|
||||
for dir in dirs {
|
||||
assert_owned_disk_root(&owned_root, dir.path());
|
||||
let retained = dir.keep();
|
||||
assert_owned_disk_root(&owned_root, &retained);
|
||||
}
|
||||
drop(disks);
|
||||
drop(store);
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
rmp_serde::to_vec_named(&persisted).expect("encode exact original restart state"),
|
||||
)
|
||||
.expect("publish writer manifest after every repair assertion");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,19 +12,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! `ListOperations` for `SetDisks` and the borrow-based prefix-maintenance
|
||||
//! service unit.
|
||||
//! Set-scoped listing (`SetDisks::walk`) and the borrow-based
|
||||
//! prefix-maintenance service unit (`SetDisks::delete_all`).
|
||||
//!
|
||||
//! P3 of the SetDisks God-Object split (tracking backlog#815, issue #819).
|
||||
//! Consolidates the `ListOperations` storage-api contract impl (relocated from
|
||||
//! `set_disk/mod.rs`) with the `SetDisks::delete_all` borrow service unit
|
||||
//! (relocated from `set_disk/list.rs`). Method bodies are moved verbatim and
|
||||
//! runtime behavior is unchanged.
|
||||
//! Paginated listings are an `ECStore` concern: they merge every set of every
|
||||
//! pool, so `SetDisks` deliberately does not implement the `ListOperations`
|
||||
//! storage-api contract.
|
||||
|
||||
use super::super::ctx::SetDisksCtx;
|
||||
use super::super::{
|
||||
Arc, CancellationToken, DeleteOptions, DiskError, DiskStore, Error, ListObjectVersionsInfo, ListObjectsV2Info,
|
||||
OBJECT_OP_IGNORED_ERRS, ObjectInfoOrErr, Result, Sender, SetDisks, WalkOptions, debug, join_all, reduce_write_quorum_errs,
|
||||
Arc, CancellationToken, DeleteOptions, DiskError, DiskStore, OBJECT_OP_IGNORED_ERRS, ObjectInfoOrErr, Result, Sender,
|
||||
SetDisks, WalkOptions, debug, join_all, reduce_write_quorum_errs,
|
||||
};
|
||||
use crate::disk::DiskAPI;
|
||||
|
||||
@@ -139,56 +137,14 @@ impl<'a> ListOperations<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage_api_contracts::list::ListOperations for SetDisks {
|
||||
type Error = Error;
|
||||
type ListObjectsV2Info = ListObjectsV2Info;
|
||||
type ListObjectVersionsInfo = ListObjectVersionsInfo;
|
||||
type ObjectInfoOrErr = ObjectInfoOrErr;
|
||||
type WalkOptions = WalkOptions;
|
||||
type WalkCancellation = CancellationToken;
|
||||
type WalkResultSender = 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(
|
||||
impl SetDisks {
|
||||
/// Streams every object under `prefix` of this erasure set into `result`.
|
||||
///
|
||||
/// Paginated listings (`ListObjectsV2`, `ListObjectVersions`) exist only on
|
||||
/// `ECStore`, which merges all sets of all pools; a single set cannot answer
|
||||
/// them on its own. Set-scoped callers (prefix deletes, tier reference
|
||||
/// proofs, pool-local maintenance) walk the set instead.
|
||||
pub async fn walk(
|
||||
self: Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
bucket: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
mod native_abort_closure_tests {
|
||||
use super::*;
|
||||
use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortRound, MultipartSourceOperation,
|
||||
MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse, MultipartSourceSnapshot, ProofVerificationContext,
|
||||
SourceGeneration,
|
||||
};
|
||||
use crate::layout::endpoints::{EndpointServerPools, PoolEndpoints};
|
||||
|
||||
async fn operation(disk: &DiskStore, path: &str, operation: MultipartSourceOperation) -> MultipartSourceResponse {
|
||||
disk.recover_multipart_source(MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: path.to_owned(),
|
||||
operation,
|
||||
})
|
||||
.await
|
||||
.expect("real native Abort operation")
|
||||
}
|
||||
|
||||
async fn source_snapshots(disks: &[DiskStore], path: &str) -> Vec<MultipartSourceSnapshot> {
|
||||
let mut snapshots = Vec::with_capacity(disks.len());
|
||||
for disk in disks {
|
||||
let MultipartSourceResponse::Inspected(inspection) =
|
||||
operation(disk, path, MultipartSourceOperation::Inspect { lineage: None }).await
|
||||
else {
|
||||
panic!("native Inspect response");
|
||||
};
|
||||
snapshots.push(inspection.snapshot.expect("actual source snapshot"));
|
||||
}
|
||||
snapshots
|
||||
}
|
||||
|
||||
async fn prepare_certificate(disks: &[DiskStore], set: &SetDisks, path: &str) -> MultipartAbortCertificate {
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
let authority = set.multipart_proof_verification_context();
|
||||
bind_multipart_proof_snapshot(&physical, &authority).expect("bind actual complete physical cohort");
|
||||
let snapshots = source_snapshots(disks, path).await;
|
||||
let round = MultipartAbortRound {
|
||||
lineage: snapshots[0].lineage.clone(),
|
||||
generation: SourceGeneration {
|
||||
epoch: snapshots
|
||||
.iter()
|
||||
.map(|snapshot| snapshot.generation.epoch)
|
||||
.max()
|
||||
.expect("nonempty cohort")
|
||||
+ 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
};
|
||||
let mut proofs = Vec::with_capacity(disks.len());
|
||||
let mut disk_ids = Vec::with_capacity(disks.len());
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
let MultipartSourceResponse::AbortPrepared(proof) = operation(
|
||||
disk,
|
||||
path,
|
||||
MultipartSourceOperation::PrepareAbort {
|
||||
expected: snapshots[slot].clone(),
|
||||
round: round.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("native PrepareAbort response");
|
||||
};
|
||||
assert_eq!(proof.snapshot.physical_slot, slot);
|
||||
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::Aborting);
|
||||
assert!(proof.snapshot.head_sha256.is_none() && proof.snapshot.capability_sha256.is_none());
|
||||
disk_ids.push(disk.get_disk_id().await.expect("actual disk ID").expect("formatted disk"));
|
||||
proofs.push(proof);
|
||||
}
|
||||
let certificate = MultipartAbortCertificate { round, disk_ids, proofs };
|
||||
certificate
|
||||
.verify_with_authority(&authority)
|
||||
.expect("complete N physical closure certificate");
|
||||
certificate
|
||||
}
|
||||
|
||||
async fn cold_reopen(disks: Vec<DiskStore>, set: Arc<SetDisks>) -> (Vec<DiskStore>, Arc<SetDisks>) {
|
||||
let endpoints = disks.iter().map(|disk| disk.endpoint()).collect::<Vec<_>>();
|
||||
let format = set.format.clone();
|
||||
let old_context = Arc::downgrade(&set.ctx);
|
||||
let old_authority = match set.multipart_proof_verification_context() {
|
||||
ProofVerificationContext::Local(authority) => Arc::downgrade(&authority),
|
||||
ProofVerificationContext::RpcOnly => panic!("isolated native authority required"),
|
||||
};
|
||||
drop(set);
|
||||
drop(disks);
|
||||
assert!(old_context.upgrade().is_none(), "all old InstanceContext owners must die");
|
||||
assert!(
|
||||
old_authority.upgrade().is_none(),
|
||||
"no old local proof authority may hydrate cold evidence"
|
||||
);
|
||||
let mut reopened = Vec::with_capacity(endpoints.len());
|
||||
for endpoint in &endpoints {
|
||||
reopened.push(
|
||||
new_disk(
|
||||
endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("cold formatted disk reopen"),
|
||||
);
|
||||
}
|
||||
let context = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
context.update_erasure_type(SetupType::Erasure).await;
|
||||
context
|
||||
.bind_multipart_proof_topology(&EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: endpoints.clone().into(),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
}]))
|
||||
.expect("fresh complete physical topology");
|
||||
let cold = SetDisks::new_with_instance_ctx(
|
||||
"native-abort-cold-owner".to_owned(),
|
||||
Arc::new(RwLock::new(reopened.iter().cloned().map(Some).collect())),
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
endpoints,
|
||||
format,
|
||||
Vec::new(),
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
// Direct disk inspection needs the same verified instance binding that
|
||||
// production coordinator entry points establish before native RPCs.
|
||||
bind_multipart_proof_snapshot(
|
||||
&reopened.iter().cloned().map(Some).collect::<Vec<_>>(),
|
||||
&cold.multipart_proof_verification_context(),
|
||||
)
|
||||
.expect("bind the actual cold physical cohort to its new native proof authority");
|
||||
(reopened, cold)
|
||||
}
|
||||
|
||||
async fn rename_fanout_slot_for_physical_source(
|
||||
set: &SetDisks,
|
||||
disks: &[DiskStore],
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload: &str,
|
||||
physical_slot: usize,
|
||||
) -> usize {
|
||||
let (source, metadata) = set
|
||||
.check_upload_id_exists(bucket, object, upload, true)
|
||||
.await
|
||||
.expect("read the actual physical source layout before fault injection");
|
||||
let fanout_slot = metadata[physical_slot]
|
||||
.erasure
|
||||
.index
|
||||
.checked_sub(1)
|
||||
.filter(|slot| *slot < disks.len())
|
||||
.expect("actual source uses a valid one-based erasure role");
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
let (shuffled, _) = SetDisks::shuffle_disks_and_parts_metadata_by_index(&physical, &metadata, &source);
|
||||
assert_eq!(
|
||||
shuffled[fanout_slot]
|
||||
.as_ref()
|
||||
.expect("actual fanout participant")
|
||||
.get_disk_id()
|
||||
.await
|
||||
.expect("fanout disk ID"),
|
||||
disks[physical_slot].get_disk_id().await.expect("physical disk ID"),
|
||||
"rename fault indices follow erasure order, so select the actual physical producer explicitly"
|
||||
);
|
||||
fanout_slot
|
||||
}
|
||||
|
||||
async fn originals(disks: &[DiskStore], path: &str) -> Vec<Bytes> {
|
||||
let mut raw = Vec::with_capacity(disks.len());
|
||||
for disk in disks {
|
||||
raw.push(
|
||||
disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/{STORAGE_FORMAT_FILE}"))
|
||||
.await
|
||||
.expect("snapshot actual initial original bytes"),
|
||||
);
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
async fn body(set: &SetDisks, bucket: &str, object: &str, opts: &ObjectOptions) -> Vec<u8> {
|
||||
let mut reader = set
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), opts)
|
||||
.await
|
||||
.expect("read actual target body");
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.expect("read the whole actual target body");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_requires_all_originals_and_preserves_every_source_on_missing_witness() {
|
||||
for missing in [false, true] {
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-abort-all-originals";
|
||||
let object = if missing { "missing" } else { "healthy" };
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let (upload, _) = stage_upload_with_create_opts(&set, bucket, object, &[0x64; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
let before = originals(&disks, &path).await;
|
||||
for disk in &disks {
|
||||
let fi = disk
|
||||
.read_version("", RUSTFS_META_MULTIPART_BUCKET, &path, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("marked initial source");
|
||||
assert!(native_source_protocol(&fi).expect("known birth marker"));
|
||||
}
|
||||
if missing {
|
||||
disks[3]
|
||||
.delete(RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/{STORAGE_FORMAT_FILE}"), Default::default())
|
||||
.await
|
||||
.expect("model initial publication without one physical original witness");
|
||||
}
|
||||
let aborted = set
|
||||
.abort_multipart_upload(bucket, object, &upload, &ObjectOptions::default())
|
||||
.await;
|
||||
if missing {
|
||||
assert!(
|
||||
matches!(aborted, Err(StorageError::ErasureWriteQuorum | StorageError::InsufficientWriteQuorum(..))),
|
||||
"marked native source cannot take the legacy read-quorum shortcut: {aborted:?}"
|
||||
);
|
||||
for (slot, dir) in dirs.iter().enumerate() {
|
||||
assert!(
|
||||
dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists(),
|
||||
"no source root is deleted"
|
||||
);
|
||||
if slot != 3 {
|
||||
assert_eq!(
|
||||
disks[slot]
|
||||
.read_all(RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/{STORAGE_FORMAT_FILE}"))
|
||||
.await
|
||||
.expect("unchanged actual source"),
|
||||
before[slot]
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
aborted.expect("healthy full-N native upload can be ordinarily aborted");
|
||||
for dir in dirs {
|
||||
assert!(!dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_unknown_disk_keeps_original_bytes_and_never_starts_deletion() {
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-abort-unknown";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let (upload, _) = stage_upload_with_create_opts(&set, bucket, object, &[0x65; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
let before = originals(&disks, &path).await;
|
||||
let saved = set.disks.write().await[3].take();
|
||||
let failed = set
|
||||
.abort_multipart_upload(bucket, object, &upload, &ObjectOptions::default())
|
||||
.await;
|
||||
assert!(failed.is_err(), "unknown actual participant cannot certify all-N closure");
|
||||
set.disks.write().await[3] = saved;
|
||||
assert_eq!(originals(&disks, &path).await, before);
|
||||
for dir in &dirs {
|
||||
assert!(dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
}
|
||||
set.abort_multipart_upload(bucket, object, &upload, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the same upload can abort once all actual witnesses return");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_partial_prepare_cold_reopen_unifies_without_recreating_a_head() {
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-abort-partial-prepare";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let (upload, _) = stage_upload_with_create_opts(&set, bucket, object, &[0x66; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
let before = originals(&disks, &path).await;
|
||||
{
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
bind_multipart_proof_snapshot(&physical, &set.multipart_proof_verification_context()).expect("real topology");
|
||||
let snapshot = source_snapshots(&disks, &path).await.remove(0);
|
||||
let round = MultipartAbortRound {
|
||||
lineage: snapshot.lineage.clone(),
|
||||
generation: SourceGeneration {
|
||||
epoch: snapshot.generation.epoch + 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
};
|
||||
let response = operation(
|
||||
&disks[0],
|
||||
&path,
|
||||
MultipartSourceOperation::PrepareAbort {
|
||||
expected: snapshot,
|
||||
round,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response, MultipartSourceResponse::AbortPrepared(_)));
|
||||
}
|
||||
assert!(matches!(
|
||||
disks[0]
|
||||
.read_all(RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/{STORAGE_FORMAT_FILE}"))
|
||||
.await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
assert_eq!(retained_native_multipart_original(disk, slot, bucket, object, &path).await, before[slot]);
|
||||
}
|
||||
let (reopened, cold) = cold_reopen(disks, set).await;
|
||||
cold.abort_multipart_upload(bucket, object, &upload, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("abandoned preparation converges to a new closed round under a new authority");
|
||||
for dir in dirs {
|
||||
assert!(!dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
}
|
||||
drop(reopened);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_partial_certificate_commit_cold_reopen_preserves_full_n_authority() {
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-abort-partial-commit";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let (upload, _) = stage_upload_with_create_opts(&set, bucket, object, &[0x67; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
let before = originals(&disks, &path).await;
|
||||
{
|
||||
let certificate = prepare_certificate(&disks, &set, &path).await;
|
||||
let response = operation(
|
||||
&disks[0],
|
||||
&path,
|
||||
MultipartSourceOperation::CommitAbort {
|
||||
expected: certificate.proofs[0].snapshot.clone(),
|
||||
certificate,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response, MultipartSourceResponse::AbortCommitted(_)));
|
||||
}
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
assert_eq!(retained_native_multipart_original(disk, slot, bucket, object, &path).await, before[slot]);
|
||||
}
|
||||
let (_reopened, cold) = cold_reopen(disks, set).await;
|
||||
cold.abort_multipart_upload(bucket, object, &upload, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("one persisted complete N certificate resumes the all-copy barrier before cleanup");
|
||||
for dir in dirs {
|
||||
assert!(!dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_abort_partial_delete_surviving_producer_confirms_current_and_historical_exact_target() {
|
||||
use crate::set_disk::core::io_primitives::{rename_fault_injection, rollback_fault_injection};
|
||||
for historical in [false, true] {
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-abort-surviving-producer";
|
||||
let object = if historical { "historical" } else { "current" };
|
||||
let opts = ObjectOptions {
|
||||
versioned: historical,
|
||||
..Default::default()
|
||||
};
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let expected_body = vec![0x68; 4096];
|
||||
let (upload, parts) = stage_upload_with_create_opts(&set, bucket, object, &expected_body, &opts).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
let before = originals(&disks, &path).await;
|
||||
let failed_fanout = rename_fanout_slot_for_physical_source(&set, &disks, bucket, object, &upload, 3).await;
|
||||
{
|
||||
let rename_fault = rename_fault_injection::fail_rename_on(object, &[failed_fanout]);
|
||||
let coordinator_fault =
|
||||
rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::CoordinatorPanic);
|
||||
set.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload, parts.clone(), &opts)
|
||||
.await
|
||||
.expect_err("crash the coordinator after the actual three producer publications");
|
||||
drop(coordinator_fault);
|
||||
drop(rename_fault);
|
||||
}
|
||||
drop(
|
||||
set.acquire_write_lock_diag("native_abort_producer_tail", bucket, object)
|
||||
.await
|
||||
.expect("actual object owners drain"),
|
||||
);
|
||||
let completed = set
|
||||
.get_object_info(bucket, object, &opts)
|
||||
.await
|
||||
.expect("the real three-producer target exists");
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
let actual = disk.read_version("", bucket, object, "", &ReadOptions::default()).await;
|
||||
if slot < 3 {
|
||||
let actual = actual.expect("each of physical slots 0/1/2 really produced C");
|
||||
let publication = publication_identity(&actual)
|
||||
.expect("strict native target tuple")
|
||||
.expect("actual C producer");
|
||||
assert_eq!(publication.lineage.path, path);
|
||||
assert_eq!(actual.erasure.index, actual.erasure.distribution[slot]);
|
||||
assert_eq!(actual.data_dir, completed.data_dir);
|
||||
assert_eq!(
|
||||
actual.version_id.filter(|id| !id.is_nil()),
|
||||
completed.version_id.filter(|id| !id.is_nil())
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(actual, Err(DiskError::FileNotFound | DiskError::FileVersionNotFound)),
|
||||
"physical slot3 must have no C and cannot later supply a RootMissing C vote: {actual:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let target_opts = ObjectOptions {
|
||||
versioned: historical,
|
||||
version_id: completed
|
||||
.version_id
|
||||
.filter(|version| !version.is_nil())
|
||||
.map(|version| version.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(body(&set, bucket, object, &target_opts).await, expected_body);
|
||||
if historical {
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![0x69; 37]),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("real successor PUT makes C a historical exact target");
|
||||
}
|
||||
{
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
let SourceCleanupAuthority::Native { certificate, snapshots } =
|
||||
prepare_source_cleanup(&physical, bucket, object, &path, true, &set.multipart_proof_verification_context())
|
||||
.await
|
||||
.expect("real all-N closure and all-copy commit")
|
||||
else {
|
||||
panic!("marked native closure");
|
||||
};
|
||||
for slot in [0, 1, 3] {
|
||||
let response = operation(
|
||||
&disks[slot],
|
||||
&path,
|
||||
MultipartSourceOperation::DeleteAborted {
|
||||
expected: snapshots[slot].clone().expect("actual committed source"),
|
||||
certificate: certificate.as_ref().clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response, MultipartSourceResponse::AbortDeleted(_)));
|
||||
}
|
||||
}
|
||||
assert!(!dirs[0].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
assert!(!dirs[1].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
assert!(dirs[2].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
assert!(!dirs[3].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
let (reopened, cold) = cold_reopen(disks, set).await;
|
||||
assert_eq!(
|
||||
retained_native_multipart_original(&reopened[2], 2, bucket, object, &path).await,
|
||||
before[2]
|
||||
);
|
||||
let replayed = cold
|
||||
.replay_completed_multipart_upload(bucket, object, &upload, &parts, None, &opts)
|
||||
.await
|
||||
.expect("fresh Q must include the surviving Aborting true producer")
|
||||
.expect("exact current/historical completed C");
|
||||
assert!(replayed.multipart_completion_replayed);
|
||||
assert_eq!(replayed.etag, completed.etag);
|
||||
assert_eq!(replayed.version_id, completed.version_id);
|
||||
assert_eq!(body(&cold, bucket, object, &target_opts).await, expected_body);
|
||||
cold.abort_multipart_upload(bucket, object, &upload, &opts)
|
||||
.await
|
||||
.expect("certified original on one remaining root can finish cold cleanup");
|
||||
for dir in &dirs {
|
||||
assert!(!dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path).exists());
|
||||
}
|
||||
assert_eq!(body(&cold, bucket, object, &target_opts).await, expected_body);
|
||||
if historical {
|
||||
assert_eq!(body(&cold, bucket, object, &opts).await, vec![0x69; 37]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_birth_upgrade_is_guarded_and_readonly_cleanup_does_not_mark_legacy() {
|
||||
let (_dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-birth-upgrade";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let (upload, parts) = stage_upload_with_create_opts(&set, bucket, object, &[0x6a; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
pin_historical_unmarked_upload(&disks, bucket, &path).await;
|
||||
let before = originals(&disks, &path).await;
|
||||
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
|
||||
.await
|
||||
.expect("readonly legacy cleanup identity lookup");
|
||||
assert_eq!(
|
||||
originals(&disks, &path).await,
|
||||
before,
|
||||
"readonly discovery cannot upgrade or create a private state"
|
||||
);
|
||||
{
|
||||
let _guard = set
|
||||
.acquire_multipart_upload_write_lock("native_birth_upgrade", bucket, object, &upload, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("canonical source upgrade guard");
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
upgrade_legacy_source(&physical, bucket, object, &path, &set.multipart_proof_verification_context())
|
||||
.await
|
||||
.expect("exact-identity native upgrade through every physical source owner");
|
||||
for disk in &disks {
|
||||
let fi = disk
|
||||
.read_version("", RUSTFS_META_MULTIPART_BUCKET, &path, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("marked source");
|
||||
assert!(native_source_protocol(&fi).expect("strict dual-family marker"));
|
||||
}
|
||||
}
|
||||
set.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload, parts, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("upgraded legacy source publishes through the real native path");
|
||||
for disk in &disks {
|
||||
let fi = disk
|
||||
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual final metadata");
|
||||
assert!(
|
||||
!rustfs_utils::http::contains_key_str(&fi.metadata, crate::disk::multipart_source::NATIVE_SOURCE_PROTOCOL_SUFFIX),
|
||||
"the source-only protocol marker cannot leak into target equivalence metadata"
|
||||
);
|
||||
}
|
||||
assert_eq!(body(&set, bucket, object, &ObjectOptions::default()).await, vec![0x6a; 4096]);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn native_birth_partial_upgrade_cold_reopen_completes_without_a_quorum_dead_end() {
|
||||
let (_dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-birth-partial-upgrade";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let (upload, parts) = stage_upload_with_create_opts(&set, bucket, object, &[0x6b; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
pin_historical_unmarked_upload(&disks, bucket, &path).await;
|
||||
{
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
bind_multipart_proof_snapshot(&physical, &set.multipart_proof_verification_context()).expect("actual topology");
|
||||
let snapshots = source_snapshots(&disks, &path).await;
|
||||
for slot in [0, 1] {
|
||||
let response = operation(
|
||||
&disks[slot],
|
||||
&path,
|
||||
MultipartSourceOperation::UpgradeLegacy {
|
||||
expected: snapshots[slot].clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response, MultipartSourceResponse::LegacyUpgraded(_)));
|
||||
}
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
let fi = disk
|
||||
.read_version("", RUSTFS_META_MULTIPART_BUCKET, &path, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual partially upgraded source");
|
||||
assert_eq!(native_source_protocol(&fi).expect("known protocol aliases"), slot < 2);
|
||||
}
|
||||
}
|
||||
let (reopened, cold) = cold_reopen(disks, set).await;
|
||||
cold.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload, parts, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("cold retry resumes a partial upgrade before dispatching native publication");
|
||||
assert_eq!(body(&cold, bucket, object, &ObjectOptions::default()).await, vec![0x6b; 4096]);
|
||||
for disk in &reopened {
|
||||
let final_info = disk
|
||||
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual completed target");
|
||||
assert!(!rustfs_utils::http::contains_key_str(
|
||||
&final_info.metadata,
|
||||
crate::disk::multipart_source::NATIVE_SOURCE_PROTOCOL_SUFFIX
|
||||
));
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn native_orphan_controls_veto_genuine_legacy_read_quorum_without_deleting_any_source_or_target() {
|
||||
use crate::set_disk::core::io_primitives::{rename_fault_injection, rollback_fault_injection};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
fn exact_tree(root: &Path) -> BTreeMap<PathBuf, Option<Vec<u8>>> {
|
||||
let mut result = BTreeMap::new();
|
||||
if !root.exists() {
|
||||
return result;
|
||||
}
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(path) = pending.pop() {
|
||||
let metadata = std::fs::symlink_metadata(&path).expect("inspect actual fixture tree");
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.expect("fixture entry belongs to its exact root")
|
||||
.to_path_buf();
|
||||
if metadata.is_dir() {
|
||||
result.insert(relative, None);
|
||||
pending.extend(
|
||||
std::fs::read_dir(&path)
|
||||
.expect("list actual fixture tree")
|
||||
.map(|entry| entry.expect("actual fixture entry").path()),
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
metadata.is_file(),
|
||||
"source and target fixtures contain only ordinary files and directories"
|
||||
);
|
||||
result.insert(relative, Some(std::fs::read(&path).expect("snapshot every actual file byte")));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
let (dirs, disks, set) = hermetic_set_disks(4).await;
|
||||
let bucket = "native-orphan-legacy-veto";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let predecessor = vec![0x71; 4096];
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(predecessor.clone()),
|
||||
&ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed a real predecessor whose whole body remains readable");
|
||||
let (upload, parts) = stage_upload_with_create_opts(&set, bucket, object, &[0x72; 4096], &ObjectOptions::default()).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
let initial = originals(&disks, &path).await;
|
||||
let producer_fanout = rename_fanout_slot_for_physical_source(&set, &disks, bucket, object, &upload, 0).await;
|
||||
let failed_fanout = (0..disks.len()).filter(|slot| *slot != producer_fanout).collect::<Vec<_>>();
|
||||
{
|
||||
let rename_fault = rename_fault_injection::fail_rename_on(object, &failed_fanout);
|
||||
let coordinator_fault = rollback_fault_injection::arm(object, 0, rollback_fault_injection::Fault::CoordinatorPanic);
|
||||
set.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload, parts, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("stop the coordinator after exactly one actual native publication");
|
||||
drop(coordinator_fault);
|
||||
drop(rename_fault);
|
||||
}
|
||||
drop(
|
||||
set.acquire_write_lock_diag("orphan_legacy_veto_tail", bucket, object)
|
||||
.await
|
||||
.expect("actual publication owners drain"),
|
||||
);
|
||||
let produced = disks[0]
|
||||
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("read the actual single-producer target metadata");
|
||||
let publication = publication_identity(&produced)
|
||||
.expect("strict native publication tuple")
|
||||
.expect("real producer C");
|
||||
assert_eq!(publication.lineage.path, path);
|
||||
assert_eq!(produced.erasure.index, produced.erasure.distribution[0]);
|
||||
let challenge = Uuid::new_v4();
|
||||
let MultipartSourceResponse::TerminalConfirmed(proof) = operation(
|
||||
&disks[0],
|
||||
&path,
|
||||
MultipartSourceOperation::ConfirmTerminal {
|
||||
expected: None,
|
||||
publication: publication.clone(),
|
||||
challenge,
|
||||
},
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("real physical producer must return a native exact-target proof");
|
||||
};
|
||||
proof
|
||||
.verify_with_authority(&set.multipart_proof_verification_context())
|
||||
.expect("real native proof authority");
|
||||
assert_eq!(proof.kind, crate::disk::multipart_source::MultipartProofKind::TerminalTarget);
|
||||
assert_eq!(proof.challenge, challenge);
|
||||
assert_eq!(proof.snapshot.physical_slot, 0);
|
||||
assert_eq!(
|
||||
Some(proof.snapshot.disk_id),
|
||||
disks[0].get_disk_id().await.expect("actual producer disk ID")
|
||||
);
|
||||
assert_eq!(proof.snapshot.publication.as_ref(), Some(&publication));
|
||||
drop(proof);
|
||||
assert_eq!(retained_native_multipart_original(&disks[0], 0, bucket, object, &path).await, initial[0]);
|
||||
let source0 = dirs[0].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path);
|
||||
assert!(
|
||||
!source0.join(STORAGE_FORMAT_FILE).exists(),
|
||||
"the publisher really consumed its source head"
|
||||
);
|
||||
assert!(
|
||||
source0.join(".mpu-completions").is_dir(),
|
||||
"real independently owned native originals remain"
|
||||
);
|
||||
|
||||
// Slots 1/2 use their own genuine historical originals. Slot 3 has no
|
||||
// authority, so a legacy decision depends on exactly two physical votes.
|
||||
pin_historical_unmarked_upload(&disks[1..], bucket, &path).await;
|
||||
let mut legacy_originals = Vec::new();
|
||||
for slot in [1, 2] {
|
||||
legacy_originals.push(
|
||||
disks[slot]
|
||||
.read_all(RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/{STORAGE_FORMAT_FILE}"))
|
||||
.await
|
||||
.expect("pin the original belonging to this exact legacy physical slot"),
|
||||
);
|
||||
let source = dirs[slot].path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path);
|
||||
std::fs::create_dir(source.join(MULTIPART_RECOVERY_DIR)).expect("create the historical recovery directory");
|
||||
std::fs::rename(
|
||||
source.join(STORAGE_FORMAT_FILE),
|
||||
source.join(MULTIPART_RECOVERY_DIR).join(STORAGE_FORMAT_FILE),
|
||||
)
|
||||
.expect("retain this slot's genuine authority without duplicating votes");
|
||||
}
|
||||
disks[3]
|
||||
.delete(RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/{STORAGE_FORMAT_FILE}"), Default::default())
|
||||
.await
|
||||
.expect("remove the fourth historical head without adding an authority vote");
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
let recovery = format!("{path}/{MULTIPART_RECOVERY_DIR}");
|
||||
let (metadata, errors) =
|
||||
SetDisks::read_all_fileinfo(&physical, bucket, RUSTFS_META_MULTIPART_BUCKET, &recovery, "", false, false, false)
|
||||
.await
|
||||
.expect("observe the actual legacy recovery fanout");
|
||||
assert_eq!(errors.iter().filter(|error| error.is_none()).count(), 2);
|
||||
assert_eq!(
|
||||
SetDisks::object_quorum_from_meta(&metadata, &errors, 2),
|
||||
Ok((2, 3)),
|
||||
"the genuine legacy files alone reach readQ"
|
||||
);
|
||||
for (index, slot) in [1, 2].into_iter().enumerate() {
|
||||
assert_eq!(metadata[slot].erasure.index, metadata[slot].erasure.distribution[slot]);
|
||||
assert!(!recognized_native_source_controls(&metadata[slot]).expect("historical recovery files remain unmarked"));
|
||||
assert_eq!(
|
||||
disks[slot]
|
||||
.read_all(RUSTFS_META_MULTIPART_BUCKET, &format!("{recovery}/{STORAGE_FORMAT_FILE}"))
|
||||
.await
|
||||
.expect("actual same-slot recovery bytes"),
|
||||
legacy_originals[index]
|
||||
);
|
||||
}
|
||||
std::fs::remove_file(source0.join(".mpu-source-state")).expect("inject loss of only the native state record");
|
||||
let source_roots = dirs
|
||||
.iter()
|
||||
.map(|dir| dir.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path))
|
||||
.collect::<Vec<_>>();
|
||||
let target_roots = dirs
|
||||
.iter()
|
||||
.map(|dir| dir.path().join(bucket).join(object))
|
||||
.collect::<Vec<_>>();
|
||||
let sources_before = source_roots.iter().map(|root| exact_tree(root)).collect::<Vec<_>>();
|
||||
let targets_before = target_roots.iter().map(|root| exact_tree(root)).collect::<Vec<_>>();
|
||||
let inspected = disks[0]
|
||||
.recover_multipart_source(MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: path.clone(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
matches!(inspected, Err(DiskError::ConditionalDeleteConflict)),
|
||||
"known orphan native evidence remains unknown: {inspected:?}"
|
||||
);
|
||||
let readonly = set
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
readonly,
|
||||
Err(StorageError::ErasureReadQuorum
|
||||
| StorageError::ErasureWriteQuorum
|
||||
| StorageError::InsufficientReadQuorum(..)
|
||||
| StorageError::InsufficientWriteQuorum(..))
|
||||
),
|
||||
"one orphan native slot vetoes legacy fallback despite a genuine readQ: {readonly:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
source_roots.iter().map(|root| exact_tree(root)).collect::<Vec<_>>(),
|
||||
sources_before,
|
||||
"readonly classification changes zero source bytes"
|
||||
);
|
||||
assert_eq!(target_roots.iter().map(|root| exact_tree(root)).collect::<Vec<_>>(), targets_before);
|
||||
let aborted = set
|
||||
.abort_multipart_upload(bucket, object, &upload, &ObjectOptions::default())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
aborted,
|
||||
Err(StorageError::ErasureReadQuorum
|
||||
| StorageError::ErasureWriteQuorum
|
||||
| StorageError::InsufficientReadQuorum(..)
|
||||
| StorageError::InsufficientWriteQuorum(..))
|
||||
),
|
||||
"uncertain native state cannot authorize legacy source deletion: {aborted:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
source_roots.iter().map(|root| exact_tree(root)).collect::<Vec<_>>(),
|
||||
sources_before,
|
||||
"ordinary Abort deletes no source and preserves every original/part/control byte"
|
||||
);
|
||||
assert_eq!(
|
||||
target_roots.iter().map(|root| exact_tree(root)).collect::<Vec<_>>(),
|
||||
targets_before,
|
||||
"Abort never mutates any target metadata, shard or native anchor"
|
||||
);
|
||||
assert_eq!(body(&set, bucket, object, &ObjectOptions::default()).await, predecessor);
|
||||
}
|
||||
mod initial_probe_tests {
|
||||
include!("multipart_initial_probe_tests.rs");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::*;
|
||||
use crate::set_disk::core::io_primitives::disk_call_counters;
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgraded_legacy_publication_cold_retry_keeps_the_exact_completed_target() {
|
||||
for versioned in [false, true] {
|
||||
let (dirs, disks, set) = Box::pin(hermetic_set_disks(4)).await;
|
||||
let bucket = "initial-source-cold-legacy";
|
||||
let object = if versioned {
|
||||
"legacy-cold-origin-versioned"
|
||||
} else {
|
||||
"legacy-cold-origin-unversioned"
|
||||
};
|
||||
let opts = ObjectOptions {
|
||||
versioned,
|
||||
..Default::default()
|
||||
};
|
||||
make_bucket_on_all(&disks, bucket).await;
|
||||
let expected_body = vec![0x73; 4096];
|
||||
let (upload, parts) = Box::pin(stage_upload_with_create_opts(&set, bucket, object, &expected_body, &opts)).await;
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
pin_historical_unmarked_upload(&disks, bucket, &path).await;
|
||||
let source_before = originals(&disks, &path).await;
|
||||
let completed_at = OffsetDateTime::now_utc();
|
||||
let etag = get_complete_multipart_md5(&parts);
|
||||
let staging = format!("legacy-complete-{}", Uuid::new_v4());
|
||||
let mut completed_version = None;
|
||||
let mut target_before = Vec::with_capacity(disks.len());
|
||||
// Use the real legacy publication primitive on actual selected part
|
||||
// inodes. No native Complete, source receipt, or target PUB is created
|
||||
// and no consumed native source head is restored by this fixture.
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
let mut final_info = disk
|
||||
.read_version("", RUSTFS_META_MULTIPART_BUCKET, &path, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("read the actual unmarked physical source");
|
||||
assert!(!recognized_native_source_controls(&final_info).expect("genuine legacy schema"));
|
||||
let data_dir = final_info.data_dir.expect("actual upload data directory");
|
||||
let part_path = format!("{path}/{data_dir}/part.1.meta");
|
||||
let mut selected = disk
|
||||
.read_parts(RUSTFS_META_MULTIPART_BUCKET, &[part_path])
|
||||
.await
|
||||
.expect("decode actual selected physical part record");
|
||||
assert_eq!(selected.len(), 1);
|
||||
let part = selected.pop().expect("one actual selected part");
|
||||
assert_eq!(Some(&part.etag), parts[0].etag.as_ref());
|
||||
let source_dir = dirs[slot]
|
||||
.path()
|
||||
.join(RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&path)
|
||||
.join(data_dir.to_string());
|
||||
let staged_dir = dirs[slot]
|
||||
.path()
|
||||
.join(RUSTFS_META_TMP_BUCKET)
|
||||
.join(&staging)
|
||||
.join(data_dir.to_string());
|
||||
std::fs::create_dir_all(&staged_dir).expect("create a real legacy private staging tree");
|
||||
let mut files = vec!["part.1".to_owned(), "part.1.meta".to_owned()];
|
||||
if let Some(integrity) = &part.integrity {
|
||||
files.push(integrity.file_name());
|
||||
}
|
||||
for file in files {
|
||||
std::fs::hard_link(source_dir.join(&file), staged_dir.join(&file))
|
||||
.expect("stage the actual selected shard, record, and proof without replacing source metadata");
|
||||
}
|
||||
final_info.size = i64::try_from(part.size).expect("fixture part size fits FI");
|
||||
final_info.parts = vec![part];
|
||||
final_info.mod_time = Some(completed_at);
|
||||
final_info.versioned = versioned;
|
||||
final_info.data = None;
|
||||
strip_internal_multipart_metadata(&mut final_info.metadata);
|
||||
rustfs_utils::http::remove_str(&mut final_info.metadata, rustfs_filemeta::shard_integrity::SUFFIX_UPLOAD_INTEGRITY);
|
||||
rustfs_utils::http::remove_str(&mut final_info.metadata, SUFFIX_INLINE_DATA);
|
||||
insert_str(&mut final_info.metadata, SUFFIX_ACTUAL_SIZE, expected_body.len().to_string());
|
||||
insert_str(&mut final_info.metadata, rustfs_utils::http::SUFFIX_MULTIPART_UPLOAD_ID, upload.clone());
|
||||
final_info.metadata.insert("etag".to_owned(), etag.clone());
|
||||
final_info
|
||||
.persist_shard_integrity()
|
||||
.expect("persist the actual selected part integrity layout");
|
||||
if slot == 0 {
|
||||
completed_version = final_info.version_id;
|
||||
} else {
|
||||
assert_eq!(final_info.version_id, completed_version);
|
||||
}
|
||||
disk.write_metadata(bucket, RUSTFS_META_TMP_BUCKET, &staging, final_info.clone())
|
||||
.await
|
||||
.expect("write actual legacy staged FileMeta");
|
||||
let result = disk
|
||||
.rename_data(RUSTFS_META_TMP_BUCKET, &staging, final_info, bucket, object)
|
||||
.await
|
||||
.expect("publish the real legacy target through the ordinary metadata/data commit");
|
||||
assert!(
|
||||
result.multipart_source.is_none(),
|
||||
"legacy TMP publication must not fabricate a native receipt"
|
||||
);
|
||||
let persisted = disk
|
||||
.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("real legacy target");
|
||||
assert!(
|
||||
publication_identity(&persisted)
|
||||
.expect("strict legacy target decode")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(persisted.erasure.index, persisted.erasure.distribution[slot]);
|
||||
target_before.push(
|
||||
disk.read_all(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}"))
|
||||
.await
|
||||
.expect("published raw target"),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
originals(&disks, &path).await,
|
||||
source_before,
|
||||
"legacy publisher retained canonical sources; none were restored"
|
||||
);
|
||||
assert_eq!(body(&set, bucket, object, &opts).await, expected_body);
|
||||
{
|
||||
let _guard = set
|
||||
.acquire_multipart_upload_write_lock("legacy_cold_upgrade", bucket, object, &upload, &opts)
|
||||
.await
|
||||
.expect("upgrade source guard");
|
||||
let physical = disks.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
upgrade_legacy_source(&physical, bucket, object, &path, &set.multipart_proof_verification_context())
|
||||
.await
|
||||
.expect("actual all-participant guarded legacy upgrade");
|
||||
}
|
||||
let mut upgraded = Vec::with_capacity(disks.len());
|
||||
for disk in &disks {
|
||||
let info = disk
|
||||
.read_version("", RUSTFS_META_MULTIPART_BUCKET, &path, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual upgraded head");
|
||||
assert_eq!(
|
||||
get_consistent_str(&info.metadata, crate::disk::multipart_source::NATIVE_SOURCE_PROTOCOL_SUFFIX),
|
||||
Some("native-v1")
|
||||
);
|
||||
assert!(!is_initial_native_source(&info).expect("upgraded origin is always conservative"));
|
||||
upgraded.push(info);
|
||||
}
|
||||
assert!(!initial_multipart_source_observations(&upgraded[0], &upgraded));
|
||||
let (reopened, cold) = Box::pin(cold_reopen(disks, set)).await;
|
||||
let calls = disk_call_counters::observe(object);
|
||||
let replay = cold
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload, parts, &opts)
|
||||
.await
|
||||
.expect("cold retry must discover the pre-upgrade completed legacy quorum");
|
||||
assert!(
|
||||
replay.multipart_completion_replayed,
|
||||
"UpgradeLegacy cannot turn a preserved source into a new publication"
|
||||
);
|
||||
assert_eq!(replay.version_id.filter(|id| !id.is_nil()), completed_version.filter(|id| !id.is_nil()));
|
||||
assert_eq!(replay.etag, Some(etag));
|
||||
let probes = calls.total(disk_call_counters::KIND_READ_VERSION);
|
||||
assert!(
|
||||
(4..=8).contains(&probes),
|
||||
"ambiguous upgraded origin must keep the bounded destination write-quorum replay fanout: {probes}"
|
||||
);
|
||||
drop(calls);
|
||||
assert_eq!(body(&cold, bucket, object, &opts).await, expected_body);
|
||||
for (slot, disk) in reopened.iter().enumerate() {
|
||||
assert_eq!(
|
||||
disk.read_all(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}"))
|
||||
.await
|
||||
.expect("unchanged completed target"),
|
||||
target_before[slot],
|
||||
"cold retry must not add a version or rewrite the existing target"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4822,6 +4822,63 @@ impl SetDisks {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if bucket == RUSTFS_META_BUCKET
|
||||
&& object == "pool.bin"
|
||||
&& rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&opts.user_defined,
|
||||
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
|
||||
)
|
||||
{
|
||||
// The logical If-Match above remains authoritative. Bind each
|
||||
// disk mutation to its actual physical predecessor as well, so
|
||||
// a lagging replica can converge without admitting late tails.
|
||||
let condition = rustfs_utils::http::metadata_compat::get_consistent_str(
|
||||
&opts.user_defined,
|
||||
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
|
||||
)
|
||||
.ok_or(DiskError::FileCorrupt)?;
|
||||
let condition: crate::disk::PoolMetaNativeCasCondition =
|
||||
serde_json::from_str(condition).map_err(|_| DiskError::FileCorrupt)?;
|
||||
let (parents, errors) =
|
||||
Self::read_all_fileinfo(&shuffle_disks, bucket, bucket, object, "", false, false, false).await?;
|
||||
for (index, ((metadata, parent), error)) in parts_metadatas.iter_mut().zip(&parents).zip(&errors).enumerate() {
|
||||
let mut parent_raw_sha256 = None;
|
||||
let parent = match error {
|
||||
None => Some(crate::disk::PoolMetaNativeFileIdentity::from_file_info(parent)?),
|
||||
Some(DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::VolumeNotFound) => None,
|
||||
// An unavailable disk keeps the exact logical parent;
|
||||
// reconnecting cannot turn an unknown head into absence.
|
||||
Some(
|
||||
DiskError::DiskNotFound | DiskError::FaultyDisk | DiskError::DiskAccessDenied | DiskError::Timeout,
|
||||
) => condition.parent.clone(),
|
||||
Some(DiskError::FileCorrupt) => {
|
||||
use sha2::Digest as _;
|
||||
let disk = shuffle_disks[index].as_ref().ok_or(DiskError::DiskNotFound)?;
|
||||
match disk
|
||||
.pool_meta_native_recovery(crate::disk::PoolMetaNativeRecoveryRequest::Inspect {
|
||||
object: crate::disk::PoolMetaNativeObject::PoolBin,
|
||||
})
|
||||
.await?
|
||||
{
|
||||
crate::disk::PoolMetaNativeRecoveryResponse::Inspected(snapshot) => {
|
||||
let raw = snapshot.current.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
parent_raw_sha256 = Some(sha2::Sha256::digest(&raw).into());
|
||||
None
|
||||
}
|
||||
_ => return Err(DiskError::FileCorrupt.into()),
|
||||
}
|
||||
}
|
||||
Some(err) => return Err(err.clone().into()),
|
||||
};
|
||||
crate::disk::PoolMetaNativeCasCondition {
|
||||
attempt_id: condition.attempt_id,
|
||||
parent,
|
||||
parent_raw_sha256,
|
||||
}
|
||||
.insert_into(&mut metadata.metadata)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ordinary PUT timestamps under the commit lock so version
|
||||
// ordering follows durable commit ordering when writers queued on
|
||||
// the same object. Internal callers with an explicit timestamp keep
|
||||
@@ -8118,13 +8175,23 @@ fn notify_put_object_commit_namespace_acquired(bucket: &str, object: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DeleteObjectCommitPause {
|
||||
BeforeNamespace,
|
||||
BeforeCommit,
|
||||
AfterPublish,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DeleteObjectCommitBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
pause_after_publish: bool,
|
||||
pause: DeleteObjectCommitPause,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
namespace_pending: tokio::sync::Notify,
|
||||
namespace_acquired: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -8139,21 +8206,27 @@ static DELETE_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option
|
||||
#[cfg(test)]
|
||||
impl DeleteObjectCommitBarrier {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
Self::install_with_mode(bucket, object, false)
|
||||
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::BeforeCommit)
|
||||
}
|
||||
|
||||
pub(crate) fn install_before_namespace(bucket: &str, object: &str) -> Self {
|
||||
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::BeforeNamespace)
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub(crate) fn install_after_publish(bucket: &str, object: &str) -> Self {
|
||||
Self::install_with_mode(bucket, object, true)
|
||||
Self::install_with_mode(bucket, object, DeleteObjectCommitPause::AfterPublish)
|
||||
}
|
||||
|
||||
fn install_with_mode(bucket: &str, object: &str, pause_after_publish: bool) -> Self {
|
||||
fn install_with_mode(bucket: &str, object: &str, pause: DeleteObjectCommitPause) -> Self {
|
||||
let state = Arc::new(DeleteObjectCommitBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
pause_after_publish,
|
||||
pause,
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
namespace_pending: tokio::sync::Notify::new(),
|
||||
namespace_acquired: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
let mut slot = DELETE_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
@@ -8173,6 +8246,19 @@ impl DeleteObjectCommitBarrier {
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) async fn release_and_wait_until_namespace_pending(&self) {
|
||||
assert_eq!(self.state.pause, DeleteObjectCommitPause::BeforeNamespace);
|
||||
let pending = self.state.namespace_pending.notified();
|
||||
self.release();
|
||||
tokio::time::timeout(Duration::from_secs(5), pending)
|
||||
.await
|
||||
.expect("delete object must poll its actual namespace acquisition to Pending");
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_acquired(&self) -> bool {
|
||||
self.state.namespace_acquired.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -8189,6 +8275,43 @@ impl Drop for DeleteObjectCommitBarrier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_delete_object_before_namespace(bucket: &str, object: &str) {
|
||||
let barrier = DELETE_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete object commit barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| {
|
||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeNamespace
|
||||
})
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn notify_delete_object_namespace(bucket: &str, object: &str, acquired: bool) {
|
||||
let barrier = DELETE_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete object commit barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| {
|
||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeNamespace
|
||||
})
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
if acquired {
|
||||
barrier.namespace_acquired.store(true, std::sync::atomic::Ordering::Release);
|
||||
} else {
|
||||
barrier.namespace_pending.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_delete_object_commit(bucket: &str, object: &str) {
|
||||
let barrier = DELETE_OBJECT_COMMIT_BARRIER
|
||||
@@ -8196,7 +8319,9 @@ async fn pause_delete_object_commit(bucket: &str, object: &str) {
|
||||
.lock()
|
||||
.expect("delete object commit barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && !barrier.pause_after_publish)
|
||||
.filter(|barrier| {
|
||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::BeforeCommit
|
||||
})
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
@@ -8211,7 +8336,9 @@ async fn pause_delete_object_commit_after_publish(bucket: &str, object: &str) {
|
||||
.lock()
|
||||
.expect("delete object commit barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause_after_publish)
|
||||
.filter(|barrier| {
|
||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == DeleteObjectCommitPause::AfterPublish
|
||||
})
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
@@ -10470,7 +10597,20 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
|
||||
// Guard lock for single object delete
|
||||
let _lock_guard = if (!opts.delete_prefix || opts.delete_prefix_object) && !opts.no_lock {
|
||||
Some(Arc::new(self.acquire_write_lock_diag("delete_object", bucket, object).await?))
|
||||
#[cfg(test)]
|
||||
let guard = {
|
||||
pause_delete_object_before_namespace(bucket, object).await;
|
||||
let guard = self
|
||||
.acquire_write_lock_diag_with_pending_hook("delete_object", bucket, object, || {
|
||||
notify_delete_object_namespace(bucket, object, false);
|
||||
})
|
||||
.await?;
|
||||
notify_delete_object_namespace(bucket, object, true);
|
||||
guard
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let guard = self.acquire_write_lock_diag("delete_object", bucket, object).await?;
|
||||
Some(Arc::new(guard))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -12791,6 +12931,21 @@ pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
|
||||
// This fixture owns a complete physical topology even when it shares
|
||||
// the bootstrap service context with other independent hermetic sets.
|
||||
let topology = crate::layout::endpoints::EndpointServerPools(vec![crate::layout::endpoints::PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: disk_count,
|
||||
endpoints: endpoints.clone().into(),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
}]);
|
||||
let (_, multipart_authority) = crate::disk::multipart_source::ProofVerificationContext::for_topology(&topology)
|
||||
.expect("hermetic fixture should have a complete local topology");
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &multipart_authority)
|
||||
.expect("hermetic local disks should bind to their fixture authority");
|
||||
|
||||
let set_disks = SetDisks::new_with_instance_ctx(
|
||||
"hermetic-ops-test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
@@ -12805,6 +12960,7 @@ pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
||||
)
|
||||
.await;
|
||||
|
||||
set_disks.set_multipart_proof_authority_for_test(multipart_authority);
|
||||
(temp_dirs, disk_stores, set_disks)
|
||||
}
|
||||
}
|
||||
@@ -21359,6 +21515,206 @@ mod put_object_tmp_cleanup_tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deep_heal_preserves_committed_generation_during_old_data_cleanup() {
|
||||
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
|
||||
|
||||
for (disk_count, parity, read_repair) in [(4, 2, false), (4, 2, true), (16, 4, false), (16, 4, true)] {
|
||||
let (dirs, disks, set) = super::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity_isolated(
|
||||
disk_count, 0, parity,
|
||||
)
|
||||
.await;
|
||||
let bucket = "native-heal-commit-cleanup";
|
||||
let object = format!("generation-{disk_count}-{parity}-{read_repair}.bin");
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("create actual fixture volume");
|
||||
}
|
||||
let options = ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
shard_integrity_write_mode: Some(crate::object_api::ShardIntegrityWriteMode::Protected),
|
||||
..Default::default()
|
||||
};
|
||||
set.put_object(bucket, &object, &mut PutObjReader::from_vec(vec![0x17; TEST_OBJECT_SIZE]), &options)
|
||||
.await
|
||||
.expect("acknowledge original generation on every disk");
|
||||
let old = disks[0]
|
||||
.read_version("", bucket, &object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("read actual original generation");
|
||||
let old_dir = old.data_dir.expect("original external data directory");
|
||||
let old_part = dirs[0]
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join(&object)
|
||||
.join(old_dir.to_string())
|
||||
.join("part.1");
|
||||
assert!(tokio::fs::try_exists(&old_part).await.expect("original part exists"));
|
||||
|
||||
// Cleanup slots follow the writer's shuffled erasure distribution,
|
||||
// not the physical fixture order. Pause every real cleanup slot so
|
||||
// no physical old shard can disappear before the proof below.
|
||||
let barriers: Vec<_> = (0..disk_count)
|
||||
.map(|slot| rename_fanout_barrier::arm(&object, slot, rename_fanout_barrier::PHASE_CLEANUP))
|
||||
.collect();
|
||||
let writer_set = Arc::clone(&set);
|
||||
let writer_object = object.clone();
|
||||
let writer_options = options.clone();
|
||||
let payload = vec![0xa3; TEST_OBJECT_SIZE];
|
||||
let writer_payload = payload.clone();
|
||||
let writer = tokio::spawn(async move {
|
||||
writer_set
|
||||
.put_object(bucket, &writer_object, &mut PutObjReader::from_vec(writer_payload), &writer_options)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
for barrier in &barriers {
|
||||
barrier.wait_until_paused().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("overwrite commits before its real old-data cleanup pause");
|
||||
assert!(!writer.is_finished(), "tail-drained ACK must remain pending at cleanup");
|
||||
assert!(
|
||||
tokio::fs::try_exists(&old_part)
|
||||
.await
|
||||
.expect("old source still present at pause")
|
||||
);
|
||||
|
||||
let mut committed = Vec::new();
|
||||
let mut healthy_metadata = Vec::new();
|
||||
for disk in &disks {
|
||||
let info = disk
|
||||
.read_version("", bucket, &object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("read independently committed physical generation without GET");
|
||||
assert_eq!((info.erasure.data_blocks, info.erasure.parity_blocks), (disk_count - parity, parity));
|
||||
assert!(info.data.is_none(), "use actual external parts");
|
||||
assert_ne!(info.data_dir, Some(old_dir));
|
||||
committed.push(info);
|
||||
healthy_metadata.push(
|
||||
disk.read_all(bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("committed raw metadata"),
|
||||
);
|
||||
}
|
||||
let new_dir = committed[0].data_dir.expect("new committed data directory");
|
||||
assert!(committed.iter().all(|info| info.data_dir == Some(new_dir)));
|
||||
let new_part = dirs[0]
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join(&object)
|
||||
.join(new_dir.to_string())
|
||||
.join("part.1");
|
||||
let expected_part = tokio::fs::read(&new_part).await.expect("exact new source bytes");
|
||||
let mut committed_parts = Vec::new();
|
||||
for dir in &dirs {
|
||||
let original = dir.path().join(bucket).join(&object).join(old_dir.to_string()).join("part.1");
|
||||
assert!(
|
||||
tokio::fs::try_exists(original)
|
||||
.await
|
||||
.expect("every old cleanup slot is paused")
|
||||
);
|
||||
committed_parts.push(
|
||||
tokio::fs::read(dir.path().join(bucket).join(&object).join(new_dir.to_string()).join("part.1"))
|
||||
.await
|
||||
.expect("every new encoded shard before fault"),
|
||||
);
|
||||
}
|
||||
tokio::fs::remove_file(&new_part)
|
||||
.await
|
||||
.expect("remove only the newly committed target shard");
|
||||
println!(
|
||||
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} old_cleanup_paused new_generation_committed target_part_missing"
|
||||
);
|
||||
|
||||
let (result, error) = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
set.heal_object(
|
||||
bucket,
|
||||
&object,
|
||||
"",
|
||||
&HealOpts {
|
||||
scan_mode: HealScanMode::Deep,
|
||||
read_repair,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("real locked Heal must progress during old cleanup")
|
||||
.expect("complete actual deep Heal");
|
||||
assert!(error.is_none(), "new generation must remain repairable: {error:?}");
|
||||
assert_eq!(result.drives_healed(), Some(1));
|
||||
// Shared data-phase repair deliberately does not certify full integrity.
|
||||
assert_eq!(result.integrity_verified, !read_repair);
|
||||
assert_eq!(tokio::fs::read(&new_part).await.expect("rebuilt part before any GET"), expected_part);
|
||||
let repaired = disks[0]
|
||||
.read_version("", bucket, &object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("read actual repaired generation");
|
||||
assert_eq!(repaired.data_dir, Some(new_dir));
|
||||
assert_eq!(repaired.mod_time, committed[0].mod_time);
|
||||
for (disk, expected) in disks.iter().zip(&healthy_metadata).skip(1) {
|
||||
assert_eq!(
|
||||
disk.read_all(bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("healthy raw metadata after Heal"),
|
||||
*expected
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} locked_heal_completed new_generation_part_rebuilt_before_get"
|
||||
);
|
||||
|
||||
for barrier in &barriers {
|
||||
barrier.release();
|
||||
}
|
||||
let acknowledged = tokio::time::timeout(Duration::from_secs(30), writer)
|
||||
.await
|
||||
.expect("cleanup drains")
|
||||
.expect("writer joins")
|
||||
.expect("overwrite ACK remains successful");
|
||||
assert_eq!(acknowledged.size, i64::try_from(payload.len()).expect("fixture size fits i64"));
|
||||
drop(barriers);
|
||||
for (slot, dir) in dirs.iter().enumerate() {
|
||||
let root = dir.path().join(bucket).join(&object);
|
||||
assert!(
|
||||
!tokio::fs::try_exists(root.join(old_dir.to_string()))
|
||||
.await
|
||||
.expect("old cleanup completed")
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(root.join(new_dir.to_string()).join("part.1"))
|
||||
.await
|
||||
.expect("new shard survives cleanup before GET"),
|
||||
committed_parts[slot],
|
||||
"old cleanup must preserve every exact committed encoded shard"
|
||||
);
|
||||
}
|
||||
let mut reader = set
|
||||
.get_object_reader(bucket, &object, None, HeaderMap::new(), &options)
|
||||
.await
|
||||
.expect("read final acknowledged generation");
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.expect("read complete final payload");
|
||||
assert_eq!(body, payload);
|
||||
for disk in &disks {
|
||||
let final_info = disk
|
||||
.read_version("", bucket, &object, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("final physical generation");
|
||||
assert_eq!(final_info.data_dir, Some(new_dir), "old cleanup must not revert the healed generation");
|
||||
}
|
||||
println!(
|
||||
"native_g01: geometry={disk_count}/{parity} read_repair={read_repair} overwrite_ack_preserved final_physical_generation_and_full_body_verified"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_put_releases_namespace_lock_before_old_data_cleanup() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
|
||||
@@ -1432,7 +1432,10 @@ impl SetDisks {
|
||||
"Create bitrot reader failed read quorum"
|
||||
);
|
||||
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
|
||||
return Err(to_object_err(read_err.into(), vec![bucket, object]));
|
||||
// The metadata selected this object, but the part cannot
|
||||
// be reconstructed. A dominant disk I/O/not-found error
|
||||
// must not erase the object's read-quorum failure.
|
||||
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
|
||||
}
|
||||
let reason = GetObjectFailureReason::ReadQuorum;
|
||||
error!(
|
||||
@@ -1457,7 +1460,7 @@ impl SetDisks {
|
||||
"Create bitrot reader did not have enough disks"
|
||||
);
|
||||
record_get_object_pipeline_failure(GET_STAGE_READER_SETUP, reason);
|
||||
return Err(Error::other(format!("not enough disks to read: {:?}", reader_setup.errors)));
|
||||
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
|
||||
}
|
||||
|
||||
// Check if we have missing shards even though we can read successfully
|
||||
@@ -1992,10 +1995,10 @@ impl SetDisks {
|
||||
if let Some(read_err) = reduce_read_quorum_errs(&reader_setup.errors, OBJECT_OP_IGNORED_ERRS, erasure.data_shards) {
|
||||
let reason = classify_disk_error(&read_err);
|
||||
record_get_object_pipeline_failure_for_path(metrics_path, GET_STAGE_READER_SETUP, reason);
|
||||
return Err(to_object_err(read_err.into(), vec![bucket, object]));
|
||||
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
|
||||
}
|
||||
record_get_object_pipeline_failure_for_path(metrics_path, GET_STAGE_READER_SETUP, GetObjectFailureReason::ReadQuorum);
|
||||
return Err(Error::other(format!("not enough disks to read: {:?}", reader_setup.errors)));
|
||||
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
|
||||
}
|
||||
|
||||
let missing_shards = reader_setup.completed_failed_shards();
|
||||
@@ -7801,6 +7804,105 @@ mod tests {
|
||||
(data_dir, files)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn external_missing_shards_keep_typed_read_quorum() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let payload = vec![0x37; 256 * 1024];
|
||||
for readable_shards in [1, 2, 4] {
|
||||
let erasure = coding::Erasure::new(4, 2, 1024 * 1024);
|
||||
let mut fi = codec_streaming_test_fileinfo(i64::try_from(payload.len()).expect("fixture size fits i64"), 1);
|
||||
fi.erasure.data_blocks = erasure.data_shards;
|
||||
fi.erasure.parity_blocks = erasure.parity_shards;
|
||||
fi.erasure.block_size = erasure.block_size;
|
||||
fi.erasure.distribution = (1..=erasure.total_shard_count()).collect();
|
||||
let (dirs, disks) = local_test_disks(erasure.total_shard_count(), CODEC_STREAMING_TEST_BUCKET).await;
|
||||
let (data_dir, files) = codec_streaming_external_files(&erasure, &payload, &disks).await;
|
||||
fi.data_dir = Some(data_dir);
|
||||
for directory in dirs.iter().skip(readable_shards) {
|
||||
tokio::fs::remove_file(
|
||||
directory
|
||||
.path()
|
||||
.join(CODEC_STREAMING_TEST_BUCKET)
|
||||
.join(CODEC_STREAMING_TEST_OBJECT)
|
||||
.join(data_dir.to_string())
|
||||
.join("part.1"),
|
||||
)
|
||||
.await
|
||||
.expect("remove real part while retaining its selected metadata");
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
let legacy = SetDisks::get_object_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
0,
|
||||
fi.size,
|
||||
&mut output,
|
||||
fi.clone(),
|
||||
files.clone(),
|
||||
&disks,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
GET_OBJECT_PATH_LEGACY_DUPLEX,
|
||||
"plain_single_part",
|
||||
"le_1mib",
|
||||
)
|
||||
.await;
|
||||
let streaming = SetDisks::get_object_mid_size_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&fi,
|
||||
&files,
|
||||
&disks,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
"plain_single_part",
|
||||
"le_1mib",
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
if readable_shards < erasure.data_shards {
|
||||
for error in [
|
||||
legacy.expect_err("legacy read must reject quorum minus one"),
|
||||
streaming.err().expect("streaming setup must reject quorum minus one"),
|
||||
] {
|
||||
assert_eq!(
|
||||
error,
|
||||
Error::InsufficientReadQuorum(
|
||||
CODEC_STREAMING_TEST_BUCKET.to_owned(),
|
||||
CODEC_STREAMING_TEST_OBJECT.to_owned(),
|
||||
)
|
||||
);
|
||||
}
|
||||
assert!(output.is_empty(), "quorum rejection must precede all response bytes");
|
||||
} else {
|
||||
legacy.expect("legacy reader retains reconstructable quorum");
|
||||
assert_eq!(output, payload, "legacy read returns every original byte at quorum");
|
||||
let GetCodecStreamingReaderBuildOutcome::Reader(mut reader) =
|
||||
streaming.expect("streaming quorum remains readable")
|
||||
else {
|
||||
panic!("mid-size quorum must yield an actual reader");
|
||||
};
|
||||
let mut decoded = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut decoded)
|
||||
.await
|
||||
.expect("streaming reader returns the complete body");
|
||||
assert_eq!(decoded, payload, "streaming read returns every original byte at quorum");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn payload_read_repair_is_suppressed_only_for_read_only_requests() {
|
||||
|
||||
@@ -1285,14 +1285,19 @@ impl ECStore {
|
||||
let results = if is_pool_meta_object(bucket, &object) && !opts.no_lock && !heal_pools.is_empty() {
|
||||
let target_pool_indices = heal_pools.iter().map(|pool| pool.pool_idx).collect::<Vec<_>>();
|
||||
match self.acquire_pool_meta_object_heal_fence(&target_pool_indices).await {
|
||||
Ok((pool_meta_guard, admissions)) => {
|
||||
Ok((pool_meta_guard, admissions, mut save_guard)) => {
|
||||
let fixed_set = self.pools.first().and_then(|pool| pool.disk_set.first()).cloned();
|
||||
let futures = heal_pools.iter().zip(admissions).map(|(pool, admission)| {
|
||||
let mut results = Vec::with_capacity(heal_pools.len());
|
||||
// The metadata writer is shared across these repairs. Keep
|
||||
// its mutable write state in the original lock order; each
|
||||
// target still fans out to its disks concurrently.
|
||||
for (pool, admission) in heal_pools.iter().zip(admissions) {
|
||||
let pool = Arc::clone(pool);
|
||||
let pool_object = object.clone();
|
||||
let fixed_set = fixed_set.clone();
|
||||
let pool_meta_guard = Arc::clone(&pool_meta_guard);
|
||||
let mut opts = *opts;
|
||||
async move {
|
||||
let result = async {
|
||||
admission?;
|
||||
let fixed_set = fixed_set.ok_or_else(|| Error::other("pool metadata heal requires a fixed set"))?;
|
||||
let target_set = pool.get_disks_for_heal_object(&pool_object, &opts)?;
|
||||
@@ -1303,12 +1308,14 @@ impl ECStore {
|
||||
}
|
||||
#[cfg(test)]
|
||||
crate::core::pools::notify_decommission_external_heal_operation_started(store_id);
|
||||
pool.heal_object_with_absence(bucket, &pool_object, version_id, &opts, proof)
|
||||
self.heal_pool_meta_object_with_cas(&pool, version_id, &opts, proof, pool_meta_guard, &mut save_guard)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let results = join_all(futures).await;
|
||||
.await;
|
||||
results.push(result);
|
||||
}
|
||||
drop(pool_meta_guard);
|
||||
drop(save_guard);
|
||||
results
|
||||
}
|
||||
Err(err) => (0..heal_pools.len()).map(|_| Err(err.clone())).collect(),
|
||||
@@ -1395,6 +1402,54 @@ impl ECStore {
|
||||
Ok((HealResultItem::default(), Some(Error::FileNotFound)))
|
||||
}
|
||||
|
||||
async fn heal_pool_meta_object_with_cas(
|
||||
&self,
|
||||
pool: &Arc<Sets>,
|
||||
version_id: &str,
|
||||
opts: &HealOpts,
|
||||
proof: crate::set_disk::AbsenceProofRequest<'_>,
|
||||
guard: Arc<NamespaceLockGuard>,
|
||||
state: &mut crate::core::pools::PoolMetaWriteState,
|
||||
) -> Result<(HealResultItem, Option<Error>, Option<crate::set_disk::HealedObjectAbsence>)> {
|
||||
// Observe through the ordinary healer, but let only the owning pool
|
||||
// metadata CAS publish replacements. Its native conditions also cover
|
||||
// missing members without admitting an unconditional heal overwrite.
|
||||
let mut inspect_opts = *opts;
|
||||
inspect_opts.dry_run = true;
|
||||
let version = if Uuid::parse_str(version_id).is_ok_and(|version| version.is_nil()) {
|
||||
""
|
||||
} else {
|
||||
version_id
|
||||
};
|
||||
let (before, error, absence) = pool
|
||||
.heal_object_with_absence(RUSTFS_META_BUCKET, POOL_META_NAME, version, &inspect_opts, proof)
|
||||
.await?;
|
||||
let target = pool.get_disks_for_heal_object(POOL_META_NAME, opts)?;
|
||||
let owns_replica = Arc::ptr_eq(&target, &pool.get_disks_by_key(POOL_META_NAME));
|
||||
let needs_repair = !before.after.drives.is_empty() && before.after.drives.iter().any(|drive| drive.state != "ok");
|
||||
if opts.dry_run || error.is_some() || !owns_replica || !needs_repair {
|
||||
return Ok((before, error, absence));
|
||||
}
|
||||
if let Err(error) = self
|
||||
.heal_pool_meta_replica_under_fence(pool.pool_idx, Arc::clone(&guard), state)
|
||||
.await
|
||||
{
|
||||
return Ok((before, Some(error), None));
|
||||
}
|
||||
let (mut after, error, _) = pool
|
||||
.heal_object_with_absence(RUSTFS_META_BUCKET, POOL_META_NAME, version, &inspect_opts, proof)
|
||||
.await?;
|
||||
after.before = before.before;
|
||||
if guard.is_lock_lost() {
|
||||
return Ok((after, Some(Error::SlowDown), None));
|
||||
}
|
||||
if error.is_none() && !after.after.drives.is_empty() && after.after.drives.iter().all(|drive| drive.state == "ok") {
|
||||
after.metadata_verified = true;
|
||||
after.metadata_repair_verified = true;
|
||||
}
|
||||
Ok((after, error, None))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub(super) async fn handle_check_abandoned_parts(&self, bucket: &str, object: &str, opts: &HealOpts) -> Result<()> {
|
||||
let object = encode_dir_object(object);
|
||||
@@ -1752,6 +1807,165 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn historical_heal_backup_survives_stale_incarnation_and_movement() {
|
||||
use crate::disk::local::{DurabilityMode, durability_mode, durability_mode_override, effective_durability};
|
||||
|
||||
let _mode = durability_mode_override::set(DurabilityMode::Strict);
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
("RUSTFS_DURABILITY_MODE", Some("strict")),
|
||||
("RUSTFS_NEW_BUCKET_DURABILITY_MODE", Some("strict")),
|
||||
],
|
||||
async {
|
||||
let (_root, store, shutdown) = heal_store_with_pool_count(1).await;
|
||||
assert_eq!(durability_mode(), DurabilityMode::Strict, "process durability must be strict");
|
||||
let bucket = "heal-retained-backup";
|
||||
let object = "object";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("original bucket");
|
||||
assert_eq!(effective_durability(bucket), DurabilityMode::Strict, "original bucket must be strict");
|
||||
let old = store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("original identity");
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete original empty bucket");
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("same-name successor");
|
||||
assert_eq!(effective_durability(bucket), DurabilityMode::Strict, "successor bucket must be strict");
|
||||
let current = store
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("successor identity");
|
||||
assert_ne!(old, current);
|
||||
let set = &store.pools[0].disk_set[0];
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![73; 512 * 1024]),
|
||||
&ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write successor object");
|
||||
let disks: Vec<_> = set.disks.read().await.iter().flatten().cloned().collect();
|
||||
assert_eq!(disks.len(), 4);
|
||||
let mut evidence = Vec::new();
|
||||
for disk in &disks {
|
||||
let metadata = disk.read_all(bucket, "object/xl.meta").await.expect("current metadata");
|
||||
let info = disk
|
||||
.read_version("", bucket, object, "", &Default::default())
|
||||
.await
|
||||
.expect("current generation");
|
||||
let transaction = info.data_dir.expect("external shard directory");
|
||||
let backup = format!("{object}/{transaction}/xl.meta.bkp");
|
||||
disk.write_all(bucket, &backup, metadata.clone())
|
||||
.await
|
||||
.expect("retain unreceipted historical backup");
|
||||
let inspection = disk
|
||||
.inspect_delete_transaction(bucket, object, transaction)
|
||||
.await
|
||||
.expect("read-only historical inspection");
|
||||
assert_eq!(inspection.state, crate::disk::DeleteRecoveryState::Absent);
|
||||
assert!(inspection.journal.is_none());
|
||||
assert!(inspection.backup_digest.is_some(), "delete absence must not hide the retained backup");
|
||||
evidence.push((transaction, backup, metadata, inspection.fingerprint));
|
||||
}
|
||||
let opts = HealOpts {
|
||||
pool: Some(0),
|
||||
set: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some(object), Some(old), &opts)
|
||||
.await
|
||||
.is_err(),
|
||||
"an old bucket admission cannot inspect or settle its successor"
|
||||
);
|
||||
|
||||
for rebalance in [false, true] {
|
||||
let movement_gate = store.ctx.data_movement_operation_gate();
|
||||
let held = movement_gate.write().await;
|
||||
let mut recovery = tokio::spawn({
|
||||
let store = store.clone();
|
||||
async move {
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some(object), Some(current), &opts)
|
||||
.await
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), &mut recovery)
|
||||
.await
|
||||
.is_err(),
|
||||
"recovery must wait for the movement owner before inspecting backups"
|
||||
);
|
||||
if rebalance {
|
||||
*store.rebalance_meta.write().await = Some(RebalanceMeta {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
store.pool_meta.write().await.pools[0].decommission = Some(PoolDecommissionInfo {
|
||||
start_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
drop(held);
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(10), recovery)
|
||||
.await
|
||||
.expect("released recovery must finish")
|
||||
.expect("recovery task")
|
||||
.expect_err("active movement must defer historical recovery");
|
||||
assert_eq!(error, StorageError::SlowDown);
|
||||
*store.rebalance_meta.write().await = None;
|
||||
store.pool_meta.write().await.pools[0].decommission = None;
|
||||
}
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(bucket, Some(object), Some(current), &opts)
|
||||
.await
|
||||
.expect("fresh recovery can proceed after movement stops");
|
||||
for (disk, (transaction, backup, metadata, fingerprint)) in disks.iter().zip(evidence) {
|
||||
assert_eq!(
|
||||
disk.read_all(bucket, "object/xl.meta")
|
||||
.await
|
||||
.expect("preserved current metadata"),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(disk.read_all(bucket, &backup).await.expect("preserved historical backup"), metadata);
|
||||
let inspection = disk
|
||||
.inspect_delete_transaction(bucket, object, transaction)
|
||||
.await
|
||||
.expect("retained backup remains queryable");
|
||||
assert_eq!(inspection.fingerprint, fingerprint);
|
||||
assert!(inspection.backup_digest.is_some());
|
||||
assert!(inspection.journal.is_none());
|
||||
}
|
||||
shutdown.cancel();
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn bucket_incarnation_heal_preserves_successor_shards_and_allows_fresh_repair() {
|
||||
@@ -1953,6 +2167,210 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn bucket_incarnation_fence_survives_caller_abort_at_real_repair_commit() {
|
||||
use crate::set_disk::ReadRepairPauseScope;
|
||||
|
||||
let (_root, store, shutdown) = multi_pool_heal_store().await;
|
||||
let bucket = format!("heal-commit-owner-{}", Uuid::new_v4().simple());
|
||||
let object = "history.bin";
|
||||
let version = Uuid::new_v4().to_string();
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("original bucket");
|
||||
let old = store
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("original identity");
|
||||
let set = store.pools[0].get_disks(0);
|
||||
let mut reader = PutObjReader::from_vec(vec![0x17; 1024 * 1024]);
|
||||
set.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version.clone()),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("original acknowledged version");
|
||||
let missing = set.disks.read().await[0].clone().expect("repair target");
|
||||
let healthy = set.disks.read().await[1].clone().expect("healthy target");
|
||||
let baseline = healthy
|
||||
.read_all(&bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("healthy metadata baseline");
|
||||
let original = healthy
|
||||
.read_version("", &bucket, object, &version, &Default::default())
|
||||
.await
|
||||
.expect("original version metadata");
|
||||
assert!(original.data.is_none(), "the commit barrier fixture must use actual external data shards");
|
||||
missing
|
||||
.delete(
|
||||
&bucket,
|
||||
object,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remove one physical shard");
|
||||
|
||||
// The scope pauses the real repair after reconstruction, immediately
|
||||
// before its generation check and final shard rename.
|
||||
let (_pause, arrived, release) = ReadRepairPauseScope::install_incarnation_commit(&bucket, object);
|
||||
let (drained_tx, drained_rx) = tokio::sync::oneshot::channel();
|
||||
let worker_store = store.clone();
|
||||
let worker_bucket = bucket.clone();
|
||||
let worker_version = version.clone();
|
||||
let opts = HealOpts {
|
||||
pool: Some(0),
|
||||
set: Some(0),
|
||||
scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep,
|
||||
..Default::default()
|
||||
};
|
||||
let mut waiter = tokio::spawn(async move {
|
||||
worker_store
|
||||
.run_bucket_heal_at_incarnation(&worker_bucket, old, &opts, move |store, bucket, opts| async move {
|
||||
let result = store.heal_object_with_proof(&bucket, object, &worker_version, &opts).await?;
|
||||
drained_tx
|
||||
.send(result)
|
||||
.expect("retain the actual storage result after caller cancellation");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
tokio::select! {
|
||||
paused = tokio::time::timeout(std::time::Duration::from_secs(10), arrived.notified()) => {
|
||||
paused.expect("real missing-shard repair reaches the final commit pause");
|
||||
}
|
||||
completed = &mut waiter => panic!("repair ended before the actual final commit barrier: {completed:?}"),
|
||||
}
|
||||
assert!(
|
||||
missing.read_xl(&bucket, object, false).await.is_err(),
|
||||
"final shard is not published at the pause"
|
||||
);
|
||||
println!("incarnation_commit_barrier: final_rename_paused");
|
||||
waiter.abort();
|
||||
assert!(waiter.await.expect_err("caller aborted").is_cancelled());
|
||||
let mut writer = Box::pin(store.acquire_bucket_lifecycle_write_lock(&bucket));
|
||||
assert!(
|
||||
futures::poll!(&mut writer).is_pending(),
|
||||
"bucket deletion/recreation cannot acquire its generation writer while the cancelled repair still owns commit"
|
||||
);
|
||||
println!("incarnation_commit_barrier: caller_aborted_writer_pending");
|
||||
release.notify_one();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(10), drained_rx)
|
||||
.await
|
||||
.expect("actual repair drains after release")
|
||||
.expect("retained repair result");
|
||||
assert!(
|
||||
result.error.is_none(),
|
||||
"physical repair must succeed before lifecycle handoff: {:?}",
|
||||
result.error
|
||||
);
|
||||
let guard = tokio::time::timeout(std::time::Duration::from_secs(10), writer)
|
||||
.await
|
||||
.expect("generation writer acquires only after physical repair drains")
|
||||
.expect("generation writer");
|
||||
assert!(
|
||||
missing.read_xl(&bucket, object, false).await.is_ok(),
|
||||
"the physical shard is published before writer admission"
|
||||
);
|
||||
let repaired = missing
|
||||
.read_version("", &bucket, object, &version, &Default::default())
|
||||
.await
|
||||
.expect("repaired exact version metadata");
|
||||
assert_eq!(repaired.version_id, original.version_id);
|
||||
assert_eq!(repaired.data_dir, original.data_dir);
|
||||
let checked = missing
|
||||
.check_parts(&bucket, object, &repaired)
|
||||
.await
|
||||
.expect("verify the published physical shard");
|
||||
assert_eq!(
|
||||
checked.results,
|
||||
vec![crate::disk::CHECK_PART_SUCCESS],
|
||||
"the actual published part passes bitrot verification"
|
||||
);
|
||||
assert_eq!(
|
||||
healthy
|
||||
.read_all(&bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("healthy after repair"),
|
||||
baseline,
|
||||
"repair does not overwrite the surviving version"
|
||||
);
|
||||
println!("incarnation_commit_barrier: repair_drained_writer_acquired");
|
||||
drop(guard);
|
||||
|
||||
set.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("normal explicit deletion of the old version");
|
||||
store
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("normal empty bucket deletion");
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("same-name successor bucket");
|
||||
let new = store
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("successor identity");
|
||||
assert_ne!(old, new);
|
||||
let successor_version = Uuid::new_v4().to_string();
|
||||
let mut successor_reader = PutObjReader::from_vec(vec![0x29; 512 * 1024]);
|
||||
set.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut successor_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(successor_version),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("acknowledged successor version");
|
||||
let successor = healthy
|
||||
.read_all(&bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("successor metadata");
|
||||
assert!(
|
||||
store
|
||||
.heal_object_at_incarnation(&bucket, object, &version, old, &opts)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
healthy
|
||||
.read_all(&bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("successor after obsolete admission"),
|
||||
successor,
|
||||
"obsolete admission cannot change the same-name successor"
|
||||
);
|
||||
println!("incarnation_commit_barrier: successor_rejects_obsolete_admission");
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn bucket_incarnation_heal_owner_survives_cancelled_waiter_and_queued_writer() {
|
||||
@@ -2053,18 +2471,14 @@ mod tests {
|
||||
let missing_disk = target_set.disks.read().await[0]
|
||||
.clone()
|
||||
.expect("pool metadata fixture disk should be online");
|
||||
missing_disk
|
||||
.delete(
|
||||
RUSTFS_META_BUCKET,
|
||||
POOL_META_NAME,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("one pool metadata shard should be removable");
|
||||
assert!(missing_disk.is_local(), "pool metadata fault fixture requires a local disk");
|
||||
tokio::fs::remove_dir_all(
|
||||
std::path::Path::new(&missing_disk.endpoint().get_file_path())
|
||||
.join(RUSTFS_META_BUCKET)
|
||||
.join(POOL_META_NAME),
|
||||
)
|
||||
.await
|
||||
.expect("remove one physical metadata shard without relaxing the native mutation guard");
|
||||
assert!(
|
||||
missing_disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err(),
|
||||
"pool metadata fixture must start with one missing shard"
|
||||
@@ -2962,10 +3376,36 @@ mod tests {
|
||||
async fn replacement_format_heal_initialized_identity_without_pool_meta_never_writes_and_latches() {
|
||||
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||
// Simulate physical loss; raw deletion of authoritative pool.bin is
|
||||
// deliberately rejected by the native metadata mutation guard.
|
||||
for pool in &store.pools {
|
||||
delete_config(pool.clone(), POOL_META_NAME)
|
||||
let (original_identity, _) = read_config_no_lock_preserve_empty_with_metadata(pool.clone(), POOL_META_IDENTITY_NAME)
|
||||
.await
|
||||
.expect("pool metadata replica should be removable");
|
||||
.expect("initialized identity before physical pool metadata loss");
|
||||
let metadata_set = pool.get_disks_by_key(POOL_META_NAME);
|
||||
let disks = metadata_set.disks.read().await.clone();
|
||||
assert!(disks.iter().all(Option::is_some), "metadata-loss fixture requires every replica online");
|
||||
for disk in disks.iter().flatten() {
|
||||
assert!(disk.is_local(), "metadata-loss fixture requires local disks");
|
||||
let metadata = std::path::PathBuf::from(disk.endpoint().get_file_path())
|
||||
.join(RUSTFS_META_BUCKET)
|
||||
.join(POOL_META_NAME);
|
||||
tokio::fs::remove_dir_all(&metadata)
|
||||
.await
|
||||
.expect("remove the actual pool metadata shard");
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&metadata)
|
||||
.await
|
||||
.expect("inspect physically absent metadata")
|
||||
);
|
||||
}
|
||||
let (identity_after, _) = read_config_no_lock_preserve_empty_with_metadata(pool.clone(), POOL_META_IDENTITY_NAME)
|
||||
.await
|
||||
.expect("initialized identity survives physical metadata loss");
|
||||
assert!(
|
||||
identity_after == original_identity,
|
||||
"fault injection must preserve the initialized identity bytes"
|
||||
);
|
||||
}
|
||||
|
||||
let err = store
|
||||
@@ -3091,6 +3531,106 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn pool_meta_heal_dry_run_preserves_missing_shard() {
|
||||
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||
let missing_disk = remove_pool_meta_shard(&store, 0).await;
|
||||
let (result, error) = store
|
||||
.handle_heal_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
POOL_META_NAME,
|
||||
"",
|
||||
&HealOpts {
|
||||
dry_run: true,
|
||||
pool: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("pool metadata dry-run inspection");
|
||||
assert!(
|
||||
error.is_none(),
|
||||
"readable canonical metadata should support a dry-run inspection: {error:?}"
|
||||
);
|
||||
assert_eq!(result.object, POOL_META_NAME);
|
||||
assert!(
|
||||
result
|
||||
.before
|
||||
.drives
|
||||
.iter()
|
||||
.any(|drive| drive.state == DriveState::Missing.to_string())
|
||||
);
|
||||
assert!(
|
||||
missing_disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err(),
|
||||
"dry-run must not perform the controlled CAS"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn pool_meta_heal_cas_repairs_only_the_requested_pool() {
|
||||
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||
let selected_missing = remove_pool_meta_shard(&store, 0).await;
|
||||
let unselected_missing = remove_pool_meta_shard(&store, 1).await;
|
||||
let (result, error) = store
|
||||
.handle_heal_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
POOL_META_NAME,
|
||||
"",
|
||||
&HealOpts {
|
||||
pool: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("targeted pool metadata heal");
|
||||
assert!(error.is_none(), "the selected pool metadata replica should heal: {error:?}");
|
||||
assert_eq!(result.object, POOL_META_NAME);
|
||||
assert!(
|
||||
selected_missing
|
||||
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
unselected_missing
|
||||
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||
.await
|
||||
.is_err(),
|
||||
"targeted metadata heal must not rewrite an unselected pool"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn pool_meta_heal_retains_save_gate_before_writer_through_native_cas() {
|
||||
let (_directories, store, _other_store) = test_two_pool_stores(None).await;
|
||||
let missing = remove_pool_meta_shard(&store, 0).await;
|
||||
let (guard, admissions, mut state) = store.acquire_pool_meta_object_heal_fence(&[0]).await.expect("heal authority");
|
||||
assert!(admissions.into_iter().all(|admission| admission.is_ok()));
|
||||
assert!(store.pool_meta_save_gate.try_lock().is_err(), "heal must retain the earlier save gate");
|
||||
let writer_store = Arc::clone(&store);
|
||||
let writer = tokio::spawn(async move { writer_store.save_current_pool_meta_for_test(&[0]).await });
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!writer.is_finished(), "ordinary writer must queue before taking its namespace writer");
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
store.heal_pool_meta_replica_under_fence(0, Arc::clone(&guard), &mut state),
|
||||
)
|
||||
.await
|
||||
.expect("heal cannot wait for a gate held by the queued writer")
|
||||
.expect("native heal");
|
||||
drop(guard);
|
||||
drop(state);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), writer)
|
||||
.await
|
||||
.expect("queued writer must progress after heal")
|
||||
.expect("writer task")
|
||||
.expect("writer result");
|
||||
assert!(missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn pool_meta_heal_preserves_typed_lock_timeout() {
|
||||
|
||||
+998
-111
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
mod multipart_copy_heal_tests {
|
||||
use super::{build_isolated_test_store, run_large_stack_async_test, without_storage_class_env};
|
||||
use crate::{
|
||||
disk::{
|
||||
DeleteOptions, DiskAPI as _, DiskStore, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET, ReadOptions,
|
||||
error::DiskError,
|
||||
multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartProofKind, MultipartSourceOperation, MultipartSourcePhase,
|
||||
MultipartSourceProof, MultipartSourceRequest, MultipartSourceResponse, PublicationIdentity, SourceConditionV1,
|
||||
is_publication_metadata_key, publication_identity, publication_identity_from_metadata,
|
||||
},
|
||||
},
|
||||
object_api::{ObjectOptions, PutObjReader, WriteCompletion},
|
||||
runtime::instance::InstanceContext,
|
||||
storage_api_contracts::{
|
||||
bucket::{BucketOperations as _, MakeBucketOptions},
|
||||
heal::HealOperations,
|
||||
multipart::{CompletePart, MultipartOperations as _},
|
||||
object::{ObjectIO as _, ObjectOperations as _},
|
||||
},
|
||||
store::ECStore,
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
use rustfs_heal_contracts::heal_channel::{DriveState, HealOpts, HealScanMode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ANCHOR: &str = ".mpu-source-retirement";
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AnchorBytes {
|
||||
protocol_version: u16,
|
||||
disk_id: Uuid,
|
||||
physical_slot: usize,
|
||||
publication: PublicationIdentity,
|
||||
source_original_sha256: [u8; 32],
|
||||
source_validation_receipt_id: Uuid,
|
||||
}
|
||||
|
||||
struct CopyHealFixture {
|
||||
ctx: Arc<InstanceContext>,
|
||||
store: Arc<ECStore>,
|
||||
shutdown: CancellationToken,
|
||||
disks: Vec<DiskStore>,
|
||||
bucket: String,
|
||||
object: String,
|
||||
publication: PublicationIdentity,
|
||||
original_options: ObjectOptions,
|
||||
body: Vec<u8>,
|
||||
anchors: Vec<Vec<u8>>,
|
||||
_root: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Drop for CopyHealFixture {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn local_path(disk: &DiskStore, bucket: &str, path: &str) -> PathBuf {
|
||||
disk.get_object_path_if_local(bucket, path)
|
||||
.expect("actual local participant")
|
||||
.expect("valid physical fixture path")
|
||||
}
|
||||
|
||||
impl CopyHealFixture {
|
||||
async fn new(label: &str) -> Self {
|
||||
let root = tempfile::tempdir().expect("real Copy/heal store roots");
|
||||
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root.path(), label, &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("copy-heal-source-{}", Uuid::new_v4());
|
||||
let object = "native-completion-with-shared-data-directory".to_owned();
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("actual versioned source bucket metadata");
|
||||
let mut seed = 0x6d70_752d_636f_7079_u64;
|
||||
let body = (0..1 << 20)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let options = ObjectOptions {
|
||||
versioned: true,
|
||||
mod_time: Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed native C timestamp")),
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
let upload = store.pools[0]
|
||||
.new_multipart_upload(&bucket, &object, &options)
|
||||
.await
|
||||
.expect("actual native C Init");
|
||||
let part = store.pools[0]
|
||||
.put_object_part(
|
||||
&bucket,
|
||||
&object,
|
||||
&upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(body.clone()),
|
||||
&options,
|
||||
)
|
||||
.await
|
||||
.expect("actual C erasure shards and selected part generation");
|
||||
let completed = store.pools[0]
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
&bucket,
|
||||
&object,
|
||||
&upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&options,
|
||||
)
|
||||
.await
|
||||
.expect("actual native C quorum publication");
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("native completion physical tails drain");
|
||||
let version = completed.version_id.expect("actual native C version");
|
||||
let original_options = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let current = store
|
||||
.get_object_info(&bucket, &object, &original_options)
|
||||
.await
|
||||
.expect("actual committed C metadata");
|
||||
let publication = publication_identity_from_metadata(¤t.user_defined)
|
||||
.expect("actual C tuple is strict typed metadata")
|
||||
.expect("actual C carries native provenance");
|
||||
assert_eq!(publication.version_id, Some(version));
|
||||
let disks = store.pools[0].disk_set[0]
|
||||
.disk_inventory()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|disk| disk.expect("all actual formatted participants online"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut anchors = Vec::with_capacity(4);
|
||||
for (slot, disk) in disks.iter().enumerate() {
|
||||
let fi = disk
|
||||
.read_version("", &bucket, &object, &version.to_string(), &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual C metadata on each producer");
|
||||
assert_eq!(publication_identity(&fi).expect("actual participant C tuple"), Some(publication.clone()));
|
||||
assert_eq!(fi.data_dir, Some(publication.final_data_dir));
|
||||
assert!(
|
||||
!local_path(disk, RUSTFS_META_MULTIPART_BUCKET, &publication.lineage.path).exists(),
|
||||
"normal Complete removes the actual source root before these RootMissing tests"
|
||||
);
|
||||
let raw = std::fs::read(local_path(disk, &bucket, &format!("{object}/{}/{ANCHOR}", publication.final_data_dir)))
|
||||
.expect("actual producer's version-owned anchor");
|
||||
let anchor: AnchorBytes = rmp_serde::from_slice(&raw).expect("actual native anchor record");
|
||||
assert_eq!(
|
||||
(anchor.disk_id, anchor.physical_slot),
|
||||
(disk.get_disk_id().await.expect("actual format ID").expect("formatted disk"), slot)
|
||||
);
|
||||
assert_eq!(anchor.publication, publication);
|
||||
anchors.push(raw);
|
||||
}
|
||||
Self {
|
||||
ctx,
|
||||
store,
|
||||
shutdown,
|
||||
disks,
|
||||
bucket,
|
||||
object,
|
||||
publication,
|
||||
original_options,
|
||||
body,
|
||||
anchors,
|
||||
_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while self.ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("real Copy/heal physical tails drain");
|
||||
}
|
||||
|
||||
async fn version_copy(&self) -> ObjectOptions {
|
||||
self.version_copy_with_replacement(false).await
|
||||
}
|
||||
|
||||
async fn version_copy_with_replacement(&self, replace_metadata: bool) -> ObjectOptions {
|
||||
let mut source = self
|
||||
.store
|
||||
.get_object_info(&self.bucket, &self.object, &self.original_options)
|
||||
.await
|
||||
.expect("actual original version for CopyObject");
|
||||
source.metadata_only = true;
|
||||
source.put_object_reader = None;
|
||||
if replace_metadata {
|
||||
// This is the real storage producer input for MetadataDirective
|
||||
// REPLACE: CopyObject receives the caller's replacement map.
|
||||
source.user_defined = Arc::new(std::collections::HashMap::new());
|
||||
}
|
||||
Arc::make_mut(&mut source.user_defined).insert("x-amz-meta-version-copy".to_owned(), "D".to_owned());
|
||||
let version = Uuid::new_v4();
|
||||
let options = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version.to_string()),
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
self.store
|
||||
.copy_object(
|
||||
&self.bucket,
|
||||
&self.object,
|
||||
&self.bucket,
|
||||
&self.object,
|
||||
&mut source,
|
||||
&self.original_options,
|
||||
&options,
|
||||
)
|
||||
.await
|
||||
.expect("actual metadata/version-only CopyObject creates D");
|
||||
assert!(source.version_only, "the real ECStore producer selected the version-only branch");
|
||||
self.drain().await;
|
||||
for disk in &self.disks {
|
||||
let d = disk
|
||||
.read_version("", &self.bucket, &self.object, &version.to_string(), &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual D version on each disk");
|
||||
assert_eq!(d.version_id, Some(version));
|
||||
assert_ne!(d.version_id, self.publication.version_id);
|
||||
assert_ne!(
|
||||
d.mod_time.expect("actual D timestamp").unix_timestamp_nanos(),
|
||||
self.publication.modified_unix_nanos
|
||||
);
|
||||
assert_eq!(
|
||||
d.data_dir,
|
||||
Some(self.publication.final_data_dir),
|
||||
"actual version-only Copy shares C's physical DD"
|
||||
);
|
||||
assert_eq!(
|
||||
publication_identity(&d).expect("strict metadata from the actual Copy producer"),
|
||||
if replace_metadata {
|
||||
None
|
||||
} else {
|
||||
Some(self.publication.clone())
|
||||
},
|
||||
"metadata replacement removes the historical tuple; ordinary version Copy preserves it"
|
||||
);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
async fn confirm(
|
||||
&self,
|
||||
slot: usize,
|
||||
publication: &PublicationIdentity,
|
||||
) -> std::result::Result<MultipartSourceProof, DiskError> {
|
||||
let response = self.disks[slot]
|
||||
.recover_multipart_source(MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: publication.lineage.path.clone(),
|
||||
operation: MultipartSourceOperation::ConfirmTerminal {
|
||||
expected: None,
|
||||
publication: publication.clone(),
|
||||
challenge: Uuid::new_v4(),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let MultipartSourceResponse::TerminalConfirmed(proof) = response else {
|
||||
panic!("strict terminal native response");
|
||||
};
|
||||
proof.verify_with_authority(&self.ctx.multipart_proof_verification_context())?;
|
||||
assert_eq!(proof.kind, MultipartProofKind::RootMissingTerminal);
|
||||
assert_eq!(proof.snapshot.phase, MultipartSourcePhase::RootMissing);
|
||||
assert_eq!(proof.snapshot.publication.as_ref(), Some(publication));
|
||||
assert_eq!(proof.snapshot.physical_slot, slot);
|
||||
Ok(proof)
|
||||
}
|
||||
|
||||
fn anchor_path(&self, slot: usize) -> PathBuf {
|
||||
local_path(
|
||||
&self.disks[slot],
|
||||
&self.bucket,
|
||||
&format!("{}/{}/{ANCHOR}", self.object, self.publication.final_data_dir),
|
||||
)
|
||||
}
|
||||
|
||||
async fn force_deep_heal(&self, slot: usize, bucket: &str, object: &str, options: &ObjectOptions, data_dir: Uuid) {
|
||||
let shard = local_path(&self.disks[slot], bucket, &format!("{object}/{data_dir}/part.1"));
|
||||
let original = std::fs::read(&shard).expect("real producer's physical shard before corruption");
|
||||
assert!(!original.is_empty(), "noninline one-MiB object has a real bitrot shard");
|
||||
let mut corrupt = original.clone();
|
||||
corrupt[0] ^= 0x80;
|
||||
std::fs::write(&shard, &corrupt).expect("actual bitrot corruption in shared physical shard");
|
||||
std::fs::File::open(&shard)
|
||||
.expect("open corrupted actual shard")
|
||||
.sync_all()
|
||||
.expect("persist actual fault bytes");
|
||||
assert_ne!(std::fs::read(&shard).expect("actual corrupt bytes"), original);
|
||||
let (healed, error) = HealOperations::heal_object(
|
||||
self.store.pools[0].disk_set[0].as_ref(),
|
||||
bucket,
|
||||
object,
|
||||
options.version_id.as_deref().expect("exact actual Copy version"),
|
||||
&HealOpts {
|
||||
scan_mode: HealScanMode::Deep,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("actual DeepHeal must accept historical native metadata on CopyObject");
|
||||
assert!(error.is_none(), "actual DeepHeal succeeds: {error:?}");
|
||||
assert!(
|
||||
matches!(healed.before.drives[slot].state.as_str(), "corrupt" | "missing"),
|
||||
"forced corruption must take the real repair path rather than a healthy no-op: {:?}",
|
||||
healed.before.drives[slot]
|
||||
);
|
||||
assert_eq!(healed.after.drives[slot].state, DriveState::Ok.to_string());
|
||||
self.drain().await;
|
||||
assert_eq!(
|
||||
std::fs::read(&shard).expect("actual rebuilt physical shard"),
|
||||
original,
|
||||
"DeepHeal reconstructed the exact original physical slot, not a donor shard"
|
||||
);
|
||||
assert!(
|
||||
!local_path(&self.disks[slot], RUSTFS_META_MULTIPART_BUCKET, &self.publication.lineage.path).exists(),
|
||||
"ordinary Copy healing cannot recreate the consumed native source"
|
||||
);
|
||||
}
|
||||
|
||||
async fn body_is_exact(&self, bucket: &str, object: &str, options: &ObjectOptions) {
|
||||
let mut reader = self
|
||||
.store
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), options)
|
||||
.await
|
||||
.expect("actual version/body after Copy and DeepHeal");
|
||||
let expected_version = options
|
||||
.version_id
|
||||
.as_deref()
|
||||
.map(|value| Uuid::parse_str(value).expect("fixture version"));
|
||||
assert_eq!(reader.object_info.version_id, expected_version);
|
||||
let mut actual = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("consume actual healed object body");
|
||||
assert_eq!(actual, self.body);
|
||||
}
|
||||
|
||||
async fn versions_are_exact(&self, slot: usize, bucket: &str, object: &str, expected: &[Uuid]) {
|
||||
let raw = self.disks[slot]
|
||||
.read_all(bucket, &format!("{object}/xl.meta"))
|
||||
.await
|
||||
.expect("actual physical version stack after repair");
|
||||
let metadata = FileMeta::load(&raw).expect("valid repaired FileMeta inventory");
|
||||
let mut versions = metadata
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| version.header.version_id.expect("actual concrete version slot"))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort_unstable();
|
||||
let mut expected = expected.to_vec();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(versions, expected, "repair preserves each actual version exactly once");
|
||||
}
|
||||
|
||||
fn projected_copy_request(&self, fi: &FileInfo, bucket: &str, object: &str) -> PublicationIdentity {
|
||||
// This is an untrusted request derived from the actual Copy FI,
|
||||
// never a replacement FI or a record written to any disk.
|
||||
let mut lineage = self.publication.lineage.clone();
|
||||
if lineage.bucket != bucket || lineage.object != object {
|
||||
lineage.bucket = bucket.to_owned();
|
||||
lineage.object = object.to_owned();
|
||||
let hash = hex_simd::encode_to_string(
|
||||
Sha256::digest(format!("{bucket}/{object}").as_bytes()),
|
||||
hex_simd::AsciiCase::Lower,
|
||||
);
|
||||
lineage.path = format!(
|
||||
"{hash}/{}",
|
||||
self.publication
|
||||
.lineage
|
||||
.path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.expect("actual upload component")
|
||||
);
|
||||
}
|
||||
let condition = SourceConditionV1 {
|
||||
lineage,
|
||||
generation: self.publication.source_generation,
|
||||
attempt: self.publication.attempt,
|
||||
};
|
||||
condition
|
||||
.attach(&mut fi.clone())
|
||||
.expect("well-formed request for the actual Copy identity")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn native_multipart_copy_heal_version_copy_preserves_original_producer_anchor() {
|
||||
run_large_stack_async_test("multipart-copy-heal-shared-dd", || async {
|
||||
let fixture = CopyHealFixture::new("native-multipart-copy-heal-shared-dd").await;
|
||||
let d = fixture.version_copy().await;
|
||||
fixture
|
||||
.force_deep_heal(0, &fixture.bucket, &fixture.object, &d, fixture.publication.final_data_dir)
|
||||
.await;
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.anchor_path(0)).expect("original owned anchor after actual D repair"),
|
||||
fixture.anchors[0]
|
||||
);
|
||||
fixture
|
||||
.confirm(0, &fixture.publication)
|
||||
.await
|
||||
.expect("exact C remains a strict own-producer vote");
|
||||
let current_d = fixture.disks[0]
|
||||
.read_version(
|
||||
"",
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
d.version_id.as_deref().expect("D"),
|
||||
&ReadOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual D after real heal");
|
||||
let d_request = fixture.projected_copy_request(¤t_d, &fixture.bucket, &fixture.object);
|
||||
assert_ne!(d_request, fixture.publication);
|
||||
assert!(
|
||||
fixture.confirm(0, &d_request).await.is_err(),
|
||||
"historical C metadata cannot become fresh D authority"
|
||||
);
|
||||
fixture
|
||||
.body_is_exact(&fixture.bucket, &fixture.object, &fixture.original_options)
|
||||
.await;
|
||||
fixture
|
||||
.versions_are_exact(
|
||||
0,
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
&[
|
||||
fixture.publication.version_id.expect("actual native C version"),
|
||||
Uuid::parse_str(d.version_id.as_deref().expect("actual D version")).expect("D UUID"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
fixture.body_is_exact(&fixture.bucket, &fixture.object, &d).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn native_multipart_copy_heal_preserves_own_anchor_while_original_metadata_is_missing() {
|
||||
run_large_stack_async_test("multipart-copy-heal-missing-original", || async {
|
||||
for replace_metadata in [false, true] {
|
||||
let fixture = CopyHealFixture::new("native-multipart-copy-heal-missing-original").await;
|
||||
let d = fixture.version_copy_with_replacement(replace_metadata).await;
|
||||
let c_version = fixture.original_options.version_id.as_deref().expect("actual C version");
|
||||
let c = fixture.disks[0]
|
||||
.read_version("", &fixture.bucket, &fixture.object, c_version, &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual C before one-disk metadata loss");
|
||||
fixture.disks[0]
|
||||
.delete_version(&fixture.bucket, &fixture.object, c, false, DeleteOptions::default())
|
||||
.await
|
||||
.expect("remove only this participant's actual C version; D still references the DD");
|
||||
assert!(matches!(
|
||||
fixture.disks[0]
|
||||
.read_version("", &fixture.bucket, &fixture.object, c_version, &ReadOptions::default())
|
||||
.await,
|
||||
Err(DiskError::FileNotFound | DiskError::FileVersionNotFound)
|
||||
));
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.anchor_path(0)).expect("shared D reference retains actual anchor"),
|
||||
fixture.anchors[0]
|
||||
);
|
||||
fixture
|
||||
.force_deep_heal(0, &fixture.bucket, &fixture.object, &d, fixture.publication.final_data_dir)
|
||||
.await;
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.anchor_path(0)).expect("D repair keeps the original record byte-for-byte"),
|
||||
fixture.anchors[0]
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
fixture.disks[0]
|
||||
.read_version("", &fixture.bucket, &fixture.object, c_version, &ReadOptions::default())
|
||||
.await,
|
||||
Err(DiskError::FileNotFound | DiskError::FileVersionNotFound)
|
||||
),
|
||||
"D heal cannot synthesize C metadata"
|
||||
);
|
||||
assert!(
|
||||
fixture.confirm(0, &fixture.publication).await.is_err(),
|
||||
"retained record alone cannot vote while exact C metadata is absent"
|
||||
);
|
||||
let current_d = fixture.disks[0]
|
||||
.read_version(
|
||||
"",
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
d.version_id.as_deref().expect("D"),
|
||||
&ReadOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual D while C is absent");
|
||||
assert_eq!(
|
||||
publication_identity(¤t_d).expect("actual D metadata after repair"),
|
||||
if replace_metadata {
|
||||
None
|
||||
} else {
|
||||
Some(fixture.publication.clone())
|
||||
},
|
||||
"heal preserves the real Copy metadata and does not mint a publication tuple"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.confirm(0, &fixture.projected_copy_request(¤t_d, &fixture.bucket, &fixture.object))
|
||||
.await
|
||||
.is_err(),
|
||||
"actual D never inherits C's producer authority"
|
||||
);
|
||||
let (healed, error) = HealOperations::heal_object(
|
||||
fixture.store.pools[0].disk_set[0].as_ref(),
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
c_version,
|
||||
&HealOpts {
|
||||
scan_mode: HealScanMode::Deep,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("legitimate C heal from actual original quorum");
|
||||
assert!(error.is_none(), "actual C metadata repair succeeds: {error:?}");
|
||||
assert_ne!(
|
||||
healed.before.drives[0].state,
|
||||
DriveState::Ok.to_string(),
|
||||
"C is actually missing on the repaired participant"
|
||||
);
|
||||
assert_eq!(healed.after.drives[0].state, DriveState::Ok.to_string());
|
||||
fixture.drain().await;
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.anchor_path(0)).expect("original record survives legitimate C repair"),
|
||||
fixture.anchors[0]
|
||||
);
|
||||
fixture
|
||||
.confirm(0, &fixture.publication)
|
||||
.await
|
||||
.expect("restored exact C metadata revives only its original strict vote");
|
||||
fixture
|
||||
.body_is_exact(&fixture.bucket, &fixture.object, &fixture.original_options)
|
||||
.await;
|
||||
fixture
|
||||
.versions_are_exact(
|
||||
0,
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
&[
|
||||
fixture.publication.version_id.expect("actual native C version"),
|
||||
Uuid::parse_str(d.version_id.as_deref().expect("actual D version")).expect("D UUID"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
fixture.body_is_exact(&fixture.bucket, &fixture.object, &d).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn native_multipart_copy_heal_full_copy_repairs_new_scope_without_minting_an_anchor() {
|
||||
run_large_stack_async_test("multipart-copy-heal-materialized", || async {
|
||||
let fixture = CopyHealFixture::new("native-multipart-copy-heal-materialized").await;
|
||||
let bucket = format!("copy-heal-destination-{}", Uuid::new_v4());
|
||||
let object = "materialized-copy-in-a-new-key";
|
||||
fixture
|
||||
.store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("real distinct destination bucket");
|
||||
let reader = fixture
|
||||
.store
|
||||
.get_object_reader(&fixture.bucket, &fixture.object, None, HeaderMap::new(), &fixture.original_options)
|
||||
.await
|
||||
.expect("actual source stream for full CopyObject");
|
||||
let mut source = reader.object_info.clone();
|
||||
let size = source.get_actual_size().expect("actual full Copy body length");
|
||||
source.put_object_reader = Some(PutObjReader::new(
|
||||
rustfs_rio::HashReader::from_stream(reader.stream, size, size, None, None, false)
|
||||
.expect("actual materialized Copy reader"),
|
||||
));
|
||||
let d = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::new_v4().to_string()),
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
fixture
|
||||
.store
|
||||
.copy_object(
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
&bucket,
|
||||
object,
|
||||
&mut source,
|
||||
&fixture.original_options,
|
||||
&d,
|
||||
)
|
||||
.await
|
||||
.expect("actual full stream CopyObject across bucket and key");
|
||||
drop(source);
|
||||
fixture.drain().await;
|
||||
let actual = fixture.disks[0]
|
||||
.read_version(
|
||||
"",
|
||||
&bucket,
|
||||
object,
|
||||
d.version_id.as_deref().expect("actual full Copy version"),
|
||||
&ReadOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual materialized Copy FI");
|
||||
let data_dir = actual.data_dir.expect("actual full Copy DD");
|
||||
assert_ne!(data_dir, fixture.publication.final_data_dir);
|
||||
assert_ne!(actual.version_id, fixture.publication.version_id);
|
||||
assert_eq!(
|
||||
publication_identity(&actual).expect("inherited historical tuple"),
|
||||
Some(fixture.publication.clone())
|
||||
);
|
||||
for disk in &fixture.disks {
|
||||
assert!(
|
||||
!local_path(disk, &bucket, &format!("{object}/{data_dir}/{ANCHOR}")).exists(),
|
||||
"ordinary full Copy never imports native provenance"
|
||||
);
|
||||
}
|
||||
fixture.force_deep_heal(0, &bucket, object, &d, data_dir).await;
|
||||
assert!(
|
||||
!local_path(&fixture.disks[0], &bucket, &format!("{object}/{data_dir}/{ANCHOR}")).exists(),
|
||||
"actual full-Copy DeepHeal cannot mint or rebind an anchor"
|
||||
);
|
||||
let after = fixture.disks[0]
|
||||
.read_version("", &bucket, object, d.version_id.as_deref().expect("D"), &ReadOptions::default())
|
||||
.await
|
||||
.expect("actual healed full Copy");
|
||||
assert!(
|
||||
fixture
|
||||
.confirm(0, &fixture.projected_copy_request(&after, &bucket, object))
|
||||
.await
|
||||
.is_err(),
|
||||
"historical tuple cannot confirm the new target scope"
|
||||
);
|
||||
fixture
|
||||
.confirm(0, &fixture.publication)
|
||||
.await
|
||||
.expect("original C remains independent native provenance");
|
||||
fixture
|
||||
.versions_are_exact(
|
||||
0,
|
||||
&bucket,
|
||||
object,
|
||||
&[Uuid::parse_str(d.version_id.as_deref().expect("actual full Copy version")).expect("D UUID")],
|
||||
)
|
||||
.await;
|
||||
fixture.body_is_exact(&bucket, object, &d).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn stage_actual_tmp_dd(fixture: &CopyHealFixture, fi: &FileInfo, include_anchor: bool) -> String {
|
||||
let path = format!("copy-heal-negative-{}", Uuid::new_v4());
|
||||
let data_dir = fi.data_dir.expect("actual selected DD");
|
||||
let source = local_path(&fixture.disks[0], &fixture.bucket, &format!("{}/{data_dir}", fixture.object));
|
||||
let target = local_path(&fixture.disks[0], RUSTFS_META_TMP_BUCKET, &format!("{path}/{data_dir}"));
|
||||
std::fs::create_dir_all(&target).expect("actual ordinary TMP repair directory");
|
||||
for entry in std::fs::read_dir(&source).expect("actual native shard, sidecar and proof inventory") {
|
||||
let entry = entry.expect("native DD entry");
|
||||
if entry.file_name() == ANCHOR && !include_anchor {
|
||||
continue;
|
||||
}
|
||||
assert!(entry.file_type().expect("native regular-file type").is_file());
|
||||
std::fs::copy(entry.path(), target.join(entry.file_name())).expect("real immutable repair input bytes");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn native_multipart_copy_heal_rejects_malformed_aliases_and_reserved_incoming_record_before_mutation() {
|
||||
run_large_stack_async_test("multipart-copy-heal-negative-input", || async {
|
||||
let fixture = CopyHealFixture::new("native-multipart-copy-heal-negative-input").await;
|
||||
let d = fixture.version_copy().await;
|
||||
let actual = fixture.disks[0]
|
||||
.read_version(
|
||||
"",
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
d.version_id.as_deref().expect("D"),
|
||||
&ReadOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("real Copy FI for malformed input probes");
|
||||
let metadata_path = local_path(&fixture.disks[0], &fixture.bucket, &format!("{}/xl.meta", fixture.object));
|
||||
let metadata = std::fs::read(&metadata_path).expect("actual C+D stack before rejected inputs");
|
||||
let shard_path = local_path(
|
||||
&fixture.disks[0],
|
||||
&fixture.bucket,
|
||||
&format!("{}/{}/part.1", fixture.object, fixture.publication.final_data_dir),
|
||||
);
|
||||
let shard = std::fs::read(&shard_path).expect("actual shared shard before rejected inputs");
|
||||
for case in [
|
||||
"short-distribution",
|
||||
"overflow-geometry",
|
||||
"alias-conflict",
|
||||
"incoming-reserved",
|
||||
] {
|
||||
let mut incoming = actual.clone();
|
||||
incoming.set_healing();
|
||||
let keys = incoming
|
||||
.metadata
|
||||
.keys()
|
||||
.filter(|key| is_publication_metadata_key(key))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(keys.len(), 2, "actual producer writes both native internal metadata aliases");
|
||||
let mut malformed = fixture.publication.clone();
|
||||
match case {
|
||||
"short-distribution" => malformed.lineage.geometry.distribution.clear(),
|
||||
"overflow-geometry" => {
|
||||
malformed.lineage.geometry.data_blocks = usize::MAX;
|
||||
malformed.lineage.geometry.parity_blocks = 1;
|
||||
}
|
||||
"alias-conflict" => malformed.attempt = Uuid::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
if case != "incoming-reserved" {
|
||||
let encoded = hex_simd::encode_to_string(
|
||||
rmp_serde::to_vec_named(&malformed).expect("untrusted malformed typed tuple"),
|
||||
hex_simd::AsciiCase::Lower,
|
||||
);
|
||||
for key in keys.iter().take(if case == "alias-conflict" { 1 } else { keys.len() }) {
|
||||
incoming.metadata.insert(key.clone(), encoded.clone());
|
||||
}
|
||||
}
|
||||
let tmp = stage_actual_tmp_dd(&fixture, &actual, case == "incoming-reserved");
|
||||
let result = fixture.disks[0]
|
||||
.rename_data(RUSTFS_META_TMP_BUCKET, &tmp, incoming, &fixture.bucket, &fixture.object)
|
||||
.await;
|
||||
let error: DiskError = result.expect_err(
|
||||
"malformed geometry, alias conflict or reserved incoming record is a typed failure, never a panic",
|
||||
);
|
||||
if case == "incoming-reserved" {
|
||||
assert!(
|
||||
matches!(&error, DiskError::Io(io) if io.kind() == std::io::ErrorKind::PermissionDenied)
|
||||
|| matches!(error, DiskError::FileAccessDenied | DiskError::DiskAccessDenied),
|
||||
"reserved incoming native record is an access rejection: {error:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(error, DiskError::FileCorrupt | DiskError::ConditionalDeleteConflict),
|
||||
"malformed typed metadata must not become an executor panic or generic I/O error: {error:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(&metadata_path).expect("target after rejected ordinary TMP input"), metadata);
|
||||
assert_eq!(std::fs::read(&shard_path).expect("shared shard after rejected input"), shard);
|
||||
assert_eq!(
|
||||
std::fs::read(fixture.anchor_path(0)).expect("original anchor after rejected input"),
|
||||
fixture.anchors[0]
|
||||
);
|
||||
}
|
||||
fixture
|
||||
.confirm(0, &fixture.publication)
|
||||
.await
|
||||
.expect("invalid repair input never damages the original strict vote");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn native_multipart_copy_heal_never_rebinds_foreign_disk_or_scope_records() {
|
||||
run_large_stack_async_test("multipart-copy-heal-foreign-records", || async {
|
||||
for foreign in ["disk", "scope"] {
|
||||
let fixture = CopyHealFixture::new("native-multipart-copy-heal-foreign-records").await;
|
||||
let d = fixture.version_copy().await;
|
||||
let raw = if foreign == "disk" {
|
||||
fixture.anchors[1].clone()
|
||||
} else {
|
||||
let mut anchor: AnchorBytes =
|
||||
rmp_serde::from_slice(&fixture.anchors[0]).expect("actual native anchor before scope corruption");
|
||||
anchor.publication.lineage.object.push_str("-foreign-scope");
|
||||
rmp_serde::to_vec_named(&anchor).expect("untrusted wrong-scope record")
|
||||
};
|
||||
std::fs::write(fixture.anchor_path(0), &raw).expect("actual foreign-record fault in this physical DD");
|
||||
fixture
|
||||
.force_deep_heal(0, &fixture.bucket, &fixture.object, &d, fixture.publication.final_data_dir)
|
||||
.await;
|
||||
assert!(
|
||||
!fixture.anchor_path(0).exists(),
|
||||
"ordinary heal never retains or rebinds foreign-disk or foreign-key provenance"
|
||||
);
|
||||
assert!(
|
||||
fixture.confirm(0, &fixture.publication).await.is_err(),
|
||||
"the repaired donor cannot become an original producer"
|
||||
);
|
||||
let actual = fixture.disks[0]
|
||||
.read_version(
|
||||
"",
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
d.version_id.as_deref().expect("D"),
|
||||
&ReadOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual repaired D with no native origin");
|
||||
assert!(
|
||||
fixture
|
||||
.confirm(0, &fixture.projected_copy_request(&actual, &fixture.bucket, &fixture.object))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
fixture
|
||||
.versions_are_exact(
|
||||
0,
|
||||
&fixture.bucket,
|
||||
&fixture.object,
|
||||
&[
|
||||
fixture.publication.version_id.expect("actual native C version"),
|
||||
Uuid::parse_str(d.version_id.as_deref().expect("actual D version")).expect("D UUID"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
fixture.body_is_exact(&fixture.bucket, &fixture.object, &d).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ use crate::disk::error_reduce::{count_errs, reduce_write_quorum_errs};
|
||||
use crate::disk::{self, DiskAPI};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::runtime::sources::{quarantine_local_disks, reconcile_local_disk_ids};
|
||||
use crate::runtime::sources::{
|
||||
quarantine_local_disks, quarantine_local_disks_preserving_bootstrap_routes, reconcile_local_disk_ids,
|
||||
};
|
||||
use crate::{
|
||||
disk::{
|
||||
DiskInfoOptions, DiskOption, DiskStore, FORMAT_CONFIG_FILE, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET,
|
||||
@@ -96,7 +98,7 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
set_drive_count: usize,
|
||||
deployment_id: Option<Uuid>,
|
||||
) -> Result<LoadedFormat> {
|
||||
let (formats, errs) = load_format_erasure_all(disks, false).await;
|
||||
let (formats, errs, unformatted_bootstrap_routes) = load_format_erasure_all_observing_bootstrap_routes(disks, false).await;
|
||||
|
||||
check_disk_fatal_errs(&errs)?;
|
||||
|
||||
@@ -137,7 +139,15 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
if matches!(format_quorum, Some(Err(Error::ErasureReadQuorum)))
|
||||
&& let Some((format, members)) = select_existing_format_read_quorum(&formats, &errs, set_drive_count)
|
||||
{
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &members, set_drive_count).await?;
|
||||
retain_format_quorum_members(
|
||||
instance_ctx,
|
||||
disks,
|
||||
&format,
|
||||
&members,
|
||||
Some(&unformatted_bootstrap_routes),
|
||||
set_drive_count,
|
||||
)
|
||||
.await?;
|
||||
// Loading an existing format grants no authority to initialize pool
|
||||
// metadata or migrate formats. The persisted storage classes and each
|
||||
// object's EC metadata still determine service and operation quorums.
|
||||
@@ -158,7 +168,7 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
match try_migrate_format(disks, &formats, set_count, set_drive_count).await {
|
||||
Ok(LegacyFormatOutcome::Migrated { format, quorum_members }) => {
|
||||
info!("Migrated format from MinIO config");
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, set_drive_count).await?;
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, None, set_drive_count).await?;
|
||||
return Ok(LoadedFormat {
|
||||
format: *format,
|
||||
pool_meta_bootstrap_authority: PoolMetaBootstrapAuthority::LegacyAdoption,
|
||||
@@ -206,7 +216,15 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
None => select_format_erasure_in_quorum(&formats, 0)?,
|
||||
};
|
||||
check_format_erasure_value_for_topology(&fm, formats.len(), set_drive_count)?;
|
||||
retain_format_quorum_members(instance_ctx, disks, &fm, &quorum_members, set_drive_count).await?;
|
||||
retain_format_quorum_members(
|
||||
instance_ctx,
|
||||
disks,
|
||||
&fm,
|
||||
&quorum_members,
|
||||
Some(&unformatted_bootstrap_routes),
|
||||
set_drive_count,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(LoadedFormat {
|
||||
format: fm,
|
||||
@@ -238,9 +256,13 @@ async fn retain_format_quorum_members(
|
||||
disks: &mut [Option<DiskStore>],
|
||||
format: &FormatV3,
|
||||
quorum_members: &[bool],
|
||||
unformatted_bootstrap_routes: Option<&[bool]>,
|
||||
set_drive_count: usize,
|
||||
) -> Result<()> {
|
||||
if set_drive_count == 0 || quorum_members.len() != disks.len() {
|
||||
if set_drive_count == 0
|
||||
|| quorum_members.len() != disks.len()
|
||||
|| unformatted_bootstrap_routes.is_some_and(|routes| routes.len() != disks.len())
|
||||
{
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
|
||||
@@ -279,11 +301,18 @@ async fn retain_format_quorum_members(
|
||||
let mut local_pool_endpoints = Vec::new();
|
||||
let mut selected_local_disk_ids = Vec::new();
|
||||
let mut quarantined_endpoints = Vec::new();
|
||||
let mut bootstrap_routes = Vec::new();
|
||||
for (index, ((disk, endpoint), belongs_to_quorum)) in disks.iter().zip(&endpoints).zip(quorum_members).enumerate() {
|
||||
if let Some(endpoint) = endpoint.as_ref().filter(|endpoint| endpoint.is_local) {
|
||||
local_pool_endpoints.push(endpoint.to_string());
|
||||
if !belongs_to_quorum {
|
||||
quarantined_endpoints.push(endpoint.clone());
|
||||
// An elected formatter may still be publishing this disk.
|
||||
// Preserve only its explicitly unformatted bootstrap path;
|
||||
// every service and voting slot remains quarantined.
|
||||
if unformatted_bootstrap_routes.is_some_and(|routes| routes[index]) {
|
||||
bootstrap_routes.push(endpoint.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !belongs_to_quorum {
|
||||
@@ -302,7 +331,11 @@ async fn retain_format_quorum_members(
|
||||
selected_local_disk_ids.push((disk_id, disk.endpoint().to_string()));
|
||||
}
|
||||
}
|
||||
quarantine_local_disks(instance_ctx, &quarantined_endpoints).await?;
|
||||
if bootstrap_routes.is_empty() {
|
||||
quarantine_local_disks(instance_ctx, &quarantined_endpoints).await?;
|
||||
} else {
|
||||
quarantine_local_disks_preserving_bootstrap_routes(instance_ctx, &quarantined_endpoints, &bootstrap_routes).await?;
|
||||
}
|
||||
|
||||
for (disk, disk_id) in disks.iter().zip(member_disk_ids) {
|
||||
if let Some(disk) = disk {
|
||||
@@ -369,7 +402,7 @@ async fn init_format_erasure(
|
||||
write_format_file_all(disks, &fms).await?;
|
||||
let format = get_format_erasure_in_quorum(&fms, 0)?;
|
||||
let quorum_members = vec![true; disks.len()];
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, set_drive_count).await?;
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, None, set_drive_count).await?;
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
@@ -406,38 +439,44 @@ async fn try_migrate_format(
|
||||
let mut read_error: Option<Error> = None;
|
||||
let mut legacy_formats = vec![None; disks.len()];
|
||||
|
||||
let reads = stream::iter(disks.iter().enumerate().map(|(index, disk)| async move {
|
||||
let Some(disk) = disk else {
|
||||
return (index, None);
|
||||
};
|
||||
let reader = match disk.read_file(MIGRATING_META_BUCKET, FORMAT_CONFIG_FILE).await {
|
||||
Ok(reader) => reader,
|
||||
Err(DiskError::FileNotFound | DiskError::VolumeNotFound) => return (index, None),
|
||||
Err(err) => return (index, Some(Err(err))),
|
||||
};
|
||||
let data = match read_legacy_format_bytes(reader, legacy_format_max_bytes).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => return (index, Some(Err(err))),
|
||||
};
|
||||
if data.is_empty() {
|
||||
return (index, Some(Ok(None)));
|
||||
}
|
||||
let reads = stream::iter(
|
||||
disks
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.map(|(index, disk): (usize, Option<DiskStore>)| async move {
|
||||
let Some(disk) = disk else {
|
||||
return (index, None);
|
||||
};
|
||||
let reader = match disk.read_file(MIGRATING_META_BUCKET, FORMAT_CONFIG_FILE).await {
|
||||
Ok(reader) => reader,
|
||||
Err(DiskError::FileNotFound | DiskError::VolumeNotFound) => return (index, None),
|
||||
Err(err) => return (index, Some(Err(err))),
|
||||
};
|
||||
let data = match read_legacy_format_bytes(reader, legacy_format_max_bytes).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => return (index, Some(Err(err))),
|
||||
};
|
||||
if data.is_empty() {
|
||||
return (index, Some(Ok(None)));
|
||||
}
|
||||
|
||||
match FormatV3::try_from(data.as_slice()) {
|
||||
Ok(format) => (index, Some(Ok(Some(format)))),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = "legacy_format_decode_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "store_init",
|
||||
disk_index = index,
|
||||
error = %err,
|
||||
"failed to decode legacy storage format"
|
||||
);
|
||||
(index, Some(Ok(None)))
|
||||
}
|
||||
}
|
||||
}))
|
||||
match FormatV3::try_from(data.as_slice()) {
|
||||
Ok(format) => (index, Some(Ok(Some(format)))),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = "legacy_format_decode_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "store_init",
|
||||
disk_index = index,
|
||||
error = %err,
|
||||
"failed to decode legacy storage format"
|
||||
);
|
||||
(index, Some(Ok(None)))
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.buffer_unordered(LEGACY_FORMAT_READ_CONCURRENCY);
|
||||
tokio::pin!(reads);
|
||||
while let Some((index, read)) = reads.next().await {
|
||||
@@ -795,22 +834,34 @@ fn validate_format_erasure_layout(format: &FormatV3) -> Result<usize> {
|
||||
|
||||
// load_format_erasure_all reads all format.json files
|
||||
pub async fn load_format_erasure_all(disks: &[Option<DiskStore>], heal: bool) -> (Vec<Option<FormatV3>>, Vec<Option<DiskError>>) {
|
||||
let (formats, errors, _) = load_format_erasure_all_observing_bootstrap_routes(disks, heal).await;
|
||||
(formats, errors)
|
||||
}
|
||||
|
||||
async fn load_format_erasure_all_observing_bootstrap_routes(
|
||||
disks: &[Option<DiskStore>],
|
||||
heal: bool,
|
||||
) -> (Vec<Option<FormatV3>>, Vec<Option<DiskError>>, Vec<bool>) {
|
||||
let mut futures = Vec::with_capacity(disks.len());
|
||||
let mut datas = Vec::with_capacity(disks.len());
|
||||
let mut errors = Vec::with_capacity(disks.len());
|
||||
|
||||
for disk in disks.iter() {
|
||||
futures.push(async move {
|
||||
if let Some(disk) = disk {
|
||||
load_format_erasure(disk, heal).await
|
||||
let mut unformatted_bootstrap_route = false;
|
||||
let result = if let Some(disk) = disk {
|
||||
load_format_erasure_observing_bootstrap_route(disk, heal, &mut unformatted_bootstrap_route).await
|
||||
} else {
|
||||
Err(DiskError::DiskNotFound)
|
||||
}
|
||||
};
|
||||
(result, unformatted_bootstrap_route)
|
||||
});
|
||||
}
|
||||
|
||||
let results = join_all(futures).await;
|
||||
for result in results {
|
||||
let mut unformatted_bootstrap_routes = Vec::with_capacity(disks.len());
|
||||
for (result, unformatted_bootstrap_route) in results {
|
||||
unformatted_bootstrap_routes.push(unformatted_bootstrap_route);
|
||||
match result {
|
||||
Ok(s) => {
|
||||
datas.push(Some(s));
|
||||
@@ -841,13 +892,28 @@ pub async fn load_format_erasure_all(disks: &[Option<DiskStore>], heal: bool) ->
|
||||
);
|
||||
}
|
||||
|
||||
(datas, errors)
|
||||
(datas, errors, unformatted_bootstrap_routes)
|
||||
}
|
||||
|
||||
pub async fn load_format_erasure(disk: &DiskStore, heal: bool) -> disk::error::Result<FormatV3> {
|
||||
let mut unformatted_bootstrap_route = false;
|
||||
load_format_erasure_observing_bootstrap_route(disk, heal, &mut unformatted_bootstrap_route).await
|
||||
}
|
||||
|
||||
async fn load_format_erasure_observing_bootstrap_route(
|
||||
disk: &DiskStore,
|
||||
heal: bool,
|
||||
unformatted_bootstrap_route: &mut bool,
|
||||
) -> disk::error::Result<FormatV3> {
|
||||
let data = disk
|
||||
.read_all(RUSTFS_META_BUCKET, FORMAT_CONFIG_FILE)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
// Do not use the normalized error below: DiskNotFound can mean a
|
||||
// stale UUID or unavailable routing, not an absent local format.
|
||||
*unformatted_bootstrap_route =
|
||||
disk.is_local() && matches!(error, DiskError::FileNotFound | DiskError::UnformattedDisk);
|
||||
})
|
||||
.map_err(|e| match e {
|
||||
DiskError::FileNotFound => DiskError::UnformattedDisk,
|
||||
DiskError::DiskNotFound => DiskError::UnformattedDisk,
|
||||
@@ -1800,6 +1866,226 @@ mod tests {
|
||||
assert!(disks[2].is_none(), "the malformed outlier must be isolated");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn format_observer_preserves_bootstrap_routes_until_the_real_creator_finishes() {
|
||||
use crate::disk::os::prepared_publication_test_hooks::{Stage, install_at};
|
||||
use std::time::Duration;
|
||||
|
||||
let (_root, disks) = local_disks_with_set_width(6, 3).await;
|
||||
let context = Arc::new(InstanceContext::new());
|
||||
let registered = disks.iter().flatten().cloned().collect::<Vec<_>>();
|
||||
record_local_disks(&context, registered).await;
|
||||
*context.local_disk_set_drives().write().await = vec![disks.chunks(3).map(|set| set.to_vec()).collect()];
|
||||
let mut hooks = Vec::new();
|
||||
let mut entered = Vec::new();
|
||||
let mut releases = Vec::new();
|
||||
for slot in [2, 5] {
|
||||
let destination = disks[slot]
|
||||
.as_ref()
|
||||
.expect("paused format disk exists")
|
||||
.get_object_path_for_io_if_local(RUSTFS_META_BUCKET, FORMAT_CONFIG_FILE)
|
||||
.expect("format publication uses a real local backend")
|
||||
.expect("format publication path");
|
||||
let (arrived, arrival) = tokio::sync::oneshot::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
hooks.push(install_at(Stage::Rename, &destination, move || {
|
||||
let _ = arrived.send(());
|
||||
let _ = released.recv();
|
||||
}));
|
||||
entered.push(arrival);
|
||||
releases.push(release);
|
||||
}
|
||||
let creator_context = context.clone();
|
||||
let mut creator_disks = disks.clone();
|
||||
let creator = tokio::spawn(async move {
|
||||
let result =
|
||||
connect_load_init_formats_with_instance_ctx(&creator_context, true, &mut creator_disks, 2, 3, None).await;
|
||||
(result, creator_disks)
|
||||
});
|
||||
for arrival in entered {
|
||||
tokio::time::timeout(Duration::from_secs(5), arrival)
|
||||
.await
|
||||
.expect("creator reaches each actual paused format rename")
|
||||
.expect("format rename reports its physical entry");
|
||||
}
|
||||
let partial = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let bytes = format_bytes(&disks).await;
|
||||
if bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(slot, bytes)| bytes.is_some() != matches!(slot, 2 | 5))
|
||||
{
|
||||
break bytes;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("other physical format publications reach two real copies per three-disk set");
|
||||
let mut observer_disks = disks.clone();
|
||||
let observer = connect_load_init_formats_with_instance_ctx(&context, false, &mut observer_disks, 2, 3, None)
|
||||
.await
|
||||
.expect("the observer loads each set at read quorum without waiting for its third format");
|
||||
assert_eq!(observer.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::None);
|
||||
assert!(observer_disks.chunks(3).all(|set| set.iter().flatten().count() == 2));
|
||||
assert!(
|
||||
observer_disks[2].is_none() && observer_disks[5].is_none(),
|
||||
"unformatted routes are never service votes"
|
||||
);
|
||||
{
|
||||
let slots = context.local_disk_set_drives();
|
||||
let slots = slots.read().await;
|
||||
assert!(
|
||||
slots[0][0][2].is_none() && slots[0][1][2].is_none(),
|
||||
"runtime voting slots must also exclude both unformatted paths"
|
||||
);
|
||||
}
|
||||
assert_eq!(format_bytes(&disks).await, partial, "the observer cannot publish or rewrite formats");
|
||||
{
|
||||
let routes = context.local_disk_map();
|
||||
let routes = routes.read().await;
|
||||
for slot in [2, 5] {
|
||||
let endpoint = disks[slot]
|
||||
.as_ref()
|
||||
.expect("original participant exists")
|
||||
.endpoint()
|
||||
.to_string();
|
||||
assert!(
|
||||
routes.get(&endpoint).is_some_and(Option::is_some),
|
||||
"the actual bootstrap RPC path must survive while its format publisher is in flight"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!creator.is_finished(),
|
||||
"the elected formatter still waits for both actual ALLN publications"
|
||||
);
|
||||
for release in releases {
|
||||
release.send(()).expect("release the actual format rename");
|
||||
}
|
||||
let (created, creator_disks) = tokio::time::timeout(Duration::from_secs(5), creator)
|
||||
.await
|
||||
.expect("the real formatter finishes after its physical publications are released")
|
||||
.expect("format creator joins");
|
||||
let created = created.expect("the original all-unformatted creator commits its complete format");
|
||||
assert_eq!(created.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::Fresh);
|
||||
let (formats, errors) = load_format_erasure_all(&creator_disks, false).await;
|
||||
assert!(errors.iter().all(Option::is_none), "all actual formats are readable: {errors:?}");
|
||||
assert!(formats_match_reference_slots(&formats, &created.format, 0));
|
||||
assert!(
|
||||
observer_disks[2].is_none() && observer_disks[5].is_none(),
|
||||
"bootstrap routing never promotes an observer's votes"
|
||||
);
|
||||
drop(hooks);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_format_keeps_only_explicitly_unformatted_local_bootstrap_routes() {
|
||||
for invalid_format_is_directory in [false, true] {
|
||||
let (_root, mut disks) = local_disks(8).await;
|
||||
let context = Arc::new(InstanceContext::new());
|
||||
let canonical = FormatV3::new(1, 8);
|
||||
for (slot, disk) in disks.iter().enumerate().take(5) {
|
||||
let mut format = canonical.clone();
|
||||
format.erasure.this = canonical.erasure.sets[0][slot];
|
||||
save_format_file(disk, &Some(format))
|
||||
.await
|
||||
.expect("actual canonical majority format");
|
||||
}
|
||||
let mut foreign = FormatV3::new(1, 8);
|
||||
foreign.erasure.this = foreign.erasure.sets[0][6];
|
||||
let foreign_id = foreign.erasure.this;
|
||||
save_format_file(&disks[6], &Some(foreign))
|
||||
.await
|
||||
.expect("actual foreign format");
|
||||
disks[6]
|
||||
.as_ref()
|
||||
.expect("foreign disk exists")
|
||||
.set_disk_id_state(Some(Uuid::new_v4()))
|
||||
.await
|
||||
.expect("model a stale wrapper UUID without changing the foreign format");
|
||||
let invalid_path = disks[7]
|
||||
.as_ref()
|
||||
.expect("invalid format participant exists")
|
||||
.path()
|
||||
.join(RUSTFS_META_BUCKET)
|
||||
.join(FORMAT_CONFIG_FILE);
|
||||
tokio::fs::create_dir_all(invalid_path.parent().expect("format parent"))
|
||||
.await
|
||||
.expect("invalid format's real metadata directory");
|
||||
if invalid_format_is_directory {
|
||||
tokio::fs::create_dir(&invalid_path)
|
||||
.await
|
||||
.expect("actual format read I/O failure");
|
||||
} else {
|
||||
tokio::fs::write(&invalid_path, b"invalid format JSON")
|
||||
.await
|
||||
.expect("actual corrupt format bytes");
|
||||
}
|
||||
let physical_disks = disks.clone();
|
||||
let before = format_bytes(&physical_disks[..7]).await;
|
||||
record_local_disks(&context, disks.iter().flatten().cloned().collect()).await;
|
||||
*context.local_disk_set_drives().write().await = vec![vec![disks.clone()]];
|
||||
let unformatted_endpoint = disks[5]
|
||||
.as_ref()
|
||||
.expect("unformatted participant exists")
|
||||
.endpoint()
|
||||
.to_string();
|
||||
let foreign_endpoint = disks[6].as_ref().expect("foreign participant exists").endpoint().to_string();
|
||||
let invalid_endpoint = disks[7].as_ref().expect("invalid participant exists").endpoint().to_string();
|
||||
context
|
||||
.local_disk_id_map()
|
||||
.write()
|
||||
.await
|
||||
.insert(foreign_id, foreign_endpoint.clone());
|
||||
let (_, errors, bootstrap_routes) = load_format_erasure_all_observing_bootstrap_routes(&disks, false).await;
|
||||
assert!(matches!(errors[5], Some(DiskError::UnformattedDisk)));
|
||||
assert!(
|
||||
matches!(errors[6], Some(DiskError::UnformattedDisk)),
|
||||
"the public error still normalizes stale DiskNotFound"
|
||||
);
|
||||
assert_eq!(bootstrap_routes, [false, false, false, false, false, true, false, false]);
|
||||
let loaded = connect_load_init_formats_with_instance_ctx(&context, false, &mut disks, 1, 8, None)
|
||||
.await
|
||||
.expect("a real formatted majority remains available");
|
||||
assert_eq!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::None);
|
||||
assert_eq!(disks.iter().flatten().count(), 5);
|
||||
assert!(disks[5..].iter().all(Option::is_none));
|
||||
assert!(
|
||||
context.local_disk_set_drives().read().await[0][0][5..]
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
let routes = context.local_disk_map();
|
||||
let routes = routes.read().await;
|
||||
assert!(routes.get(&unformatted_endpoint).is_some_and(Option::is_some));
|
||||
assert!(routes.get(&foreign_endpoint).is_some_and(Option::is_none));
|
||||
assert!(routes.get(&invalid_endpoint).is_some_and(Option::is_none));
|
||||
drop(routes);
|
||||
assert!(!context.local_disk_id_map().read().await.contains_key(&foreign_id));
|
||||
assert_eq!(
|
||||
format_bytes(&physical_disks[..7]).await,
|
||||
before,
|
||||
"route retention cannot rewrite any actual format, including a foreign stale-ID disk"
|
||||
);
|
||||
if invalid_format_is_directory {
|
||||
assert!(
|
||||
tokio::fs::metadata(&invalid_path)
|
||||
.await
|
||||
.expect("invalid format path remains")
|
||||
.is_dir()
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
tokio::fs::read(&invalid_path).await.expect("corrupt format remains"),
|
||||
b"invalid format JSON"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_format_load_initializes_all_disks() {
|
||||
let (_temp_dir, mut disks) = local_disks(3).await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ use super::*;
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::disk::STORAGE_FORMAT_FILE;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use bytes::Bytes;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::pin::Pin;
|
||||
@@ -153,9 +154,6 @@ impl Fixture {
|
||||
fixture.write_metadata(disk_index, object).await;
|
||||
}
|
||||
}
|
||||
if !fixture.objects.is_empty() {
|
||||
observe_list_objects_mutations(&fixture.store, bucket, fixture.objects.len()).await;
|
||||
}
|
||||
fixture
|
||||
}
|
||||
|
||||
@@ -236,50 +234,27 @@ impl Fixture {
|
||||
.expect("corpus xl.meta should reach the real local disk");
|
||||
}
|
||||
|
||||
async fn page(
|
||||
&self,
|
||||
layer: usize,
|
||||
prefix: &str,
|
||||
marker: Option<String>,
|
||||
delimiter: Option<&str>,
|
||||
max: i32,
|
||||
) -> ListObjectsInfo {
|
||||
let request = format!("corpus layer={layer}, prefix={prefix}, marker={marker:?}, delimiter={delimiter:?}, max={max}");
|
||||
async fn page(&self, prefix: &str, marker: Option<String>, delimiter: Option<&str>, max: i32) -> ListObjectsInfo {
|
||||
let request = format!("corpus prefix={prefix}, marker={marker:?}, delimiter={delimiter:?}, max={max}");
|
||||
let delimiter = delimiter.map(str::to_owned);
|
||||
timeout(Duration::from_secs(10), async {
|
||||
match layer {
|
||||
0 => {
|
||||
self.store
|
||||
.clone()
|
||||
.list_objects_generic(&self.bucket, prefix, marker, delimiter, max, false)
|
||||
.await
|
||||
}
|
||||
1 => {
|
||||
self.store.pools[0]
|
||||
.clone()
|
||||
.list_objects_generic(&self.bucket, prefix, marker, delimiter, max, false)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
self.store.pools[0].disk_set[0]
|
||||
.clone()
|
||||
.list_objects_generic(&self.bucket, prefix, marker, delimiter, max, false)
|
||||
.await
|
||||
}
|
||||
}
|
||||
self.store
|
||||
.clone()
|
||||
.list_objects_generic(&self.bucket, prefix, marker, delimiter, max, false)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{request}: page must finish: {error}"))
|
||||
.unwrap_or_else(|error| panic!("corpus layer={layer}, prefix={prefix}, max={max}: {error:?}"))
|
||||
.unwrap_or_else(|error| panic!("corpus prefix={prefix}, max={max}: {error:?}"))
|
||||
}
|
||||
|
||||
async fn assert_walk(&self, layer: usize, prefix: &str, delimiter: Option<&str>, start: Option<&str>, max: usize) {
|
||||
async fn assert_walk(&self, prefix: &str, delimiter: Option<&str>, start: Option<&str>, max: usize) {
|
||||
let mut marker = start.map(str::to_owned);
|
||||
let mut seen = Vec::new();
|
||||
for page_number in 0..=self.objects.len() {
|
||||
let expected = reference_page(&self.objects, prefix, delimiter, marker.as_deref().map(marker_key), max);
|
||||
let actual = self
|
||||
.page(layer, prefix, marker.clone(), delimiter, i32::try_from(max).expect("small corpus limit"))
|
||||
.page(prefix, marker.clone(), delimiter, i32::try_from(max).expect("small corpus limit"))
|
||||
.await;
|
||||
let observed = Page {
|
||||
objects: actual.objects.into_iter().map(|object| object.name).collect(),
|
||||
@@ -288,11 +263,8 @@ impl Fixture {
|
||||
truncated: actual.is_truncated,
|
||||
};
|
||||
assert_eq!(
|
||||
observed,
|
||||
expected,
|
||||
"layer={layer}, prefix={prefix}, delimiter={delimiter:?}, max={max}, page={page_number}, marker={marker:?}, mode={:?}, provider={:?}",
|
||||
std::env::var(ENV_API_LIST_OBJECTS_INDEX_MODE).ok(),
|
||||
std::env::var(ENV_API_LIST_OBJECTS_INDEX_PROVIDER).ok(),
|
||||
observed, expected,
|
||||
"prefix={prefix}, delimiter={delimiter:?}, max={max}, page={page_number}, marker={marker:?}",
|
||||
);
|
||||
seen.extend(observed.objects);
|
||||
seen.extend(observed.prefixes);
|
||||
@@ -348,12 +320,10 @@ async fn list_objects_shared_corpus_static_namespace() {
|
||||
.map(|entry| entry.name)
|
||||
.collect();
|
||||
assert_eq!(names, keys[..count], "raw walker should preserve byte order and __XLDIR__ decoding");
|
||||
for layer in 0..3 {
|
||||
for prefix in ["", "a", "a/", "中/", "missing/"] {
|
||||
for delimiter in [None, Some("/")] {
|
||||
for max in [0, 1, 3, 4] {
|
||||
fixture.assert_walk(layer, prefix, delimiter, None, max).await;
|
||||
}
|
||||
for prefix in ["", "a", "a/", "中/", "missing/"] {
|
||||
for delimiter in [None, Some("/")] {
|
||||
for max in [0, 1, 3, 4] {
|
||||
fixture.assert_walk(prefix, delimiter, None, max).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,10 +353,8 @@ async fn list_objects_shared_corpus_cross_subdirectory_limit() {
|
||||
"#7049: a recursive child that reaches the limit must stop its parent"
|
||||
);
|
||||
}
|
||||
for layer in 0..3 {
|
||||
for max in [1, 2, 3, 4] {
|
||||
fixture.assert_walk(layer, "", None, None, max).await;
|
||||
}
|
||||
for max in [1, 2, 3, 4] {
|
||||
fixture.assert_walk("", None, None, max).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,23 +364,7 @@ async fn list_objects_shared_corpus_max_and_max_plus_one() {
|
||||
for count in [max, max + 1] {
|
||||
let keys: Vec<_> = (0..count).map(|index| format!("bulk/key-{index:04}")).collect();
|
||||
let fixture = Fixture::new(&format!("list-corpus-max-{count}"), namespace(&keys), true).await;
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, "bulk/", None, None, max).await;
|
||||
}
|
||||
let index_dir = tempfile::tempdir().expect("maximum-boundary index fixture");
|
||||
let index_path = index_dir.path().join("maximum.idx");
|
||||
for mode in [ListSourceMode::IndexKeyOnly, ListSourceMode::IndexVerifiedPage] {
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(ENV_API_LIST_OBJECTS_INDEX_MODE, Some(mode.cursor_value())),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER, Some(LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY)),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_PATH, index_path.to_str()),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_GENERATION, Some("maximum-corpus")),
|
||||
],
|
||||
async { fixture.assert_walk(0, "bulk/", None, None, max).await },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
fixture.assert_walk("bulk/", None, None, max).await;
|
||||
let names: Vec<_> = fixture
|
||||
.disk_walk(WalkDirOptions {
|
||||
bucket: fixture.bucket.clone(),
|
||||
@@ -446,19 +398,15 @@ async fn list_objects_shared_corpus_fixed_seed_marker_and_prefix() {
|
||||
let marker = marker.filter(|marker| marker.starts_with(prefix));
|
||||
let delimiter = (seed & 1 == 0).then_some("/");
|
||||
let max = usize::try_from((seed >> 16) % 4 + 1).expect("small page size");
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, prefix, delimiter, marker, max).await;
|
||||
}
|
||||
fixture.assert_walk(prefix, delimiter, marker, max).await;
|
||||
}
|
||||
// A folded prefix can consume more raw candidates than the producer's
|
||||
// ordinary page budget before the next visible identity appears.
|
||||
let mut keys: Vec<_> = (0..12).map(|index| format!("data-{index:02}")).collect();
|
||||
keys.push("other-key".to_owned());
|
||||
let folded = Fixture::new("list-corpus-long-fold", namespace(&keys), true).await;
|
||||
for layer in 0..3 {
|
||||
for max in [1, 2] {
|
||||
folded.assert_walk(layer, "", Some("-"), None, max).await;
|
||||
}
|
||||
for max in [1, 2] {
|
||||
folded.assert_walk("", Some("-"), None, max).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +428,7 @@ async fn list_objects_shared_corpus_offline_corrupt_and_minority_stale() {
|
||||
.await
|
||||
.expect("fixture should retain exactly a write quorum of metadata copies");
|
||||
}
|
||||
fixture.assert_walk(0, "", None, None, 3).await;
|
||||
fixture.assert_walk("", None, None, 3).await;
|
||||
let offline_disk = set.disks.write().await[0]
|
||||
.take()
|
||||
.expect("a disk with a committed copy should exist before the offline fault");
|
||||
@@ -489,10 +437,8 @@ async fn list_objects_shared_corpus_offline_corrupt_and_minority_stale() {
|
||||
3,
|
||||
"offline fault must reach the real set"
|
||||
);
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, "", None, None, 3).await;
|
||||
fixture.assert_walk(layer, "", Some("/"), None, 3).await;
|
||||
}
|
||||
fixture.assert_walk("", None, None, 3).await;
|
||||
fixture.assert_walk("", Some("/"), None, 3).await;
|
||||
set.disks.write().await[0] = Some(offline_disk);
|
||||
for object in &fixture.objects {
|
||||
fixture.write_metadata(3, object).await;
|
||||
@@ -508,9 +454,7 @@ async fn list_objects_shared_corpus_offline_corrupt_and_minority_stale() {
|
||||
.await
|
||||
.expect("corrupt fault should reach disk bytes");
|
||||
assert!(FileMeta::load(&tokio::fs::read(&metadata_path).await.expect("fault bytes should be readable")).is_err());
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, "a", None, None, 1).await;
|
||||
}
|
||||
fixture.assert_walk("a", None, None, 1).await;
|
||||
fixture.write_metadata(3, object).await;
|
||||
|
||||
let mut stale = object.clone();
|
||||
@@ -521,9 +465,7 @@ async fn list_objects_shared_corpus_offline_corrupt_and_minority_stale() {
|
||||
versions: stale.versions.clone(),
|
||||
};
|
||||
fixture.write_metadata(3, &phantom).await;
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, "", None, None, 3).await;
|
||||
}
|
||||
fixture.assert_walk("", None, None, 3).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -698,91 +640,59 @@ async fn list_objects_shared_corpus_versions_delete_markers_and_free_versions()
|
||||
.map(|version| (object.key.clone(), version.id, version.kind == VersionKind::Delete))
|
||||
})
|
||||
.collect();
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, "", None, None, 1).await;
|
||||
fixture.assert_walk(layer, "", Some("/"), None, 1).await;
|
||||
for max in [0, 1, expected.len() - 1, expected.len(), expected.len() + 1] {
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let result = timeout(Duration::from_secs(10), async {
|
||||
match layer {
|
||||
0 => {
|
||||
fixture
|
||||
.store
|
||||
.clone()
|
||||
.inner_list_object_versions(
|
||||
&fixture.bucket,
|
||||
"",
|
||||
marker.clone(),
|
||||
version_marker.clone(),
|
||||
None,
|
||||
i32::try_from(max).expect("small limit"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
1 => {
|
||||
fixture.store.pools[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(
|
||||
&fixture.bucket,
|
||||
"",
|
||||
marker.clone(),
|
||||
version_marker.clone(),
|
||||
None,
|
||||
i32::try_from(max).expect("small limit"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
fixture.store.pools[0].disk_set[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(
|
||||
&fixture.bucket,
|
||||
"",
|
||||
marker.clone(),
|
||||
version_marker.clone(),
|
||||
None,
|
||||
i32::try_from(max).expect("small limit"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("version layer={layer}, max={max}, offset={offset}, marker={marker:?}, version_marker={version_marker:?}: page must finish: {error}")
|
||||
})
|
||||
.expect("real version page should list");
|
||||
let end = (offset + max).min(expected.len());
|
||||
let actual: Vec<_> = result
|
||||
.objects
|
||||
.into_iter()
|
||||
.map(|object| (object.name, object.version_id.filter(|id| !id.is_nil()), object.delete_marker))
|
||||
.collect();
|
||||
assert_eq!(actual, expected[offset..end], "version layer={layer}, max={max}, offset={offset}");
|
||||
assert!(result.prefixes.is_empty());
|
||||
let more = max > 0 && end < expected.len();
|
||||
assert_eq!(
|
||||
result.is_truncated, more,
|
||||
"version layer={layer}, max={max}, offset={offset}, marker={marker:?}, version_marker={version_marker:?}, returned={actual:?}, next_key={:?}, next_version={:?}: page needs exactly one lookahead",
|
||||
result.next_marker, result.next_version_idmarker,
|
||||
);
|
||||
if !more {
|
||||
assert!(result.next_marker.is_none() && result.next_version_idmarker.is_none());
|
||||
break;
|
||||
}
|
||||
let last = &expected[end - 1];
|
||||
assert_eq!(result.next_marker.as_deref().map(marker_key), Some(last.0.as_str()));
|
||||
assert_eq!(
|
||||
result.next_version_idmarker,
|
||||
Some(last.1.map_or_else(|| "null".to_owned(), |id| id.to_string()))
|
||||
);
|
||||
marker = result.next_marker;
|
||||
version_marker = result.next_version_idmarker;
|
||||
offset = end;
|
||||
fixture.assert_walk("", None, None, 1).await;
|
||||
fixture.assert_walk("", Some("/"), None, 1).await;
|
||||
for max in [0, 1, expected.len() - 1, expected.len(), expected.len() + 1] {
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let result = timeout(Duration::from_secs(10), async {
|
||||
fixture
|
||||
.store
|
||||
.clone()
|
||||
.inner_list_object_versions(
|
||||
&fixture.bucket,
|
||||
"",
|
||||
marker.clone(),
|
||||
version_marker.clone(),
|
||||
None,
|
||||
i32::try_from(max).expect("small limit"),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("version max={max}, offset={offset}, marker={marker:?}, version_marker={version_marker:?}: page must finish: {error}")
|
||||
})
|
||||
.expect("real version page should list");
|
||||
let end = (offset + max).min(expected.len());
|
||||
let actual: Vec<_> = result
|
||||
.objects
|
||||
.into_iter()
|
||||
.map(|object| (object.name, object.version_id.filter(|id| !id.is_nil()), object.delete_marker))
|
||||
.collect();
|
||||
assert_eq!(actual, expected[offset..end], "version max={max}, offset={offset}");
|
||||
assert!(result.prefixes.is_empty());
|
||||
let more = max > 0 && end < expected.len();
|
||||
assert_eq!(
|
||||
result.is_truncated, more,
|
||||
"version max={max}, offset={offset}, marker={marker:?}, version_marker={version_marker:?}, returned={actual:?}, next_key={:?}, next_version={:?}: page needs exactly one lookahead",
|
||||
result.next_marker, result.next_version_idmarker,
|
||||
);
|
||||
if !more {
|
||||
assert!(result.next_marker.is_none() && result.next_version_idmarker.is_none());
|
||||
break;
|
||||
}
|
||||
let last = &expected[end - 1];
|
||||
assert_eq!(result.next_marker.as_deref().map(marker_key), Some(last.0.as_str()));
|
||||
assert_eq!(
|
||||
result.next_version_idmarker,
|
||||
Some(last.1.map_or_else(|| "null".to_owned(), |id| id.to_string()))
|
||||
);
|
||||
marker = result.next_marker;
|
||||
version_marker = result.next_version_idmarker;
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
// A deleted directory-marker key cannot hide a different live child.
|
||||
@@ -796,10 +706,8 @@ async fn list_objects_shared_corpus_versions_delete_markers_and_free_versions()
|
||||
)
|
||||
.await
|
||||
.expect("delete-marker fixture should retain three committed copies");
|
||||
for layer in 0..3 {
|
||||
fixture.assert_walk(layer, "folder/", None, None, 1).await;
|
||||
fixture.assert_walk(layer, "", Some("/"), None, 1).await;
|
||||
}
|
||||
fixture.assert_walk("folder/", None, None, 1).await;
|
||||
fixture.assert_walk("", Some("/"), None, 1).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -819,7 +727,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
|
||||
.await
|
||||
.expect("orphan input must share an ancestor with a legal child");
|
||||
}
|
||||
let result = fixture.page(0, "ghost/", None, None, 1).await;
|
||||
let result = fixture.page("ghost/", None, None, 1).await;
|
||||
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
|
||||
for dir in &fixture.dirs {
|
||||
assert_eq!(
|
||||
@@ -836,7 +744,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
|
||||
"purge must preserve legal siblings"
|
||||
);
|
||||
}
|
||||
fixture.assert_walk(0, "legal/", None, None, 1).await;
|
||||
fixture.assert_walk("legal/", None, None, 1).await;
|
||||
for dir in &fixture.dirs {
|
||||
assert!(
|
||||
dir.path()
|
||||
@@ -860,7 +768,7 @@ async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_eviden
|
||||
.expect("authoritative fixture has a metadata system");
|
||||
let guard = metadata_sys.write().await;
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), fixture.page(0, "timed-out/", None, None, 1))
|
||||
timeout(Duration::from_millis(100), fixture.page("timed-out/", None, None, 1))
|
||||
.await
|
||||
.is_err(),
|
||||
"blocked authoritative evidence must time out before cleanup can classify the prefix as empty"
|
||||
@@ -993,137 +901,5 @@ async fn list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_d
|
||||
"all production callback/producer sender clones must be released"
|
||||
);
|
||||
assert!(set.disks.try_write().is_ok(), "listing cancellation must not retain the set's disk lock");
|
||||
fixture.assert_walk(0, "", None, None, 3).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_objects_shared_corpus_enabled_index_contracts() {
|
||||
let fixture = Fixture::new("list-corpus-index", namespace(&shared_keys()), true).await;
|
||||
let index_dir = tempfile::tempdir().expect("index directory should exist");
|
||||
let path = index_dir.path().join("namespace.idx");
|
||||
let path_value = path.to_str().expect("test index path should be UTF-8");
|
||||
for provider in [
|
||||
LIST_OBJECTS_INDEX_PROVIDER_WALKER_KEY_ONLY,
|
||||
LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY,
|
||||
] {
|
||||
for mode in [ListSourceMode::IndexKeyOnly, ListSourceMode::IndexVerifiedPage] {
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(ENV_API_LIST_OBJECTS_INDEX_MODE, Some(mode.cursor_value())),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER, Some(provider)),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_PATH, Some(path_value)),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_GENERATION, Some("shared-corpus")),
|
||||
],
|
||||
async {
|
||||
for prefix in ["", "a", "a/", "missing/"] {
|
||||
for delimiter in [None, Some("/")] {
|
||||
for max in [0, 1, 3, 4] {
|
||||
fixture.assert_walk(0, prefix, delimiter, None, max).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = fixture.page(0, "", None, None, 1).await;
|
||||
let mut parsed = ListPathOptions {
|
||||
marker: result.next_marker,
|
||||
..Default::default()
|
||||
};
|
||||
parsed.parse_marker();
|
||||
assert_eq!(parsed.cursor_source, Some(mode), "enabled adapter must actually serve the index path");
|
||||
assert_eq!(
|
||||
parsed.cursor_generation.as_deref(),
|
||||
Some(if provider == LIST_OBJECTS_INDEX_PROVIDER_WALKER_KEY_ONLY {
|
||||
LIST_CURSOR_GENERATION_LIVE
|
||||
} else {
|
||||
"shared-corpus"
|
||||
})
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(ENV_API_LIST_OBJECTS_INDEX_MODE, Some(ListSourceMode::IndexMetadataFast.cursor_value())),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER, Some(LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY)),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_PATH, Some(path_value)),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_GENERATION, Some("shared-corpus")),
|
||||
(ENV_API_LIST_OBJECTS_METADATA_FAST_ENABLED, Some("true")),
|
||||
(ENV_API_LIST_OBJECTS_METADATA_FAST_STALENESS_MS, Some("5000")),
|
||||
],
|
||||
async {
|
||||
fixture.assert_walk(0, "", None, None, 3).await;
|
||||
let first = fixture.page(0, "", None, None, 1).await;
|
||||
let mut cursor = ListPathOptions {
|
||||
marker: first.next_marker.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
cursor.parse_marker();
|
||||
assert_eq!(cursor.cursor_source, Some(ListSourceMode::IndexMetadataFast));
|
||||
assert_eq!(cursor.cursor_generation.as_deref(), Some("shared-corpus"));
|
||||
assert!(
|
||||
!ListSourceMode::IndexMetadataFast.can_satisfy_strong_listing(),
|
||||
"snapshot mode carries an eventual-consistency contract"
|
||||
);
|
||||
|
||||
let state = ListObjectsIndexProviderState::persistent_key_only(Some(path.clone()), Some("shared-corpus".to_owned()));
|
||||
let mut expired = ListPathOptions {
|
||||
bucket: fixture.bucket.clone(),
|
||||
limit: 2,
|
||||
marker: first.next_marker,
|
||||
..Default::default()
|
||||
};
|
||||
expired.parse_marker();
|
||||
expired.cursor_generation = Some("expired-generation".to_owned());
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.clone()
|
||||
.list_objects_from_metadata_fast_provider(&expired, &state, "persistent_key_only", 1, false)
|
||||
.await
|
||||
.expect("expired cursor must be handled")
|
||||
.is_none(),
|
||||
"expired generation must fall back to walker"
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(ENV_API_LIST_OBJECTS_INDEX_MODE, Some(ListSourceMode::IndexKeyOnly.cursor_value())),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER, Some(LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY)),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_PATH, index_dir.path().to_str()),
|
||||
(ENV_API_LIST_OBJECTS_INDEX_PROVIDER_GENERATION, Some("shared-corpus")),
|
||||
],
|
||||
async {
|
||||
let options = ListPathOptions {
|
||||
bucket: fixture.bucket.clone(),
|
||||
limit: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.clone()
|
||||
.list_objects_from_opt_in_key_only_provider(&options, ListSourceMode::IndexKeyOnly, 1, false)
|
||||
.await
|
||||
.expect("unreadable provider must return a fallback decision")
|
||||
.is_none(),
|
||||
"an index path that is a directory must fail the enabled provider"
|
||||
);
|
||||
fixture.assert_walk(0, "", None, None, 3).await;
|
||||
let result = fixture.page(0, "", None, None, 1).await;
|
||||
let mut cursor = ListPathOptions {
|
||||
marker: result.next_marker,
|
||||
..Default::default()
|
||||
};
|
||||
cursor.parse_marker();
|
||||
assert_ne!(
|
||||
cursor.cursor_source,
|
||||
Some(ListSourceMode::IndexKeyOnly),
|
||||
"degraded provider must not advertise an index cursor"
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
fixture.assert_walk("", None, None, 3).await;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::runtime::instance::InstanceContext;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::multipart::{CompletePart, MultipartOperations};
|
||||
use crate::storage_api_contracts::object::ObjectIO;
|
||||
use crate::storage_api_contracts::object::ObjectOperations as _;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user