Compare commits

..

11 Commits

Author SHA1 Message Date
overtrue 81038fc4e2 test(e2e): synchronize disk restore and heal log assertions 2026-09-09 13:27:18 +08:00
overtrue 6920abfe29 ci: retain full e2e recovery diagnostics 2026-09-09 12:31:04 +08:00
overtrue f9913bbe72 fix(build): refresh cached git provenance for server builds 2026-09-09 12:31:03 +08:00
overtrue d0a6260002 fix(tier): start config refresh after initial reload failure 2026-09-09 11:12:59 +08:00
overtrue 4b4360a1ca test(e2e): preserve disk ownership through volume proxy 2026-09-09 10:23:59 +08:00
overtrue 9ae11bd3b5 merge: integrate release recovery and evidence updates 2026-09-09 10:23:19 +08:00
overtrue 3e64697d03 merge: integrate main into release validation
# Conflicts:
#	.config/e2e-nightly-selection.txt
#	crates/e2e_test/src/inline_fast_path_cluster_test.rs
#	crates/ecstore/src/bucket/replication/replication_resyncer.rs
#	crates/kms/src/backends/local.rs
2026-09-08 20:52:53 +08:00
overtrue 9fa1d3f58f fix(heal): recover pool metadata during ordinary healing 2026-09-08 20:36:34 +08:00
overtrue c03d3cdd59 fix: close replacement and protocol validation gaps 2026-09-08 17:23:53 +08:00
overtrue 590fab5c7e test: stabilize release recovery and transition gates 2026-09-08 16:57:04 +08:00
overtrue c02967baf6 fix: address confirmed release validation regressions 2026-09-08 16:42:41 +08:00
161 changed files with 3163 additions and 14804 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
sha256-darwin=f0d15f2d1183be319d9d977d20b48bb05a29a424c266eed910c67aaa6f1ff955
sha256-linux=340aa702576ebed5266b7c47e591f267a11178fa04bf3828d64f53fe92eb0907
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
sha256-darwin=12d30fff5ed48fe95bbfb310dd507048f81782b2954e00eb72435708b1133f9c
sha256-linux=e917b2fdb303d01e6008ac4f1836c7b268698afe8383bd2aa4e697061df85cf6
-1
View File
@@ -89,7 +89,6 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
test-wiring-check: ## Check tests stay registered and selected by their intended runners
@echo "🧪 Checking test wiring..."
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
.PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
-3
View File
@@ -32,8 +32,6 @@ script-tests: ## Run shell script tests
./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh
./scripts/test_scanner_heal_w13_mrf_evidence.sh
./scripts/test_scanner_heal_w16_recovery_evidence.sh
./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh
@@ -41,7 +39,6 @@ script-tests: ## Run shell script tests
./scripts/test_python_bin.sh
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
+6 -47
View File
@@ -188,11 +188,7 @@
"lane": "mrf-responsibility",
"status": "pending",
"description": "Durable MRF responsibility at every commit boundary",
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"],
"evidence_fields": [
"mrf_responsibility_oracle",
"commit_boundary_crash_matrix"
]
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"]
},
{
"gate": "G08",
@@ -200,12 +196,7 @@
"lane": "mrf-responsibility",
"status": "pending",
"description": "MRF capacity, disk-full and replica-loss matrix",
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"],
"evidence_fields": [
"mrf_capacity_evidence",
"disk_full_matrix",
"replica_loss_matrix"
]
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"]
},
{
"gate": "G09",
@@ -234,16 +225,7 @@
"lane": "maintenance-producers",
"status": "pending",
"description": "Maintenance and complete producer coverage",
"requires": [
"maintenance producer matrix",
"complete producer inventory",
"segment activation preflight"
],
"evidence_fields": [
"maintenance_producer_matrix",
"complete_producer_inventory",
"segment_activation_preflight"
]
"requires": ["maintenance producer matrix", "complete producer inventory"]
},
{
"gate": "G12",
@@ -267,20 +249,7 @@
"lane": "ec8-4-multiset",
"status": "pending",
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
"requires": [
"same-window field evidence",
"3x4 EC8+4 evidence",
"multi-set evidence",
"multi-pool evidence",
"distributed segment invalidation evidence"
],
"evidence_fields": [
"same_window_field_evidence",
"ec8_4_evidence",
"multi_set_evidence",
"multi_pool_evidence",
"distributed_segment_invalidation_evidence"
]
"requires": ["same-window field evidence", "3x4 EC8+4 evidence", "multi-set evidence", "multi-pool evidence"]
},
{
"gate": "P1",
@@ -296,11 +265,7 @@
"lane": "scheduler-pressure",
"status": "pending",
"description": "Measured post-stop convergence and cold segment reuse",
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"],
"evidence_fields": [
"post_stop_convergence_measurement",
"cold_segment_reuse_measurement"
]
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"]
},
{
"gate": "P3",
@@ -316,13 +281,7 @@
"lane": "mrf-responsibility",
"status": "pending",
"description": "Measured MRF scale and replay cost with retained responsibility",
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
"evidence_fields": [
"mrf_scale_measurement",
"mrf_replay_cost_measurement",
"retained_responsibility_evidence",
"mrf_cleanup_gc_soak_evidence"
]
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence"]
},
{
"gate": "R-E",
+4
View File
@@ -111,6 +111,10 @@ runs:
shell: bash
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
shell: bash
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
shell: bash
run: ./scripts/check_uring_lane_lib_only.sh
+2 -2
View File
@@ -16,7 +16,7 @@ name: Security Audit
on:
push:
branches: [ main ]
branches: [ main, release ]
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
@@ -32,7 +32,7 @@ on:
- 'scripts/security/check_workflow_pins.sh'
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main ]
branches: [ main, release ]
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
+79
View File
@@ -0,0 +1,79 @@
# 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.
# Reports the existing required checks for paths excluded by ci.yml.
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
# action to keep validation coverage aligned. Keep this paths list in sync with
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
name: Continuous Integration (docs only)
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main, release ]
paths:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
permissions:
contents: read
jobs:
quick-checks:
name: Quick Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
# Docs-only PRs skip the full code CI, but they are exactly where a
# planning-type document could be slipped in (git add -f bypasses
# .gitignore). Run the guard here so the required "Test and Lint" check
# stays meaningful for docs-only changes.
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Satisfy required check for docs-only changes
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
+85 -77
View File
@@ -37,6 +37,25 @@ on:
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ]
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
# reports the required "Test and Lint" check for PRs skipped here.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
merge_group:
types: [ checks_requested ]
schedule:
@@ -69,32 +88,6 @@ jobs:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 2
persist-credentials: false
- name: Select scope using the base revision's policy
id: scope
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
else
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -107,7 +100,7 @@ jobs:
- name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
# Fail early with compile-free checks for every pull request.
# Fail early with compile-free checks shared with docs-only CI.
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -123,9 +116,9 @@ jobs:
uses: ./.github/actions/quick-checks
test-and-lint:
name: Workspace Test and Lint
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
name: Test and Lint
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -296,6 +289,45 @@ jobs:
- name: Run rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh
# Record the reason before this job completes as FAILURE. A separate
# dependent job cancels sibling lanes only after GitHub has preserved this
# required check's failure verdict.
- name: Annotate early-stop reason
if: >-
failure() && github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
run: |
{
echo "## CI early-stop"
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
} >> "$GITHUB_STEP_SUMMARY"
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
# CANCELLED and hid the actionable failure in the PR checks UI.
cancel-after-test-and-lint-failure:
name: Cancel siblings after Test and Lint failure
if: >-
failure() && needs.test-and-lint.result == 'failure'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
needs: [ test-and-lint ]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Cancel remaining jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -fsS -X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
# drive the object layer through process-global singletons (the GLOBAL_ENV
# ECStore, the global tier-config manager, background-expiry workers) and bind
@@ -308,8 +340,8 @@ jobs:
# See rustfs/backlog#1148 (ilm-1) and #1155.
test-ilm-integration-serial:
name: ILM Integration (serial)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -376,8 +408,8 @@ jobs:
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -417,8 +449,8 @@ jobs:
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
@@ -475,8 +507,8 @@ jobs:
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
@@ -529,8 +561,8 @@ jobs:
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 30
env:
@@ -652,8 +684,8 @@ jobs:
# job had neither, so each closed/merged PR really ran the whole io_uring
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
# 30662728539) and kept the cancellation run in progress for minutes.
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
# a container, applies no seccomp filter that would block io_uring_setup — so
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
@@ -970,11 +1002,22 @@ jobs:
# debug binary; each test spawns its own rustfs server on a random port.
- name: Run e2e full suite
env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-full-logs
RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG: rustfs::heal=debug,rustfs_heal=debug,rustfs_ecstore::disk=debug,rustfs_lock=debug,rustfs=error
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
run: cargo nextest run --profile e2e-full -p e2e_test
- name: Upload e2e full diagnostics
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-full-diagnostics-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-e2e-full-logs/
if-no-files-found: warn
retention-days: 7
- name: Upload junit
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -1181,44 +1224,9 @@ jobs:
if-no-files-found: ignore
retention-days: 3
required-checks:
name: Test and Lint
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs:
- classify-changes
- typos
- quick-checks
- test-and-lint
- test-ilm-integration-serial
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
- e2e-tests
- s3-implemented-tests
- s3-lifecycle-behavior-tests
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Require the expected result of every CI lane
env:
CI_NEEDS: ${{ toJSON(needs) }}
shell: bash
run: python3 scripts/ci_gate.py verify
alert-on-failure:
name: Alert on scheduled failure
needs:
- classify-changes
- connect-short-credential-boundary
- required-checks
- typos
- quick-checks
- test-and-lint
+2 -2
View File
@@ -19,7 +19,7 @@
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
# nightly runs, and manual dispatches all execute the same fail-closed suite.
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
# Upgrade cases use an independent 1.0.0-rc.2 pin defined below.
#
# Isolated pool filesystems: expand/decommission/rebalance cases require
# independent `statfs` capacity. This job runs on GitHub-hosted
@@ -87,7 +87,7 @@ jobs:
NO_PROXY: 127.0.0.1,localhost
HTTP_PROXY: ""
HTTPS_PROXY: ""
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
# Independent 1.0.0-rc.2 source pin for distributed::upgrade_test.
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
-11
View File
@@ -129,22 +129,11 @@ jobs:
: > target/debug/rustfs.features
- name: Run upgrade compatibility test
env:
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
run: |
cargo test --locked -p e2e_test \
"upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
if-no-files-found: ignore
retention-days: 14
- name: Upload server logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -82,12 +82,6 @@ jobs:
performance-test:
runs-on: pf-testing
timeout-minutes: 900
env:
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
# Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
@@ -164,15 +158,19 @@ jobs:
- name: Run benchmark (GET/PUT/MIXED)
id: benchmark
run: |
# Empty on automatic (workflow_run) runs -> full 30 rounds.
# Manual dispatch can restrict method(s)/size(s).
export WARP_METHODS="${{ inputs.test_method }}"
export WARP_SIZES="${{ inputs.object_size }}"
./auto-testing/rustfs_performance_test.sh \
--step 5 -y \
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
--log-file "${LOG_FILE}"
- name: Analyze results
if: ${{ steps.benchmark.conclusion == 'success' }}
run: |
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Collect RustFS version info
-1
View File
@@ -52,7 +52,6 @@ docs
__pycache__/
!docs/
docs/*
!docs/README.md
!docs/architecture/
!docs/architecture/**
!docs/operations/
-30
View File
@@ -7,30 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Replication
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
- **KMS failures on the S3 data path carry an actionable status**: only "key not found" and a backend outage were classified; every other KMS failure — a disabled or pending-deletion key, a denied KMS grant, an encryption-context mismatch, an unsupported algorithm, a credential or timeout failure, a capability the backend does not have — collapsed onto `500 InternalError`. SDKs therefore applied exponential backoff to configuration errors that no retry can fix, and monitoring filed every one of them as a server fault. Unusable-key and request-side failures now return `400`, a denied grant `403`, transient backend failures `503` — including a key store the backend could not read, so an outage stays distinguishable from a missing key all the way to the client — and a missing backend capability `501`. Damaged or unreadable key material still returns `500`, which is what it is.
- **SSE-C on buckets with default encryption**: a `PutObject` carrying a valid SSE-C header triple on a bucket that has default encryption configured no longer fails with `400 InvalidArgument` ("The SSE-C and managed server-side encryption headers cannot be used together"). PUT and the POST-object/extract path resolved the bucket default with a hard-coded "no explicit SSE-C" flag, so the default was layered onto the request and then tripped the request's own mutual-exclusion check; an SSE-C request now suppresses the bucket default on all three write paths, matching COPY and AWS S3. Every bucket with default encryption previously refused SSE-C single PUTs outright, while `CreateMultipartUpload` on the same bucket succeeded.
- **Explicit SSE-S3 on SSE-KMS-default buckets**: `x-amz-server-side-encryption: AES256` against a bucket whose default is `aws:kms` no longer fails with `400 InvalidArgument`. The bucket default's KMS key id was inherited independently of the effective algorithm, producing a self-contradictory `AES256` + key-id pair; the key id is now inherited only when the effective algorithm is `aws:kms`. `PutBucketEncryption` fills in a default key id automatically, so this affected nearly every SSE-KMS-default bucket.
- **Restore of encrypted or compressed multipart objects (silent data corruption)**: restoring a multipart object from a remote tier addressed the tier in *plaintext* coordinates while the copy-back reads the *stored* representation. Every part received a misaligned slice of the remote object whose length still satisfied the range, the hash reader and the completion size check, so the restore reported success and replaced the object's bytes. Restore now accumulates stored part sizes, passes the stored length to the hash reader alongside the plaintext length, and validates against the stored size. Objects restored by an affected release must be re-restored from the tier or recovered from a backup — this release does not detect or repair them retroactively.
- **Restore no longer drifts the object ETag**: the copy-back digests stored (encrypted or compressed) bytes, so the recomputed MD5 is not the object's public ETag. Single-part and multipart restores now preserve the original object ETag, and each restored part keeps its own recorded part ETag.
- **ILM archive no longer forwards encryption metadata to the tier**: transition requests carried the object's SSE headers and the RustFS-wrapped data key as request headers. Any S3 target rejected an SSE-C archive outright (`400`, no key supplied), an SSE-KMS archive asked the target to encrypt a second time under a key id it does not own, and the wrapped DEK left the cluster. The archive request now strips every SSE header and encryption marker using the same predicate the replication path uses; the local `xl.meta` keeps all of it, so read-through and restore are unaffected.
- **KMS reload is no longer a no-op on a node whose KMS failed to start**: `POST /rustfs/admin/v3/kms/reload` short-circuited whenever the persisted configuration matched the in-memory one byte for byte. A node whose KMS failed to start (for example Vault briefly unreachable during a rolling restart) keeps that configuration and sits in `Error`, so the documented recovery call returned "reloaded successfully" while leaving the node down — and did the same on every peer through the reload broadcast. Reload now short-circuits only for a service that is actually running, and otherwise reconfigures, which starts the service.
- **AWS KMS capability reporting**: the AWS backend no longer advertises `versioning` support through `GET /rustfs/admin/v3/kms/status`. AWS KMS key versions are not enumerable through this backend, as the backend documentation already stated.
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
@@ -72,18 +54,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
### Changed
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
- **Per-key SSE-KMS authorization** (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY`, default `false`). With it on, anonymous callers hold no KMS grants, so **a public bucket serving SSE-KMS objects is an incompatible combination** and those reads return `AccessDenied`.
- Envelope context binding as KMS AAD (`ENV_KMS_ENVELOPE_AAD`, off by default; a node that predates the field cannot open bound envelopes).
- Vault custom CA and mutual TLS; object-level DEK rewrap plus a batch rekey admin API; a backend-locality runtime signal on `kms/status`.
- Single-pass decryption for encrypted GET, and encrypted single-part closed-range seek — the latter is now **on by default** (`RUSTFS_ENCRYPTED_RANGE_SEEK`, default `true`; the switch remains as a kill switch).
- **Vault static tokens are now tracked and renewed**: with `Token` authentication RustFS hard-coded "this token has no lease", so the renewal task never started and no remaining-TTL gauge was published. `vault token create` grants a 768-hour TTL by default, which turned a healthy-looking cluster into one where every KMS call returned 403 about a month later, with no self-healing short of a restart or reconfigure. RustFS now calls `auth/token/lookup-self` at login and adopts what Vault reports: a non-expiring token behaves exactly as before, an expiring renewable one is renewed at half TTL like the other auth methods, and an expiring non-renewable one logs `vault_static_token_not_renewable` and publishes its remaining TTL. The probe never fails the login: a token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous no-lease behaviour, so no deployment that works today stops working.
- **SSE-C over a plaintext transport is reported**: AWS S3 and MinIO refuse an SSE-C request that did not arrive over TLS, because the customer key travels in a request header. RustFS accepted them on any transport and still does by default — flipping to a rejection inside a release window would break plaintext staging and test deployments. Each such request now increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process, and `RUSTFS_SSE_C_REQUIRE_TLS=true` opts into the AWS `400` now. The default is expected to flip in a later release; confirm the counter reads zero first. The verdict is per connection: a TLS listener satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts.
- **Local KMS backend on a distributed deployment says what actually breaks**: the backend keeps key material and its Argon2id salt on each node's own disk, so two nodes derive different keys from the same `master_key` and an object encrypted on one node cannot be decrypted on another — intermittent 500s behind a load balancer. Configuring it while the deployment is distributed now logs `kms_node_local_backend_in_distributed_deployment` and appends that consequence to the `kms/configure` response, instead of only the generic "development only" positioning warning. It remains a warning, not a gate.
- **SSE-KMS is refused when no KMS is running (breaking)**: a write requesting `x-amz-server-side-encryption: aws:kms` on a node with no KMS service no longer succeeds. Earlier releases wrapped the data key with the node-local `RUSTFS_SSE_S3_MASTER_KEY` while still writing `aws:kms` and the requested key id into the object metadata — metadata that claimed a KMS protection the object never had, under a key that was never consulted. Such a request now returns `400 InvalidRequest` when KMS was never configured and `503` when a configured service is not running; the refusal is evaluated after the per-key authorization gate, so an unauthorized caller still receives `403 AccessDenied`. **Upgrade note:** a deployment that relied on this write succeeding will start receiving 4xx/503. Either configure a KMS, or request `AES256` and keep the documented SSE-S3 local-master-key fallback, which is unchanged. Objects already written this way remain readable.
- **Legacy ciphertext nonce layouts are now locked per segment**: while decrypting a v1 segment, the reader locks onto whichever of the three historical nonce layouts decoded the segment's first non-zero-index frame and rejects any later frame that needs a different one. Because a frame encrypted at block index zero authenticates under the pre-`1.0.0-alpha.91` reused-part-nonce layout at any position, an attacker able to rewrite the underlying shards could previously replay it and have the forged plaintext returned with `200`. New `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK` (default `true`) drops that third layout entirely when set to `false`, which closes the residual case of a stream built purely from repeats of frame zero. Turn it off only after migrating pre-alpha.91 encrypted objects (rewrite in place with CopyObject); see [KMS backend security properties](docs/operations/kms-backend-security.md) for what the v1 frame layout does and does not authenticate.
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
Generated
-2
View File
@@ -9928,7 +9928,6 @@ dependencies = [
"async-trait",
"base64-simd",
"bytes",
"chrono",
"crc-fast",
"futures",
"hotpath",
@@ -10579,7 +10578,6 @@ dependencies = [
"serde_json",
"sha1 0.11.0",
"sha2 0.11.0",
"temp-env",
"thiserror 2.0.20",
"tokio",
"tokio-test",
+8 -25
View File
@@ -46,31 +46,14 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
- **简单易用**:设计简洁,易于部署和管理。
状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
| 功能 | 状态 | 功能 | 状态 |
| :-------------------------- | :------ | :----------------------- | :------ |
| **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
| **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
| **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
| **RustFS KMS** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
| **生命周期管理 (ILM)** | ✅ 可用 | **站点复制** | ✅ 可用 |
| **ILM 分层 (远端 S3)** | ✅ 可用 | **存储桶配额** | ✅ 可用 |
| **S3 Select** | ✅ 可用 | **事件通知** | ✅ 可用 |
| **S3 Tables (Iceberg REST)**| 🧪 预览 | **审计日志** | ✅ 可用 |
| **IAM / 策略** | ✅ 可用 | **日志与可观测性** | ✅ 可用 |
| **OIDC / SSO** | ✅ 可用 | **Web 控制台** | ✅ 可用 |
| **Keystone 认证** | ✅ 可用 | **K8s Helm Chart** | ✅ 可用 |
| **Swift API** | ✅ 可用 | **FTPS / WebDAV** | ✅ 可用 |
| **多租户** | ✅ 可用 | **SFTP** | ✅ 可用 |
| **MinIO 磁盘格式兼容** | 🧪 预览 | | |
说明:
- **服务端加密**:支持 SSE-C、SSE-S3 与 SSE-KMS。SSE-KMS 必须先配置 KMS 服务;未配置 KMS 时请求 `aws:kms` 会被拒绝,不会降级到本地主密钥。
- **RustFS KMS**:生产环境支持 VaultKV2 / Transit)与 AWS KMS 后端;`Local``Static` 后端仅供开发与测试使用,详见 [KMS 后端安全属性](docs/operations/kms-backend-security.md)。
| 功能 | 状态 | 功能 | 状态 |
| :----------------- | :------ | :---------------------- | :-------- |
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
## RustFS vs MinIO 性能对比
-55
View File
@@ -593,20 +593,6 @@ pub struct DataUsageSnapshotIdentity {
pub scanner_epoch: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSegmentInvalidationProof {
#[serde(default)]
pub process_epoch: String,
#[serde(default)]
pub generation_start: u64,
#[serde(default)]
pub generation_end: u64,
#[serde(default)]
pub producer_identity_coverage_complete: bool,
#[serde(default)]
pub cold_zero_walk_oracle: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSnapshotSetState {
pub pool_index: u64,
@@ -621,8 +607,6 @@ pub struct DataUsageSnapshotSetState {
pub complete: bool,
#[serde(default)]
pub tombstone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
}
impl DataUsageInfo {
@@ -3089,7 +3073,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
assert!(observed_data_usage_is_newer(&partial, &authoritative));
}
@@ -3112,7 +3095,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
},
DataUsageSnapshotSetState {
pool_index: 1,
@@ -3122,7 +3104,6 @@ mod tests {
scan_plan_digest: Some([2; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
},
],
..Default::default()
@@ -3132,42 +3113,6 @@ mod tests {
assert!(partial.is_valid_partial_snapshot());
}
#[test]
fn set_state_segment_invalidation_proof_is_additive() {
#[derive(Deserialize)]
struct LegacySetState {
pool_index: u64,
set_index: u64,
complete: bool,
}
let proof = DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 3,
generation_end: 5,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
};
let state = DataUsageSnapshotSetState {
pool_index: 1,
set_index: 2,
scanner_cycle: Some(9),
scanner_epoch: Some(4),
scan_plan_digest: Some([7; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: Some(proof.clone()),
};
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
assert_eq!(legacy.pool_index, 1);
assert_eq!(legacy.set_index, 2);
assert!(legacy.complete);
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
}
#[test]
fn completeness_marker_requires_a_snapshot_timestamp() {
let untimestamped = DataUsageInfo {
+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
}
}
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=no"]).is_none_or(|status| !status.is_empty());
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
let mut features = std::env::vars()
.filter_map(|(key, _)| {
+54
View File
@@ -44,6 +44,7 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::path::{Path, PathBuf};
use tokio::time::{Duration, Instant, sleep};
use tracing::info;
use uuid::Uuid;
use walkdir::WalkDir;
@@ -374,6 +375,33 @@ pub(crate) fn census_object_version_on_disk(
})
}
/// Wait for the background PUT tail to commit every physical part on one disk.
/// Invalid metadata remains an immediate error instead of a retryable absence.
pub(crate) async fn wait_for_complete_physical_shard_on_disk(
disk: &Path,
bucket: &str,
key: &str,
version_id: Option<&str>,
timeout: Duration,
) -> ChaosResult<VersionShardCensus> {
let deadline = Instant::now() + timeout;
loop {
let census = census_object_version_on_disk(disk, bucket, key, version_id)?;
if census.is_complete() && !census.expected_part_numbers.is_empty() {
return Ok(census);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"physical shard for {bucket}/{key}@{version_id:?} on {} did not become complete within {timeout:?}: {census:?}",
disk.display()
)
.into());
}
sleep(remaining.min(Duration::from_millis(50))).await;
}
}
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal
/// regression suite.
@@ -451,4 +479,30 @@ mod tests {
assert!(expected.matches_manifest(&expected));
assert!(!changed.matches_manifest(&expected));
}
#[tokio::test]
async fn physical_shard_readiness_fails_closed_with_last_census() {
let disk = tempfile::tempdir().expect("temporary disk");
let error = wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "missing", None, Duration::ZERO)
.await
.expect_err("missing physical shards must fail the baseline gate");
assert!(error.to_string().contains("has_xl_meta: false"));
assert!(error.to_string().contains("bucket/missing"));
}
#[tokio::test]
async fn physical_shard_readiness_does_not_retry_invalid_metadata() {
let disk = tempfile::tempdir().expect("temporary disk");
let object = disk.path().join("bucket").join("corrupt");
std::fs::create_dir_all(&object).expect("object directory");
std::fs::write(object.join("xl.meta"), b"invalid metadata").expect("corrupt metadata fixture");
let error = tokio::time::timeout(
Duration::from_secs(1),
wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "corrupt", None, Duration::from_secs(30)),
)
.await
.expect("corrupt metadata must fail immediately")
.expect_err("invalid metadata must not be accepted as a complete baseline");
assert!(!error.to_string().contains("did not become complete"));
}
}
@@ -268,7 +268,6 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
let rule = CorsRule::builder()
.allowed_methods("GET")
.allowed_origins("https://example.com")
.allowed_headers("*")
.build()?;
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
@@ -289,60 +288,6 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
assert_eq!(rules[0].allowed_methods(), ["GET"]);
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
let http = reqwest::Client::builder().no_proxy().build()?;
let url = format!("http://{}/{}", cluster.nodes[1].address, BUCKET_METADATA_RELOAD_BUCKET);
let without_headers = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.send()
.await?;
assert!(without_headers.status().is_success());
assert!(!without_headers.headers().contains_key("access-control-allow-headers"));
assert!(
without_headers
.headers()
.get("vary")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
"a cached header-free preflight must not suppress a later requested header grant"
);
let preflight = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "X-Another-Header, x-could-be-anything")
.send()
.await?;
assert!(preflight.status().is_success(), "peer preflight should succeed: {preflight:?}");
assert_eq!(
preflight
.headers()
.get("access-control-allow-headers")
.and_then(|value| value.to_str().ok()),
Some("x-another-header,x-could-be-anything"),
"a wildcard rule must return only the headers requested by this preflight"
);
assert!(
preflight
.headers()
.get("vary")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
"preflight caches must distinguish the requested header list"
);
let denied = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://disallowed.example.com")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "x-another-header")
.send()
.await?;
assert!(
!denied.headers().contains_key("access-control-allow-headers"),
"a rejected origin must not receive the requested header grant"
);
writer
.delete_bucket_cors()
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
+27 -33
View File
@@ -34,7 +34,7 @@ use serde_json;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::net::{Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Once;
@@ -188,15 +188,6 @@ fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Sen
Ok(())
}
fn no_available_test_port_error(config: TestPortAllocatorConfig, attempts: u16, last_error: Option<&std::io::Error>) -> String {
let max_inclusive = config.max_exclusive() - 1;
let detail = last_error.map(|err| format!("; last bind error: {err}")).unwrap_or_default();
format!(
"no available E2E test port found in {}..={} after {} attempts{}",
config.min, max_inclusive, attempts, detail
)
}
pub(crate) fn capture_command_logs(
command: &mut Command,
log_path: Option<&str>,
@@ -703,23 +694,19 @@ impl RustFSTestEnvironment {
let _guard = PortAllocatorGuard::acquire().await?;
let config = test_port_allocator_config()?;
let mut next_port = read_next_test_port(config);
let mut last_error = None;
for _ in 0..config.range {
let port = next_port;
next_port = advance_test_port(next_port, config);
write_next_test_port(next_port)?;
match TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => {
drop(listener);
return Ok(port);
}
Err(err) => last_error = Some(err),
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
drop(listener);
return Ok(port);
}
}
Err(no_available_test_port_error(config, config.range, last_error.as_ref()).into())
Err("no available E2E test port found".into())
}
/// Kill any existing RustFS processes
@@ -1481,7 +1468,9 @@ impl RustFSTestClusterEnvironment {
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
}
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
// Endpoint locality requires the server port to match. IPv6 loopback
// keeps that port while leaving the direct IPv4 S3 listener available.
let proxy = crate::fault_proxy::FaultProxy::start_on((Ipv6Addr::LOCALHOST, target.port()).into(), target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy)
}
@@ -2214,19 +2203,6 @@ mod tests {
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
}
#[test]
fn e2e_port_allocator_reports_attempt_window_and_last_bind_error() {
let config = TestPortAllocatorConfig { min: 41000, range: 3 };
let error = std::io::Error::from(ErrorKind::PermissionDenied);
let message = no_available_test_port_error(config, config.range, Some(&error));
assert!(message.contains("41000..=41002"));
assert!(message.contains("after 3 attempts"));
assert!(message.contains("last bind error"));
assert!(message.contains("permission denied"));
}
#[test]
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
let workspace = Path::new("workspace");
@@ -2399,7 +2375,7 @@ mod tests {
#[tokio::test]
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
let mut env = RustFSTestClusterEnvironment::new(1)
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2))
.await
.expect("cluster environment should allocate a node");
let direct = env.nodes[0].address.clone();
@@ -2414,6 +2390,24 @@ mod tests {
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
proxy.shutdown().await;
for node in &env.nodes {
let local_port = node.address.parse::<SocketAddr>().expect("node address").port();
let local_paths = volumes
.split_whitespace()
.filter_map(|volume| {
let endpoint = reqwest::Url::parse(volume).expect("volume endpoint");
rustfs_utils::is_local_host(
endpoint.host().expect("volume endpoint host"),
endpoint.port().expect("volume endpoint port"),
local_port,
)
.expect("endpoint locality")
.then(|| endpoint.path().to_string())
})
.collect::<Vec<_>>();
assert_eq!(local_paths, node.data_dirs, "the proxy must preserve local disk ownership");
}
}
#[test]
@@ -962,6 +962,27 @@ pub(crate) async fn wait_for_rebalance_active(
}
}
pub(crate) async fn wait_for_rebalance_running_with_progress(
cluster: &RustFSTestClusterEnvironment,
expected_id: &str,
timeout: Duration,
) -> TestResult {
let deadline = Instant::now() + timeout;
loop {
let status = rebalance_status_json(cluster).await?;
if rebalance_running_with_progress(&status, expected_id)? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}"
)
.into());
}
sleep(Duration::from_millis(100)).await;
}
}
pub(crate) async fn wait_for_rebalance_complete(
cluster: &RustFSTestClusterEnvironment,
expected_id: &str,
+64 -227
View File
@@ -12,30 +12,22 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
wait_until,
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until};
use crate::chaos::{
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post, wait_for_complete_physical_shard_on_disk,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::common::init_logging;
use crate::common::{init_logging, rustfs_binary_path};
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::{Instant, sleep};
const EC84_NODE_COUNT: usize = 3;
const EC84_DRIVES_PER_NODE: usize = 4;
const EC84_DATA_BLOCKS: usize = 8;
const EC84_PARITY_BLOCKS: usize = 4;
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
const EC84_HEAL_CONTROL_RETRY_DELAY: Duration = Duration::from_millis(250);
#[derive(Clone)]
struct ExpectedShard {
@@ -44,91 +36,6 @@ struct ExpectedShard {
baseline: VersionShardCensus,
}
struct ScannerHealEvidenceContext {
directory: PathBuf,
run: Value,
}
struct ScannerHealEvidencePayload<'a> {
dist: &'a DistCluster,
bucket: &'a str,
expected: &'a [ExpectedShard],
outage_key: &'a str,
outage_body: &'a [u8],
replaced_drive: &'a Path,
pid_before: u32,
pid_after: u32,
node_listings: Vec<Vec<String>>,
}
fn file_sha256(path: &Path) -> TestResult<String> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn compiled_test_identity() -> Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
let mut current = value;
for segment in path.split('.') {
current = current
.get(segment)
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
}
current
.as_str()
.filter(|text| !text.is_empty())
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
}
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
if built[key] != run["test_build"][key] {
return Err(format!("compiled test identity differs for {key}").into());
}
}
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
return Err("server binary must match the run receipt".into());
}
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
return Err("test executable must match the run receipt".into());
}
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(ScannerHealEvidenceContext { directory, run }))
}
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
@@ -145,65 +52,6 @@ fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
Ok(())
}
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
let verifier = payload.dist.client(0)?;
let mut objects = Vec::new();
for item in payload.expected {
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
objects.push(serde_json::json!({
"key": item.key,
"version_id": null,
"expected_bytes": item.body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(&item.body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": item.baseline,
"physical": physical,
}));
}
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
objects.push(serde_json::json!({
"key": payload.outage_key,
"version_id": null,
"expected_bytes": payload.outage_body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(payload.outage_body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": null,
"physical": physical,
}));
let evidence = serde_json::json!({
"schema": 1,
"case": EC84_TARGET_DRIVE_RESTART_CASE,
"evidence": "process-restart",
"run_id": string_field(&context.run, "run_id")?,
"source_revision": string_field(&context.run, "source_revision")?,
"test_build": compiled_test_identity(),
"binary_sha256": string_field(&context.run, "binary.sha256")?,
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
"pid_before": payload.pid_before,
"pid_after": payload.pid_after,
"unclean_shutdown_marker": false,
"objects": objects,
"node_listings": payload.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
for key in keys {
let census = census_object_version_on_disk(drive, bucket, key, None)?;
@@ -214,29 +62,6 @@ fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> T
Ok(())
}
fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
let message = error.to_string();
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
}
async fn start_ec84_root_heal_when_control_ready(
heal_url: &str,
heal_body: &str,
access_key: &str,
secret_key: &str,
) -> TestResult {
let deadline = Instant::now() + EC84_HEAL_CONTROL_READY_TIMEOUT;
loop {
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
Ok(_) => return Ok(()),
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && Instant::now() < deadline => {
sleep(EC84_HEAL_CONTROL_RETRY_DELAY).await;
}
Err(error) => return Err(error),
}
}
}
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
let mut expected = Vec::new();
for index in 0..4 {
@@ -265,7 +90,19 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
#[tokio::test]
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
init_logging();
let evidence_context = scanner_heal_evidence_context()?;
let server_binary = rustfs_binary_path();
let evidence_run = restart_evidence_run(
&server_binary,
ScannerHealEvidenceCase {
id: "ec84-target-drive-restart",
oracle: "ec84-target-drive-restart.json",
evidence: "process-restart",
unclean_shutdown_marker: false,
topology: EvidenceTopology::new(3, 4),
storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some("12"),
},
)?;
let mut dist = DistCluster::start_with_env(
DistLayout::ThreeByFourEc84,
&[
@@ -289,18 +126,24 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
for item in &mut expected {
item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
item.baseline =
wait_for_complete_physical_shard_on_disk(&replaced_drive, &bucket, &item.key, None, Duration::from_secs(10)).await?;
assert_ec84_geometry(&item.baseline, &item.key)?;
}
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
let format_json = std::fs::read(&format_path)?;
let target_pid_before = dist.cluster.nodes[replaced_node]
let pid_before = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent before graceful restart")?
.ok_or("target process is absent")?
.id();
dist.cluster.stop_node_gracefully(replaced_node).await?;
let unclean_shutdown_marker = Path::new(&dist.cluster.nodes[replaced_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown")
.is_file();
assert!(!unclean_shutdown_marker, "graceful target shutdown must remove its unclean marker");
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?;
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
@@ -322,15 +165,10 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.await?;
dist.cluster.start_node(replaced_node).await?;
let target_pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent after restart")?
.id();
let heal_body =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
start_ec84_root_heal_when_control_ready(&heal_url, heal_body, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
wait_until(
Duration::from_secs(120),
@@ -367,47 +205,46 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.filter_map(|object| object.key().map(str::to_owned))
.collect::<HashSet<_>>();
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
let mut observed = observed.into_iter().collect::<Vec<_>>();
observed.sort();
node_listings.push(observed);
let mut keys = observed.into_iter().collect::<Vec<_>>();
keys.sort();
node_listings.push(keys);
}
if let Some(context) = evidence_context {
write_scanner_heal_evidence(
context,
ScannerHealEvidencePayload {
dist: &dist,
bucket: &bucket,
expected: &expected,
outage_key,
outage_body: &outage_body,
replaced_drive: &replaced_drive,
pid_before: target_pid_before,
pid_after: target_pid_after,
if let Some(evidence_run) = evidence_run {
let target_client = dist.client(replaced_node)?;
let mut objects = Vec::with_capacity(inventory.len());
for (key, body) in &inventory {
let response = target_client.get_object().bucket(&bucket).key(key).send().await?;
let actual = response.body.collect().await?.into_bytes();
assert_eq!(actual.as_ref(), body.as_slice(), "object body changed for {key}");
let physical = census_object_version_on_disk(&replaced_drive, &bucket, key, None)?;
assert_ec84_geometry(&physical, key)?;
let baseline = expected.iter().find(|item| item.key == *key).map(|item| &item.baseline);
objects.push(serde_json::json!({
"key": key, "version_id": null,
"expected_bytes": body.len(), "actual_bytes": actual.len(),
"expected_sha256": sha256_hex(body), "actual_sha256": sha256_hex(&actual),
"expected_physical": baseline, "physical": physical,
}));
}
let pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("restarted target is absent")?
.id();
evidence_run.write(
&server_binary,
RestartObservation {
nodes: dist.cluster.nodes.len(),
drives_per_node: dist.cluster.topology.drives_per_node,
pid_before,
pid_after,
unclean_shutdown_marker,
objects,
node_listings,
},
)
.await?;
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cluster_heal_coordination_retry_is_exact() {
let retryable: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 500 Internal Server Error cluster heal coordination unavailable".into();
assert!(is_cluster_heal_coordination_unavailable(retryable.as_ref()));
let other_internal: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 500 Internal Server Error unrelated".into();
assert!(!is_cluster_heal_coordination_unavailable(other_internal.as_ref()));
let wrong_status: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
}
}
@@ -14,9 +14,9 @@
use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, put_inventory_retrying, rebalance_active, rebalance_status_json, retrying_get_equals, retrying_put,
start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_active, wait_for_rebalance_complete,
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
};
use crate::common::init_logging;
use std::time::Duration;
@@ -67,10 +67,7 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
assert_inventory(&live, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?;
// The status API reads persisted progress, whose first periodic save is
// after 30 seconds. A shorter run can remain at zero until completion.
// Require Started around the S3 operations and nonzero progress at completion.
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
retrying_put(
&live,
&bucket,
@@ -87,26 +84,11 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
Duration::from_secs(30),
)
.await?;
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed
.contents()
.iter()
.any(|object| object.key() == Some("during-rebalance.bin")),
"list during rebalance missed the newly written key"
);
let status = rebalance_status_json(&dist.cluster).await?;
if !rebalance_active(&status, &rebalance_id)? {
if !rebalance_running_with_progress(&status, &rebalance_id)? {
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
}
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
let after = dist.client(1)?;
assert_inventory(&after, &bucket, &inventory).await?;
for (key, body) in [
("during-decommission.bin", b"written-while-decommissioning".as_slice()),
("during-rebalance.bin", b"written-while-rebalancing".as_slice()),
] {
retrying_get_equals(&after, &bucket, key, body, Duration::from_secs(30)).await?;
}
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
Ok(())
}
@@ -498,7 +498,9 @@ mod tests {
.stderr(log)
.spawn()?,
);
let status = tokio::time::timeout(Duration::from_secs(10), async {
// macOS evaluates each fresh binary copy before its capability hook can run.
let probe_timeout = if cfg!(target_os = "macos") { 60 } else { 10 };
let status = tokio::time::timeout(Duration::from_secs(probe_timeout), async {
loop {
if let Some(status) = child.0.try_wait()? {
return Ok::<_, std::io::Error>(status);
+6 -1
View File
@@ -114,7 +114,12 @@ impl FaultProxy {
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
/// connections to `target`. Starts in [`FaultMode::Pass`].
pub async fn start(target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
Self::start_on((Ipv4Addr::LOCALHOST, 0).into(), target).await
}
/// Bind the selected address and forward accepted connections to `target`.
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind(address).await?;
let listen_addr = listener.local_addr()?;
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
@@ -401,11 +401,11 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
baseline_degraded.insert(multipart_key.to_string(), mp_view);
// Restore the disk so Phase B restarts from a clean, complete disk set.
// Stop disk writers before restoring the complete layout reused by Phase B.
harness.kill_server();
harness.bring_disk_online(0)?;
// ---- Phase B: codec streaming (gates opened) ----
harness.kill_server();
for (k, v) in codec_env() {
harness.set_env(k, v);
}
@@ -497,6 +497,8 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
codec_degraded.insert(multipart_key.to_string(), mp_view);
// All server reads are complete; stop disk writers before restoring disk0.
harness.kill_server();
harness.bring_disk_online(0)?;
// A/B under parity reconstruction: codec == legacy, byte-for-byte and
@@ -16,18 +16,20 @@
#[cfg(test)]
mod tests {
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
use crate::common::{
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
init_logging, rustfs_binary_path,
use crate::chaos::{
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post,
wait_for_complete_physical_shard_on_disk,
};
use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
rustfs_binary_path,
};
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::error::Error;
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -39,52 +41,6 @@ mod tests {
const POOL_METADATA_OBJECT: &str = "pool.bin";
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
struct ScannerHealEvidenceCase {
id: &'static str,
oracle: &'static str,
evidence: &'static str,
unclean_shutdown_marker: bool,
topology: EvidenceTopology,
storage_class_standard: Option<&'static str>,
erasure_set_drive_count: Option<&'static str>,
}
#[derive(Clone, Copy)]
struct EvidenceTopology {
nodes: usize,
drives_per_node: usize,
}
impl EvidenceTopology {
const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self { nodes, drives_per_node }
}
fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
fn cluster_topology(self) -> ClusterTopology {
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
}
}
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-restart",
oracle: "background-target-restart.json",
@@ -125,81 +81,6 @@ mod tests {
erasure_set_drive_count: Some("12"),
};
struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
struct TcpPortBlackhole {
port: u16,
comment: String,
@@ -504,6 +385,79 @@ mod tests {
)
}
async fn wait_for_admin_cluster_start_log(
log_path: &Path,
client_token: &str,
deadline: Instant,
) -> Result<(), Box<dyn Error + Send + Sync>> {
loop {
let coordinator_log = std::fs::read_to_string(log_path)?;
if coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
})
{
return Ok(());
}
if Instant::now() >= deadline {
return Err(
format!("node 0 must have started the exact admin task before interruption: task_id={client_token}").into(),
);
}
sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn test_admin_cluster_start_log_waits_for_exact_delayed_event() -> Result<(), Box<dyn Error + Send + Sync>> {
use std::io::Write;
let mut log = tempfile::NamedTempFile::new()?;
for (task_id, heal_type, state) in [
("other-task", "cluster", "started"),
("admin-task", "object", "started"),
("admin-task", "cluster", "completed"),
] {
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": task_id, "heal_type": heal_type, "state": state})
)?;
}
let log_path = log.path().to_path_buf();
let started = wait_for_admin_cluster_start_log(&log_path, "admin-task", Instant::now() + Duration::from_secs(1));
tokio::pin!(started);
// Poll the reader before publishing the start event, without depending
// on scheduling or a fixed writer delay to reproduce log visibility.
tokio::select! {
biased;
result = &mut started => panic!("unrelated events must leave the exact start pending: {result:?}"),
_ = std::future::ready(()) => {}
}
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": "admin-task", "heal_type": "cluster", "state": "started"})
)?;
started.await?;
Ok(())
}
#[tokio::test]
async fn test_admin_cluster_start_log_respects_existing_deadline() -> Result<(), Box<dyn Error + Send + Sync>> {
let log = tempfile::NamedTempFile::new()?;
let error = wait_for_admin_cluster_start_log(log.path(), "admin-task", Instant::now())
.await
.expect_err("missing exact start must fail at the supplied deadline");
assert!(error.to_string().contains("task_id=admin-task"), "{error}");
Ok(())
}
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
@@ -940,7 +894,10 @@ mod tests {
cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(60);
let started = Instant::now();
let mut deadline = started + Duration::from_secs(60);
let catch_up_deadline = deadline + Duration::from_secs(300);
let mut catch_up_wait_observed = false;
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
@@ -968,24 +925,61 @@ mod tests {
let versions_scanned = metrics["versions_scanned"]
.as_u64()
.ok_or("scanner status is missing its version-coverage counter")?;
latest_cycle_end = latest_cycle_end.max(cycle_end);
let cycle_result = metrics["last_cycle_result"]
.as_str()
.ok_or("scanner status is missing its cycle result")?;
if cycle_result == "success" {
latest_cycle_end = latest_cycle_end.max(cycle_end);
}
versions_observed |= versions_scanned > 0;
let backlog = &status["pause_backlog"];
if !catch_up_wait_observed
&& backlog["persistence_state"].as_str() == Some("healthy")
&& backlog["durable"].as_bool() == Some(true)
&& backlog["phase"].as_str() == Some("catching_up")
&& backlog["rate_limited"].as_bool() == Some(true)
&& backlog["retry_exhausted"].as_bool() == Some(false)
{
let next_attempt = backlog["next_attempt_at_unix_secs"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its next attempt")?;
let interval = backlog["thresholds"]["catch_up_min_interval_seconds"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its catch-up interval")?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
let remaining = next_attempt.saturating_sub(now);
if remaining > 0 {
if interval > 300 || remaining > interval {
return Err(
format!("scanner catch-up schedule exceeds the bounded recovery budget: {backlog}").into()
);
}
// The durable catch-up interval overrides SCANNER_CYCLE=1.
// Honor one observed retry without restarting the deadline on every poll.
deadline = deadline
.max(Instant::now() + Duration::from_secs(remaining + 60))
.min(catch_up_deadline);
catch_up_wait_observed = true;
}
}
observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, backlog={}",
metrics["current_cycle"],
metrics["current_cycle_active"],
metrics["leader_lock_state"],
metrics["last_cycle_result"],
backlog,
));
}
// The coordinator records cycle completion, but remote workers
// record scanned versions. Both witnesses need not share a node.
// Only a successful coordinator cycle counts as completion; deferred
// and superseded attempts also advance its end timestamp. Remote
// workers record version coverage, so the witnesses can span nodes.
if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end);
}
if Instant::now() >= deadline {
return Err(format!(
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
"enabled scanner did not complete a successful object-scanning cycle after {previous_cycle_end}: {observations:?}"
)
.into());
}
@@ -1005,7 +999,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
)
.await?
@@ -1015,7 +1009,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
)
.await?
@@ -1025,7 +1019,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
)
.await?
@@ -1035,7 +1029,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
)
.await?
@@ -1045,7 +1039,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
timeout(
Duration::from_secs(420),
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
)
.await?
@@ -1152,10 +1146,19 @@ mod tests {
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log);
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
let log_dir = if let Some(directory) = std::env::var_os("RUSTFS_HEAL_CHAOS_LOG_DIR") {
PathBuf::from(directory)
} else if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
let cluster_name = Path::new(&cluster.temp_dir)
.file_name()
.ok_or("cluster directory has no name")?;
PathBuf::from(directory).join(cluster_name).join("heal")
} else {
PathBuf::from(&cluster.temp_dir).join("logs")
};
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
cluster.set_node_capture_log_path(node_index, log_dir.join(format!("node{node_index}.log")).to_string_lossy())?;
}
cluster.start_with_binary(&server_binary).await?;
let clients = cluster.create_all_clients()?;
@@ -1210,6 +1213,8 @@ mod tests {
attempt_count += 1;
continue;
}
let shard_census =
wait_for_complete_physical_shard_on_disk(&replaced_disk, bucket, &key, None, Duration::from_secs(10)).await?;
assert!(
shard_census.is_complete(),
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
@@ -1396,6 +1401,9 @@ mod tests {
}
sleep(Duration::from_millis(50)).await;
}
// Task execution and its non-blocking log writer advance independently.
// Observe the exact start before taking the partial-rebuild snapshot.
wait_for_admin_cluster_start_log(&log_dir.join("node0.log"), client_token, partial_deadline).await?;
let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window.
@@ -1430,19 +1438,6 @@ mod tests {
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
@@ -1567,12 +1562,11 @@ mod tests {
"Restored target endpoint forwarding"
);
} else {
if matches!(
let graceful_restart = matches!(
scenario,
InterruptionScenario::BackgroundTargetRestart
| InterruptionScenario::BackgroundTargetRestartEc84
| InterruptionScenario::BackgroundCoordinatorRestart
) {
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
);
if graceful_restart {
cluster.stop_node_gracefully(interruption_node).await?;
} else {
cluster.stop_node(interruption_node)?;
@@ -1594,10 +1588,7 @@ mod tests {
if background_enabled {
let marker_exists = unclean_shutdown_marker.is_file();
unclean_shutdown_marker_observed = Some(marker_exists);
let expected_marker = matches!(
scenario,
InterruptionScenario::BackgroundTargetCrash | InterruptionScenario::BackgroundTargetCrashEc84
);
let expected_marker = !graceful_restart;
assert!(
marker_exists == expected_marker,
"background restart/crash lane observed unexpected unclean-shutdown marker state"
@@ -1653,7 +1644,19 @@ mod tests {
}
}
if Instant::now() >= heal_deadline {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let mut mismatched_manifests = Vec::new();
for expected in &expected_manifests {
let actual = census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?;
if !actual.matches_manifest(&expected.shard_census) {
mismatched_manifests.push(serde_json::json!({
"key": expected.key,
"expected": expected.shard_census,
"actual": actual,
}));
}
}
let matching = expected_manifests.len() - mismatched_manifests.len();
let mismatched_manifests = serde_json::Value::Array(mismatched_manifests);
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)?;
@@ -1676,7 +1679,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}/{}, mismatched_manifests={mismatched_manifests}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
@@ -1769,52 +1772,49 @@ mod tests {
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
if interruption_node == 0 {
// Restart recovery must finish the original durable root request.
// Admin tasks are process-local. Physical and queue convergence
// above establish recovery; a lost task must not report success.
assert_eq!(
task_status["summary"].as_str(),
Some("notFound"),
"interrupted task status: {task_status}"
);
assert_eq!(
task_status["detail"].as_str(),
Some("heal task not found or expired"),
"interrupted admin task must be explicitly unavailable: {task_status}"
);
info!(
event = "heal_interruption_recovered",
component = "e2e_test",
subsystem = "heal",
interruption_node,
interruption_kind,
task_state = "finished",
"Original root heal completed after coordinator restart"
task_state = "not_found",
"Physical recovery completed after coordinator restart"
);
return Ok(());
}
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
if let Some(evidence_context) = evidence_run {
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
assert_ne!(target_pid, restarted_pid, "target must be a new process");
assert_eq!(
file_sha256(&server_binary)?,
evidence_context.run.binary.sha256,
"server build changed during restart"
);
let evidence = serde_json::json!({
"schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": evidence_context.run.binary.sha256,
"test_binary_sha256": evidence_context.run.test_binary.sha256,
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
"pid_before": target_pid, "pid_after": restarted_pid,
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
"objects": evidence_objects, "node_listings": node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
evidence_context.write(
&server_binary,
RestartObservation {
nodes: cluster.nodes.len(),
drives_per_node: cluster.nodes[0].data_dirs.len(),
pid_before: target_pid,
pid_after: restarted_pid,
unclean_shutdown_marker: unclean_shutdown_marker_observed.ok_or("missing shutdown marker observation")?,
objects: evidence_objects,
node_listings,
},
)?;
}
Ok(())
@@ -21,7 +21,7 @@
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks
//! distributed reader selection rather than HTTP request count.
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, signal_process};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
@@ -2207,6 +2207,33 @@ async fn four_node_manual_transition_job_status_survives_node_restart() -> TestR
Ok(())
}
struct SuspendedTransitionTarget<'a> {
// Keep the owned child borrowed until it is resumed so its PID cannot be reused.
child: &'a std::process::Child,
suspended: bool,
}
impl<'a> SuspendedTransitionTarget<'a> {
fn suspend(child: &'a std::process::Child) -> TestResult<Self> {
signal_process(child.id(), "STOP")?;
Ok(Self { child, suspended: true })
}
fn resume(&mut self) -> TestResult {
signal_process(self.child.id(), "CONT")?;
self.suspended = false;
Ok(())
}
}
impl Drop for SuspendedTransitionTarget<'_> {
fn drop(&mut self) {
if self.suspended {
let _ = signal_process(self.child.id(), "CONT");
}
}
}
#[tokio::test]
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
init_logging();
@@ -2244,7 +2271,21 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
.send()
.await?;
}
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
// Lifecycle PUT starts its own backfill. Keep its first page on a separate
// node and stop it at queue backpressure before it reaches the tested prefix:
// one active worker, one queued item, then the first rejected item.
for index in 0u8..3 {
hot_client
.put_object()
.bucket(&bucket)
.key(format!("transition/automatic-admission/object-{index:02}.bin"))
.body(ByteStream::from(payload(KIB, index)))
.send()
.await?;
}
let mut suspended_cold = SuspendedTransitionTarget::suspend(cold.process.as_ref().ok_or("cold-tier process missing")?)?;
let lifecycle_client = hot.create_s3_client(2)?;
put_lifecycle_with_transition_retry(&lifecycle_client, &bucket, &tier_name).await?;
let (node0, node1) = tokio::join!(
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
@@ -2304,6 +2345,31 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
assert_eq!(status["job_id"].as_str(), Some(job_id));
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let status = read_manual_transition_job_status_endpoint(&hot, accepted.0, status_endpoint).await?;
assert_eq!(
status["status"].as_str(),
Some("running"),
"blocked cold tier must keep the admitted job running: {status}"
);
if status["report"]["skipped_queue_full"].as_u64().is_some_and(|count| count > 0) {
assert!(
status["report"]["enqueued"].as_u64().is_some_and(|count| count > 0),
"the job must own pending transitions while the cold tier is suspended: {status}"
);
break;
}
if Instant::now() >= deadline {
return Err(format!(
"manual transition job did not reach queue backpressure while the cold tier was suspended: {status}"
)
.into());
}
sleep(Duration::from_millis(50)).await;
}
suspended_cold.resume()?;
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
assert_eq!(terminal["job_id"].as_str(), Some(job_id));
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
@@ -109,10 +109,10 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
.await;
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(503));
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
assert_eq!(
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("ServiceUnavailable")
Some("InternalError")
);
let unavailable_absence = s3_client
.get_object()
+3
View File
@@ -23,6 +23,9 @@ pub mod common;
#[cfg(test)]
pub mod chaos;
#[cfg(test)]
mod scanner_heal_evidence;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151).
#[cfg(test)]
+5 -6
View File
@@ -15,7 +15,6 @@
//! Regression coverage for anonymous access on multipart control APIs.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use crate::kms::common::LocalKMSTestEnvironment;
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
@@ -1466,10 +1465,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut kms_env = LocalKMSTestEnvironment::new().await?;
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
kms_env.wait_for_kms_ready().await?;
let env = &kms_env.base_env;
let mut env = RustFSTestEnvironment::new().await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let bucket = "anon-post-default-sse-kms";
let object_key = "post-default-sse-kms-object.txt";
@@ -1485,7 +1484,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::AwsKms)
.kms_master_key_id(default_key_id)
.kms_master_key_id("test-key")
.build()
.expect("default encryption rule should build"),
)
@@ -1137,12 +1137,7 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
let (listed, _, _) = tokio::try_join!(
env.wait_local_listed(bucket, hit_key, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
)?;
assert!(listed);
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
@@ -79,6 +79,11 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
.await
.map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
// A signed S3 request establishes readiness before the first SFTP login.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
cmptst_01::run_medium_binary_round_trip(&sftp).await?;
@@ -101,8 +106,6 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
// reach the finalised object as x-amz-meta-* user metadata
// through the CreateMultipartUpload input field. The S3 client
// connects to the same rustfs process this suite already drives.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
drop(sftp);
+7 -10
View File
@@ -168,6 +168,10 @@ pub async fn test_sftp_core_operations() -> Result<()> {
.await
.map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp().await?;
// --- 1. Subsystem canary: SFTP session reachable after password auth ---
@@ -348,16 +352,6 @@ pub async fn test_sftp_core_operations() -> Result<()> {
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
info!("PASS: bad-password authentication rejected");
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
// The rustfs binary spawned for this suite serves both SFTP on port
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
// initialising after TCP is listening, so list_buckets is polled
// until it succeeds before any cross-protocol assertion runs.
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
info!("PASS: S3 endpoint reachable from cross-protocol client");
// --- SFTP write, S3 read: SHA256 round-trip ---
// SFTP creates the object, then assert_cross_protocol_sha_match
// fetches it via both S3 GetObject and SFTP READ and compares
@@ -522,6 +516,9 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
.await
.map_err(|e| anyhow!("{}", e))?;
let s3 = build_test_s3_client(&format!("http://{IDLE_S3_ADDRESS}"));
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
// Confirm the session is live before the wait so a failure in the
@@ -4235,16 +4235,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
</Rule>
<Rule>
<ID>matrix-and-tags</ID>
<Priority>135</Priority>
<Status>Enabled</Status>
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
</Rule>
<Rule>
<ID>matrix-disabled</ID>
<Priority>140</Priority>
@@ -4299,7 +4289,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
"matrix-prefix",
"matrix-tag",
"matrix-disabled",
"matrix-and-tags",
"matrix-priority-high",
"Priority>200",
"<Status>Disabled</Status>",
@@ -4420,30 +4409,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
// the required tags is not admitted. Matching any single tag would push
// data to a destination the rule never selected (backlog#2366 P1-1), and
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
// writes, so a single-tag rule passing is not evidence for this.
source_client
.put_object()
.bucket(source_bucket)
.key("and-tags/partial.txt")
.tagging("env=prod")
.body(ByteStream::from_static(b"one of two tags"))
.send()
.await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
source_client
.put_object()
.bucket(source_bucket)
.key("and-tags/full.txt")
.tagging("env=prod&tier=gold")
.body(ByteStream::from_static(b"both tags"))
.send()
.await?;
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
source_client
.put_object()
.bucket(source_bucket)
@@ -0,0 +1,181 @@
// 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.
//! Build-bound evidence for scanner and heal restart tests.
use crate::common::ClusterTopology;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
pub(crate) struct ScannerHealEvidenceCase {
pub(crate) id: &'static str,
pub(crate) oracle: &'static str,
pub(crate) evidence: &'static str,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) topology: EvidenceTopology,
pub(crate) storage_class_standard: Option<&'static str>,
pub(crate) erasure_set_drive_count: Option<&'static str>,
}
#[derive(Clone, Copy)]
pub(crate) struct EvidenceTopology {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
}
impl EvidenceTopology {
pub(crate) const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self { nodes, drives_per_node }
}
pub(crate) fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
pub(crate) fn cluster_topology(self) -> ClusterTopology {
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
}
}
pub(crate) struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
pub(crate) fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
pub(crate) struct RestartObservation {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
pub(crate) pid_before: u32,
pub(crate) pid_after: u32,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) objects: Vec<serde_json::Value>,
pub(crate) node_listings: Vec<Vec<String>>,
}
impl RestartEvidenceContext {
pub(crate) fn write(self, binary: &Path, observed: RestartObservation) -> Result<(), Box<dyn Error + Send + Sync>> {
assert_ne!(observed.pid_before, observed.pid_after, "target must be a new process");
assert_eq!(file_sha256(binary)?, self.run.binary.sha256, "server build changed during restart");
let evidence = serde_json::json!({
"schema": 1, "case": self.case.id, "evidence": self.case.evidence,
"run_id": self.run.run_id, "source_revision": self.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": self.run.binary.sha256,
"test_binary_sha256": self.run.test_binary.sha256,
"topology": {"nodes": observed.nodes, "drives_per_node": observed.drives_per_node},
"pid_before": observed.pid_before, "pid_after": observed.pid_after,
"unclean_shutdown_marker": observed.unclean_shutdown_marker,
"objects": observed.objects, "node_listings": observed.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(self.directory.join(self.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
}
@@ -32,10 +32,8 @@ use aws_sdk_s3::types::{
VersioningConfiguration,
};
use http::{Method, StatusCode};
use serde_json::Value;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
@@ -43,7 +41,6 @@ type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
@@ -84,14 +81,6 @@ const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
// of treating it as an upgrade failure.
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
struct G09EvidenceContext {
directory: PathBuf,
current_revision: String,
previous_revision: String,
run_id: String,
measurement_window_id: String,
}
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let path = std::env::var_os(SOURCE_BINARY_ENV)
.map(PathBuf::from)
@@ -102,155 +91,6 @@ fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
Ok(path)
}
fn is_lower_hex_revision(value: &str) -> bool {
value.len() == 40
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
fn current_source_revision() -> Result<String, BoxError> {
let revision = env!("RUSTFS_E2E_BUILD_COMMIT");
if !is_lower_hex_revision(revision) {
return Err(format!("current test binary has invalid source revision {revision}").into());
}
Ok(revision.to_string())
}
async fn binary_source_revision(binary: &Path) -> Result<String, BoxError> {
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
if !output.status.success() {
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
}
let version = String::from_utf8(output.stdout)?;
source_revision_from_version_output(&version, binary)
}
fn source_revision_from_version_output(version: &str, binary: &Path) -> Result<String, BoxError> {
version
.split(|ch: char| !ch.is_ascii_hexdigit())
.find(|token| is_lower_hex_revision(token))
.map(str::to_string)
.ok_or_else(|| format!("{} --version did not expose a 40-byte source revision", binary.display()).into())
}
async fn g09_evidence_context(previous_binary: &Path) -> Result<Option<G09EvidenceContext>, BoxError> {
let Some(directory) = std::env::var_os(G09_EVIDENCE_DIR_ENV) else {
return Ok(None);
};
let directory = PathBuf::from(directory);
std::fs::create_dir_all(&directory)?;
let current_revision = current_source_revision()?;
let previous_revision = binary_source_revision(previous_binary).await?;
if current_revision == previous_revision {
return Err("G09 mixed-version evidence requires distinct current and previous source revisions".into());
}
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(Some(G09EvidenceContext {
directory,
current_revision,
previous_revision,
run_id: format!("g09-upgrade-{}-{now}", std::process::id()),
measurement_window_id: format!("g09-mixed-version-window-{now}"),
}))
}
fn write_g09_evidence(
context: &G09EvidenceContext,
field: &str,
role: &str,
cases: &[&str],
test: &str,
details: Value,
) -> TestResult {
let path = context.directory.join(format!("G09-{field}.json"));
let mut evidence = serde_json::json!({
"schema": 1,
"evidence_type": "measured",
"artifact_kind": "upgrade-compatibility-e2e",
"source_revision": context.current_revision,
"run_id": context.run_id,
"measurement_window_id": context.measurement_window_id,
"gate": "G09",
"field": field,
"versions": [context.previous_revision, context.current_revision],
"current_revision": context.current_revision,
"previous_revision": context.previous_revision,
"mixed_version_role": role,
"mixed_version_cases": cases,
"test": test,
"details": details,
});
if field == "rollback_payload_evidence" {
evidence["rollback_payload_replayed"] = Value::Bool(true);
}
let data = serde_json::to_vec_pretty(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("G09 mixed-version evidence exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
output.write_all(&data)?;
output.write_all(b"\n")?;
output.sync_all()?;
Ok(())
}
#[cfg(test)]
mod g09_evidence_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn source_revision_parser_requires_lowercase_forty_byte_sha() -> TestResult {
let binary = Path::new("rustfs");
assert_eq!(
source_revision_from_version_output("rustfs 1.0.0 abcdef0123456789abcdef0123456789abcdef01 clean", binary)?,
"abcdef0123456789abcdef0123456789abcdef01"
);
assert!(source_revision_from_version_output("rustfs ABCDEF0123456789ABCDEF0123456789ABCDEF01", binary).is_err());
assert!(source_revision_from_version_output("rustfs abcdef", binary).is_err());
Ok(())
}
#[test]
fn g09_evidence_writer_records_measured_role_and_refuses_overwrite() -> TestResult {
let directory = TempDir::new()?;
let context = G09EvidenceContext {
directory: directory.path().to_path_buf(),
current_revision: "b".repeat(40),
previous_revision: "a".repeat(40),
run_id: "g09-upgrade-test-run".to_string(),
measurement_window_id: "g09-upgrade-test-window".to_string(),
};
write_g09_evidence(
&context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"unit",
serde_json::json!({"assertions": ["reader evidence"]}),
)?;
let path = directory.path().join("G09-mixed_version_reader_evidence.json");
let evidence: Value = serde_json::from_slice(&std::fs::read(path)?)?;
assert_eq!(evidence["evidence_type"], "measured");
assert_eq!(evidence["gate"], "G09");
assert_eq!(evidence["field"], "mixed_version_reader_evidence");
assert_eq!(evidence["mixed_version_role"], "mixed-version-reader");
assert_eq!(evidence["versions"], serde_json::json!(["a".repeat(40), "b".repeat(40)]));
let overwrite = write_g09_evidence(
&context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"unit",
serde_json::json!({}),
);
assert!(overwrite.is_err(), "G09 evidence must not overwrite an existing artifact");
Ok(())
}
}
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
let configuration = VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
@@ -683,7 +523,6 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let current_binary = rustfs_binary_path();
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
@@ -716,45 +555,6 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
}
}
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "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_rc2_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"
],
}),
)?;
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_rc2_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 under one-current-node and one-previous-node layouts"
],
}),
)?;
}
Ok(())
}
@@ -1336,7 +1136,6 @@ async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let replication_target = FakeS3Target::start().await?;
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
@@ -1405,54 +1204,6 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
);
assert_eq!(body, post_rollback_bytes);
env.restart_server_preserving_data(vec![], &server_env).await?;
let current_again = env.create_s3_client();
assert_versioning_enabled(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_default_sse_s3_encryption(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_bucket_tag(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after rolling forward again").await?;
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, single_key, Some(&single_version))
.await?
.1,
single_bytes
);
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, multipart_key, None).await?.1,
multipart_bytes
);
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, post_rollback_key, None).await?.1,
post_rollback_bytes
);
if let Some(context) = evidence_context.as_ref() {
write_g09_evidence(
context,
"rollback_payload_evidence",
"rollback-payload",
&["rollback-to-old", "rollback-to-new", "unknown-field-retained"],
"upgrade_compatibility_test::rollback_to_previous_release_reads_current_bucket_metadata",
serde_json::json!({
"bucket": ROLLBACK_BUCKET,
"cases": {
"rollback-to-old": [
"previous-release binary reads current-build versioning, SSE-S3, tags, replication target and objects",
"previous-release writer honors the decoded current-build encryption configuration"
],
"rollback-to-new": [
"current build reads the object written by the rolled-back previous release",
"current build reads the current-build single-part and multipart objects after rolling forward again"
],
"unknown-field-retained": [
"previous release skips current-build bucket metadata extension fields without dropping known bucket configuration",
"current build reads the retained bucket configuration after the previous-release round trip"
]
},
}),
)?;
}
replication_target.shutdown().await;
Ok(())
}
@@ -5165,9 +5165,6 @@ pub async fn put_restore_opts(
user_defined: meta,
version_id: oi.version_id.map(|e| e.to_string()),
mod_time: oi.mod_time,
// Restore writes stored (possibly encrypted) bytes, so the writer's
// computed MD5 is not the object's public plaintext ETag.
preserve_etag: oi.etag.clone(),
//expires: oi.expires,
..Default::default()
})
+21 -492
View File
@@ -2606,24 +2606,6 @@ where
usize::try_from(size).unwrap_or_default()
}
fn is_decommission_set_local_usage_cache(bucket: &str, object: &str) -> bool {
if bucket != RUSTFS_META_BUCKET {
return false;
}
let Some(path) = object
.strip_prefix(BUCKET_META_PREFIX)
.and_then(|path| path.strip_prefix('/'))
else {
return false;
};
let name = match path.rsplit_once('/') {
Some((bucket, name)) if !bucket.is_empty() && !bucket.contains('/') && bucket != "." && bucket != ".." => name,
Some(_) => return false,
None => path,
};
name.strip_suffix(".bkp").unwrap_or(name) == DATA_USAGE_CACHE_NAME
}
fn with_decommission_entry_context<E: Display>(stage: &str, bucket: &str, object: &str, err: E) -> Error {
Error::other(format!("decommission entry {stage} failed for bucket {bucket} object {object}: {err}"))
}
@@ -3481,11 +3463,6 @@ impl PoolRebalanceActivationFence {
}
}
#[cfg(test)]
tokio::task_local! {
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
}
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
pool: Arc<S>,
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
@@ -3496,21 +3473,17 @@ where
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
>,
{
// Match entry admission: rebalance.bin -> pool.bin. An entry retains its
// run read fence while target mutations acquire the pool metadata fence;
// activation must not hold pool.bin while waiting for that entry to drain.
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
#[cfg(test)]
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
let rebalance_meta_guard = rebalance_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_rebalance_meta_lock_error)?;
// Activation lock order is always pool.bin -> rebalance.bin.
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
let pool_meta_guard = pool_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_pool_meta_lock_error)?;
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
let rebalance_meta_guard = rebalance_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_rebalance_meta_lock_error)?;
Ok(PoolRebalanceActivationFence {
pool_meta_guard,
@@ -13312,12 +13285,6 @@ impl ECStore {
);
return Ok(DecommissionEntryAttemptOutcome::Complete);
}
// Scanner caches describe their own erasure set and are rebuilt there.
// Copying one onto another set can overwrite unrelated cache contents or
// leave an unresolvable target-capacity intent after a conditional PUT.
if is_decommission_set_local_usage_cache(&bucket, &entry.name) {
return Ok(DecommissionEntryAttemptOutcome::Complete);
}
let durable_ilm_record = if bucket == RUSTFS_META_BUCKET {
classify_durable_ilm_record(&entry.name)
.map_err(|err| with_decommission_entry_context("durable_ilm_namespace", &bucket, &entry.name, err))?
@@ -13875,7 +13842,7 @@ impl ECStore {
let bucket = bucket.clone();
let read_result = set
let rd = match set
.get_object_reader(
bucket.as_str(),
&encode_dir_object(&version.name),
@@ -13883,11 +13850,8 @@ impl ECStore {
HeaderMap::new(),
&decommission_object_migration_read_opts(version_id.clone()),
)
.await;
#[cfg(test)]
let read_result =
decommission_test_wrap_result("object_read", &bucket, &version.name, version_attempt, read_result);
let rd = match read_result {
.await
{
Ok(rd) => rd,
Err(err) => {
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
@@ -13896,6 +13860,15 @@ impl ECStore {
break;
}
if !ignore {
//
if bucket == RUSTFS_META_BUCKET && version.name.contains(DATA_USAGE_CACHE_NAME) {
ignore = true;
error!("decommission_pool: ignore data usage cache {}", &version.name);
break;
}
}
failure = true;
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
error!(
@@ -16863,7 +16836,7 @@ impl ECStore {
return;
}
if is_decommission_set_local_usage_cache(&bucket_name, &entry.name) {
if bucket_name == RUSTFS_META_BUCKET && entry.name.contains(DATA_USAGE_CACHE_NAME) {
return;
}
@@ -17181,361 +17154,6 @@ mod tests {
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use serde::Serialize;
#[test]
fn decommission_set_local_usage_cache_classification_is_exact() {
for object in [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
] {
assert!(is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
assert!(!is_decommission_set_local_usage_cache("user-bucket", object), "{object}");
}
for object in [
"buckets/.usage.v2.json",
"buckets/.usage.v2.json.bkp",
"buckets/.usage-cache.bin.extra",
"buckets/.usage-cache.bin.bkp.extra",
"buckets/prefix.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp.bkp",
"buckets/photos/nested/.usage-cache.bin",
"buckets//.usage-cache.bin",
"buckets/../.usage-cache.bin",
"buckets/./.usage-cache.bin",
"buckets/.usage-cache.bin/child",
"config/.usage-cache.bin",
"buckets-other/.usage-cache.bin",
".usage-cache.bin",
] {
assert!(!is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
}
}
#[tokio::test]
#[serial_test::serial]
async fn decommission_keeps_set_local_usage_caches_out_of_target_capacity() {
use crate::object_api::PutObjReader;
use tokio::io::AsyncReadExt as _;
// Keep the scenario's large setup and migration futures off the test
// future so ordinary metadata I/O retains the default thread stack.
let (_temp_dirs, store, _other_store) =
Box::pin(crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None)).await;
let user_bucket = "decommission-usage-cache-control";
Box::pin(store.make_bucket(user_bucket, &MakeBucketOptions::default()))
.await
.expect("create the ordinary-object control bucket");
let incarnation = Box::pin(store.bucket_incarnation_id(user_bucket))
.await
.expect("control bucket incarnation");
let source_time = OffsetDateTime::now_utc();
let cache_objects = [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
];
let conflict_object = "buckets/.usage-cache.bin.conflict";
let source_read_failure_object = "buckets/.usage-cache.bin.read-error";
let source_body = b"source set cache";
let target_body = b"independent older target set cache";
for object in cache_objects.into_iter().chain([conflict_object]) {
for (pool_index, body, mod_time) in [
(0, source_body.as_slice(), source_time),
(1, target_body.as_slice(), source_time - Duration::seconds(1)),
] {
// Scanner cache persistence writes directly to its own set.
store.pools[pool_index]
.get_disks_by_key(object)
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(body.to_vec()),
&ObjectOptions {
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed distinct native set-local objects");
}
}
let controls = [
(RUSTFS_META_BUCKET, "buckets/.usage.v2.json"),
(RUSTFS_META_BUCKET, "buckets/photos/.usage-cache.bin.extra"),
(user_bucket, "ordinary-object"),
(user_bucket, "buckets/.usage-cache.bin"),
];
for (bucket, object) in controls {
store.pools[0]
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(b"ordinary object contents".to_vec()),
&ObjectOptions {
expected_bucket_incarnation_id: (bucket == user_bucket).then_some(incarnation),
..Default::default()
},
)
.await
.expect("seed a control that must migrate");
}
store.pools[0]
.put_object(
RUSTFS_META_BUCKET,
source_read_failure_object,
&mut PutObjReader::from_vec(source_body.to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed a similarly named object whose source read will fail");
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
]],
);
Box::pin(store.save_current_pool_meta_for_decommission_start(&[0], Vec::new()))
.await
.expect("activate the decommission capacity reservation");
for object in cache_objects {
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(object),
))
.await
.expect("set-local cache must not enter cross-pool migration");
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
let mut reader = store.pools[pool_index]
.get_disks_by_key(object)
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("each set must retain its own cache");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read retained cache");
assert_eq!(actual, expected, "pool {pool_index}, {object}");
}
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("decommission progress");
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 0));
assert_eq!((info.bytes_done, info.bytes_failed), (0, 0));
let reservation = info.capacity_reservation.as_ref().expect("capacity reservation");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(reservation.consumed_target_physical_bytes, 0);
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
}
let mut persisted = PoolMeta::default();
Box::pin(persisted.load_no_lock_from_replicas(store.pools.clone()))
.await
.expect("reload durable capacity intents after cache entries");
let reservation = persisted.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("durable reservation");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
let injected_reads = Arc::new(AtomicUsize::new(0));
let observed_reads = Arc::clone(&injected_reads);
let read_fault = DecommissionTestFaultGuard::install(Arc::new(move |stage, bucket, object, _, success| {
if stage == "object_read" && bucket == RUSTFS_META_BUCKET && object == source_read_failure_object && success {
observed_reads.fetch_add(1, Ordering::SeqCst);
return true;
}
false
}));
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: source_read_failure_object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(source_read_failure_object),
))
.await
.expect("entry must record the non-NotFound source read failure");
drop(read_fault);
assert_eq!(injected_reads.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
{
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("source read failure progress");
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 1));
assert_eq!(info.bytes_failed, source_body.len());
assert_eq!(
info.capacity_reservation
.as_ref()
.expect("reservation")
.pending_target_physical_bytes,
0
);
}
let mut retained = store.pools[0]
.get_object_reader(
RUSTFS_META_BUCKET,
source_read_failure_object,
None,
HeaderMap::new(),
&ObjectOptions::default(),
)
.await
.expect("source read failure must retain the source");
let mut retained_body = Vec::new();
retained
.stream
.read_to_end(&mut retained_body)
.await
.expect("read retained source");
assert_eq!(retained_body, source_body);
drop(retained);
let target_err = store.pools[1]
.get_object_info(RUSTFS_META_BUCKET, source_read_failure_object, &ObjectOptions::default())
.await
.expect_err("failed source read must not create a target object");
assert!(is_err_object_not_found(&target_err), "unexpected target state: {target_err:?}");
for (bucket, object) in controls {
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.to_string(),
store.pools[0].get_disks_by_key(object),
))
.await
.expect("ordinary and similarly named objects must migrate");
let mut reader = store.pools[1]
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("control must exist on the target");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read migrated control");
assert_eq!(actual, b"ordinary object contents", "{bucket}/{object}");
let err = store.pools[0]
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect_err("migrated control must be removed from the source");
assert!(is_err_object_not_found(&err), "{bucket}/{object}: {err:?}");
}
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: conflict_object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(conflict_object),
))
.await
.expect("entry must record a real conditional-copy failure");
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("final progress");
assert_eq!(info.items_decommissioned, controls.len());
assert_eq!(
info.items_decommission_failed, 2,
"similar names must not hide read or migration failures"
);
assert_eq!(info.bytes_failed, source_body.len() * 2);
drop(meta);
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
let mut reader = store.pools[pool_index]
.get_object_reader(RUSTFS_META_BUCKET, conflict_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("failed migration must preserve both objects");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read conflict object");
assert_eq!(actual, expected);
}
}
#[tokio::test]
#[serial_test::serial]
async fn decommission_final_sweep_excludes_only_set_local_usage_caches() {
use crate::object_api::PutObjReader;
let (_temp_dirs, store, _other_store) =
crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None).await;
for object in [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
] {
store.pools[0]
.get_disks_by_key(object)
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(b"set-local cache".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed each supported set-local cache path");
}
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
]],
);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("activate the final-sweep generation");
let generation = store.active_decommission_generation(0).await.expect("active generation");
store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect("the four set-local cache forms must not block the final sweep");
for object in [
"buckets/.usage-cache.bin.extra",
"buckets/photos/.usage-cache.bin.bkp.extra",
"buckets/.usage.v2.json",
] {
let source_set = store.pools[0].get_disks_by_key(object);
source_set
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(b"unmigrated ordinary metadata".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed ordinary metadata that must prevent completion");
let err = store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect_err("a remaining similar name or global usage snapshot must block completion");
assert!(err.to_string().contains("after decommissioning"), "unexpected final-sweep error: {err:?}");
assert!(err.to_string().contains(object), "the final sweep must identify {object}: {err:?}");
source_set
.delete_object(RUSTFS_META_BUCKET, object, ObjectOptions::default())
.await
.expect("remove only the ordinary-metadata control before the next sweep");
}
store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect("only the four set-local caches remain after removing the controls");
}
#[test]
fn pool_activation_fleet_proof_error_classifier_matches_only_retryable_proof_failures() {
assert!(is_pool_activation_fleet_proof_error(&Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED)));
@@ -20247,55 +19865,6 @@ mod tests {
assert!(!is_decommission_copy_cleanup_safe_error(&wrap(Error::SlowDown)));
}
#[test]
fn decommission_target_gate_retry_recognizes_multipart_part_errors() {
let wrap = |inner: Error| {
data_movement::data_movement_part_stage_error_for_test(
"decommission_object",
"put_object_part",
"bucket-a",
"object-a",
1,
inner,
)
};
let gate_busy_message =
format!("{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX}7{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX}");
let wrapped = wrap(decommission_capacity_blocked_error(&gate_busy_message));
assert!(is_decommission_capacity_target_gate_busy(&wrapped));
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), Some(7));
assert_eq!(
wrapped.to_string(),
format!(
"Io error: decommission_object: put_object_part failed for bucket-a/object-a part 1: {}",
decommission_capacity_blocked_error(&gate_busy_message)
)
);
for unrelated in [
Error::SlowDown,
Error::DiskFull,
decommission_capacity_blocked_error("target capacity is exhausted"),
Error::other(gate_busy_message),
] {
let wrapped = wrap(unrelated);
assert!(!is_decommission_capacity_target_gate_busy(&wrapped));
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), None);
}
for missing_target in [
Error::FileNotFound,
Error::ObjectNotFound("bucket-a".to_string(), "object-a".to_string()),
Error::VersionNotFound("bucket-a".to_string(), "object-a".to_string(), "version-a".to_string()),
] {
assert!(is_decommission_copy_cleanup_safe_error(&missing_target));
assert!(
!is_decommission_copy_cleanup_safe_error(&wrap(missing_target)),
"a missing target part must never authorize source cleanup"
);
}
assert!(is_decommission_target_capacity_error(&wrap(Error::DiskFull)));
}
#[test]
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
@@ -22525,7 +22094,7 @@ mod pools_tests {
.resources
.lock()
.expect("activation lock recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
);
let mut second_acquire = Box::pin(acquire_pool_rebalance_activation_locks(second.clone(), None));
@@ -22541,50 +22110,10 @@ mod pools_tests {
.resources
.lock()
.expect("activation lock recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
);
}
#[tokio::test]
async fn test_activation_cancellation_releases_rebalance_fence_while_pool_fence_is_contended() {
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
let pool = Arc::new(ActivationLockRecorder {
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
owner: "activation-cancellation",
resources: StdMutex::new(Vec::new()),
});
let pool_lock = pool
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("pool lock should be created");
let pool_reader = pool_lock
.get_read_lock(std::time::Duration::from_secs(5))
.await
.expect("ordinary mutation should hold the pool read fence");
pool.resources.lock().expect("recorder should not be poisoned").clear();
let mut activation = Box::pin(acquire_pool_rebalance_activation_locks(Arc::clone(&pool), None));
assert!(matches!(futures::poll!(&mut activation), Poll::Pending));
assert_eq!(
*pool.resources.lock().expect("recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()],
"activation must hold the run fence before waiting for the pool fence",
);
drop(activation);
let rebalance_lock = pool
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, REBAL_META_NAME)
.await
.expect("run lock should be created");
let run_writer = rebalance_lock
.get_write_lock(std::time::Duration::from_secs(5))
.await
.expect("cancelling activation must release its already-acquired run fence");
assert!(
!pool_reader.is_released(),
"cancelling activation must not release another caller's pool fence"
);
assert!(!run_writer.is_lock_lost());
}
#[test]
fn decommission_receipt_run_token_changes_with_persisted_start_time() {
let first = OffsetDateTime::from_unix_timestamp(1_000).expect("first run timestamp should be valid");
+4 -29
View File
@@ -1521,27 +1521,9 @@ fn data_movement_part_stage_error(
bucket: &str,
object: &str,
part_number: usize,
err: Error,
err: impl std::fmt::Display,
) -> Error {
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
return data_movement_context_error(rendered, err);
}
// A missing target part is not evidence that the source can be deleted.
// Keep other part errors opaque to the source-cleanup classifiers.
Error::other(rendered)
}
#[cfg(test)]
pub(crate) fn data_movement_part_stage_error_for_test(
op_label: &str,
stage: &str,
bucket: &str,
object: &str,
part_number: usize,
err: Error,
) -> Error {
data_movement_part_stage_error(op_label, stage, bucket, object, part_number, err)
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}"))
}
fn is_data_movement_part_read_error(err: &Error) -> bool {
@@ -2446,15 +2428,8 @@ mod tests {
let err =
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
let message = err.to_string();
assert_eq!(
message,
Error::other(format!(
"rebalance_object: put_object_part failed for bucket-a/object-a part 7: {}",
Error::SlowDown
))
.to_string()
);
assert!(data_movement_stage_source(&err).is_none());
assert!(message.contains("rebalance_object: put_object_part failed for bucket-a/object-a part 7"));
assert!(message.contains(Error::SlowDown.to_string().as_str()));
}
#[test]
-2
View File
@@ -3397,7 +3397,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
partial.buckets_usage.insert(
"bucket".to_string(),
@@ -3470,7 +3469,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
}],
..Default::default()
};
-106
View File
@@ -22543,112 +22543,6 @@ mod test {
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_storage_full_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
.await
.expect("previous MRF manifest should commit"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_0,
Some(previous_manifest.clone()),
Some(successor_manifest),
)
.await
.expect_err("storage-full fsync failure must fail the MRF manifest successor commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("previous committed MRF manifest should remain readable after storage-full rollback"),
previous_manifest
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after storage-full manifest publication failure"),
legacy_journal
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_storage_full_delete_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let committed_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(committed_manifest.clone()),)
.await
.expect("committed MRF manifest should publish"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
let err = disk
.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, Some(committed_manifest.clone()), None)
.await
.expect_err("storage-full fsync failure must fail the MRF manifest cleanup delete");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("committed MRF manifest should be restored after failed cleanup delete"),
committed_manifest
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after failed cleanup delete"),
legacy_journal
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
+16 -2
View File
@@ -570,11 +570,25 @@ pub(crate) async fn initialize_local_disk_maps(
Ok(())
}
#[cfg(test)]
tokio::task_local! {
pub(crate) static TEST_TIER_CONFIG_MGR: Arc<tokio::sync::RwLock<TierConfigMgr>>;
}
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
#[cfg(not(test))]
let handle = get_global_tier_config_mgr();
TierConfigMgr::reload_handle(&handle, store.clone()).await?;
#[cfg(test)]
let handle = TEST_TIER_CONFIG_MGR
.try_with(Arc::clone)
.unwrap_or_else(|_| get_global_tier_config_mgr());
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
if initial_reload.is_err() {
// Keep local recovery active when the initial snapshot cannot be loaded.
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
}
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
Ok(())
initial_reload.map_err(Error::from)
}
#[cfg(test)]
@@ -572,7 +572,7 @@ impl ECStore {
where
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
{
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin.
// Lock order: pool_meta_save_gate -> pool.bin -> rebalance.bin.
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
// Classify the durable rebalance record while holding both namespace
@@ -50,11 +50,6 @@ fn ensure_rebalance_entry_active(cancel: &CancellationToken) -> Result<()> {
Ok(())
}
#[cfg(test)]
tokio::task_local! {
static REBALANCE_ENTRY_RUN_FENCE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
}
#[derive(Debug)]
struct RebalanceEntryTarget {
bucket: String,
@@ -261,15 +256,9 @@ impl ECStore {
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
// Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate.
// Target capacity admission can then acquire pool.bin under the run fence.
// Stop waits for in-flight entries through cleanup, but not for entries admitted later.
ensure_rebalance_entry_active(&cancel)?;
let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?;
#[cfg(test)]
if let Ok((arrived, release)) = REBALANCE_ENTRY_RUN_FENCE_BARRIER.try_with(Clone::clone) {
arrived.notify_one();
release.notified().await;
}
let lock_lost_signal = run_guard.lock_lost_signal();
#[cfg(test)]
let _run_signal_test_fence = lock_lost_signal
@@ -1248,130 +1237,6 @@ mod tests {
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
}
#[tokio::test]
#[serial_test::serial]
async fn real_rebalance_entry_progresses_while_peer_activation_waits_for_run_fence() {
const REBALANCE_ID: &str = "rebalance-peer-activation-lock-order";
let (_temp_dirs, store, peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(
active_rebalance_meta(REBALANCE_ID),
))
.await;
assert!(!Arc::ptr_eq(&store.ctx, &peer.ctx), "node-local movement gates must be independent");
{
let mut meta = peer.rebalance_meta.write().await;
let meta = meta.as_mut().expect("peer should know the durable run");
meta.activation_gate = Arc::default();
meta.cancel = None;
}
let bucket = crate::disk::RUSTFS_META_BUCKET;
let object = "rebalance-peer-activation-object";
let version_id = uuid::Uuid::new_v4();
let payload = b"entry must drain before peer activation takes the pool fence".repeat(1024);
let source_set = store.pools[0].get_disks_by_key(object);
let target_set = store.pools[1].get_disks_by_key(object);
let opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
let mut writer = PutObjReader::from_vec(payload.clone());
let source_before = source_set
.put_object(bucket, object, &mut writer, &opts)
.await
.expect("source version should be written");
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
let arrived = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
// JoinSet aborts both scoped tasks if an assertion or timeout fails.
let mut tasks = tokio::task::JoinSet::new();
let entry_store = Arc::clone(&store);
tasks.spawn(
REBALANCE_ENTRY_RUN_FENCE_BARRIER.scope((Arc::clone(&arrived), Arc::clone(&release)), async move {
entry_store
.rebalance_entry(
RebalanceEntryTarget {
bucket: bucket.to_string(),
pool_index: 0,
},
entry,
source_set,
Arc::new(RebalanceBucketConfigs::default()),
Arc::from(REBALANCE_ID),
CancellationToken::new(),
)
.await
}),
);
tokio::time::timeout(StdDuration::from_secs(30), arrived.notified())
.await
.expect("real entry must acquire its persisted run read fence");
let attempted = Arc::new(tokio::sync::Notify::new());
let peer_pool = Arc::clone(&peer.pools[0]);
let (activation_done, activation_result) = tokio::sync::oneshot::channel();
tasks.spawn(
crate::core::pools::REBALANCE_ACTIVATION_LOCK_ATTEMPT.scope(Arc::clone(&attempted), async move {
let result = peer.fence_rebalance_worker_activation(peer_pool, REBALANCE_ID).await;
let result = result.map(|fence| match fence {
super::super::control::RebalanceWorkerActivationFence::Ready(fence) => {
fence.ensure_held().expect("peer activation must retain both fences");
}
super::super::control::RebalanceWorkerActivationFence::NotStartedTerminal => {
panic!("the paused entry's run must still require activation");
}
});
activation_done.send(result).expect("activation receiver should remain alive");
Ok(RebalanceEntryOutcome::Completed)
}),
);
tokio::time::timeout(StdDuration::from_secs(30), attempted.notified())
.await
.expect("peer activation must attempt the persisted rebalance write fence");
release.notify_one();
tokio::time::timeout(StdDuration::from_secs(30), async {
while let Some(result) = tasks.join_next().await {
assert!(matches!(
result
.expect("scoped task must not panic")
.expect("entry must not fail or defer"),
RebalanceEntryOutcome::Completed
));
}
})
.await
.expect("entry and peer activation must both make progress");
activation_result
.await
.expect("peer activation result should be sent")
.expect("peer activation must not time out behind the entry it blocks");
let mut reader = target_set
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
.await
.expect("the exact target version must be readable");
let mut actual = Vec::new();
reader
.stream
.read_to_end(&mut actual)
.await
.expect("target body should drain completely");
assert_eq!(actual, payload);
assert_eq!(reader.object_info.version_id, source_before.version_id);
assert_eq!(reader.object_info.etag, source_before.etag);
assert_eq!(reader.object_info.mod_time, source_before.mod_time);
let source_error = store.pools[0]
.get_object_info(bucket, object, &opts)
.await
.expect_err("completed entry must clean up the source version");
assert!(crate::error::is_err_object_not_found(&source_error) || crate::error::is_err_version_not_found(&source_error));
let meta = store.rebalance_meta.read().await;
let stats = &meta.as_ref().expect("local run must remain installed").pool_stats[0];
assert_eq!(stats.num_objects, 1);
assert_eq!(stats.num_versions, 1);
assert_eq!(stats.cleanup_warnings.count, 0);
}
#[tokio::test]
#[serial_test::serial]
async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() {
@@ -1907,124 +1907,6 @@ fn test_is_transient_rebalance_error_accepts_wrapped_disk_timeout() {
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout))));
}
#[test]
fn test_rebalance_stage_wrapped_transient_errors_remain_retryable() {
let cases = [
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
Error::SlowDown,
Error::ErasureReadQuorum,
Error::ErasureWriteQuorum,
Error::Io(std::io::Error::other(DiskError::Timeout)),
Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)),
];
for mut error in cases {
for depth in 0..=3 {
assert!(is_transient_rebalance_error(&error), "transient source lost at depth {depth}: {error:?}");
assert!(
should_defer_rebalance_entry_failure(&error),
"exhausted transient entries must be deferred"
);
assert!(should_retry_rebalance_listing(&error, 0, 3));
assert!(
!should_retry_rebalance_listing(&error, 2, 3),
"wrapping must not bypass the attempt limit"
);
error = data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
error,
);
}
}
}
#[test]
fn test_rebalance_stage_wrapped_terminal_errors_remain_terminal() {
let cases = [
Error::FileAccessDenied,
Error::FileCorrupt,
Error::OperationCanceled,
Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string()),
Error::Lock(rustfs_lock::LockError::already_locked("bucket/object", "owner")),
Error::other("permission denied"),
];
for mut error in cases {
for depth in 0..=3 {
assert!(
!is_transient_rebalance_error(&error),
"terminal source must survive depth {depth}: {error:?}"
);
assert!(!should_defer_rebalance_entry_failure(&error));
// Object names are untrusted context, not evidence of a transient failure.
error = data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"remote lock rpc timed out",
error,
);
}
}
}
#[tokio::test]
async fn test_rebalance_stage_wrapped_lock_timeout_retries_real_migration_loop() {
for succeeds_on_retry in [true, false] {
let backend = MigrationBackendSpy::new(None, None);
let attempts = AtomicUsize::new(0);
let waits = AtomicUsize::new(0);
let mut transfer = |_, _, _| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if succeeds_on_retry && attempt > 0 {
return Ok(());
}
Err(data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
))
}
};
let version = version_normal();
let result = migrate_entry_version_with_retry_wait(
&backend,
"bucket".to_string(),
0,
&version,
None,
3,
false,
&mut transfer,
|_: String, _: String, _: ObjectOptions| async { Ok::<_, Error>(ObjectInfo::default()) },
|_| {
waits.fetch_add(1, Ordering::SeqCst);
std::future::ready(())
},
)
.await;
assert_eq!(result.moved, succeeds_on_retry);
assert_eq!(result.failed, !succeeds_on_retry);
assert_eq!(attempts.load(Ordering::SeqCst), if succeeds_on_retry { 2 } else { 3 });
assert_eq!(backend.get_calls(), attempts.load(Ordering::SeqCst));
assert_eq!(waits.load(Ordering::SeqCst), attempts.load(Ordering::SeqCst) - 1);
if !succeeds_on_retry {
assert_eq!(result.stage, Some("write_target"));
assert!(should_defer_rebalance_entry_failure(
result.error.as_ref().expect("exhaustion must retain its source error")
));
}
}
}
#[test]
fn test_is_transient_rebalance_error_accepts_io_timeout_message() {
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout"))));
@@ -244,7 +244,6 @@ pub(super) fn resolve_rebalance_bucket_result(
}
pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
let err = rebalance_error_source(err);
match err {
Error::SlowDown
| Error::ErasureReadQuorum
@@ -257,15 +256,6 @@ pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
}
}
fn rebalance_error_source(mut err: &Error) -> &Error {
// Stage context contains object names, so classify the preserved source,
// not timeout-like text supplied by an object name. Iterate nested stages.
while let Some(source) = crate::data_movement::data_movement_stage_source(err) {
err = source;
}
err
}
fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
match err {
rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true,
@@ -319,7 +309,6 @@ pub(super) fn rebalance_listing_retry_delay(attempt: usize) -> Duration {
}
fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
let err = rebalance_error_source(err);
match err {
Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true,
Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()),
@@ -596,48 +585,3 @@ impl SetDisks {
Ok(())
}
}
#[cfg(test)]
mod error_source_tests {
use super::*;
#[test]
fn stage_wrapped_errors_select_the_source_backoff_policy() {
let cases = [
(
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
true,
),
(
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
true,
),
(Error::other("remote lock rpc timed out"), true),
(Error::SlowDown, false),
(Error::Io(std::io::Error::other(DiskError::Timeout)), false),
(Error::FileAccessDenied, false),
];
for (mut error, lock_backoff) in cases {
for depth in 0..=3 {
assert_eq!(
is_rebalance_lock_or_rpc_timeout(&error),
lock_backoff,
"wrong backoff at depth {depth}: {error:?}"
);
if !lock_backoff {
assert_eq!(rebalance_migration_retry_delay(1, &error), REBALANCE_MIGRATION_RETRY_BASE_DELAY * 2);
}
error = crate::data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"remote lock rpc timed out",
error,
);
}
}
}
}
+6 -322
View File
@@ -9209,11 +9209,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let dest_obj = transaction.remote_object.clone();
let mut transition_meta = (*oi.user_defined).clone();
rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
// The tier holds opaque stored bytes. Its metadata must not be treated
// as a second object header set: forwarding SSE intent or wrapped DEKs
// would request a second encryption pass and disclose local envelope
// material to the remote provider.
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
transition_meta.insert("name".to_string(), object.to_string());
rustfs_utils::http::metadata_compat::insert_str(
&mut transition_meta,
@@ -9770,16 +9765,13 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
part_opts.part_number = Some(part_info.number);
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?;
if part_info.size == 0 {
return Err(Error::other(format!("invalid multipart restore stored part size {}", part_info.size)));
if part_info.actual_size <= 0 {
return Err(Error::other(format!("invalid multipart restore part size {}", part_info.actual_size)));
}
let stored_part_size = i64::try_from(part_info.size).map_err(|_| {
Error::other(format!("multipart restore stored part size exceeds i64: {}", part_info.size))
})?;
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?;
let part_end = part_offset
.checked_add(stored_part_size - 1)
.checked_add(part_info.actual_size - 1)
.ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?;
let rs = Some(HTTPRangeSpec {
is_suffix_length: false,
@@ -9807,19 +9799,13 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?;
let hash_reader =
HashReader::from_stream(reader, stored_part_size, part_info.actual_size, None, None, false)?;
HashReader::from_stream(reader, part_info.actual_size, part_info.actual_size, None, None, false)?;
let mut p_reader = PutObjReader::new(hash_reader);
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?;
// `ropts` carries the object's ETag so the single-part copy-back
// keeps it (the writer only ever sees stored bytes). A part write
// must not inherit that object-level value, or every restored part
// would be recorded under the same ETag; each part keeps its own.
let mut part_write_opts = ropts.clone();
part_write_opts.preserve_etag = Some(part_info.etag.clone()).filter(|etag| !etag.is_empty());
let p_info = self_
.clone()
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &part_write_opts)
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &ropts)
.await?;
#[cfg(test)]
let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) {
@@ -9829,7 +9815,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
} else {
p_info
};
if p_info.size as i64 != stored_part_size {
if p_info.size as i64 != part_info.actual_size {
return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError {
bucket: bucket.to_string(),
object: object.to_string(),
@@ -9861,11 +9847,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
user_defined: restore_commit_metadata,
no_lock: false,
decommission_capacity_admission: opts.decommission_capacity_admission.clone(),
// The composite ETag would otherwise be recomputed from the
// parts as they were written back, which for an encrypted or
// compressed object digests stored bytes rather than the
// object's public ETag.
preserve_etag: oi.etag.clone(),
..Default::default()
};
self_
@@ -13507,303 +13488,6 @@ mod transition_commit_failure_tests {
);
}
/// backlog#2368 B5: the tier stores opaque bytes, so the archive request
/// must not carry the object's encryption headers. Forwarding them made
/// every S3 target reject an SSE-C archive outright, asked the target to
/// encrypt an SSE-KMS object a second time under a key id it does not own,
/// and handed the wrapped DEK to a third-party provider.
#[tokio::test]
#[serial_test::serial]
async fn transition_does_not_forward_encryption_metadata_to_the_tier() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "transition-encryption-metadata-bucket";
let object = "object.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let encryption_metadata = [
("x-amz-server-side-encryption", "aws:kms"),
("x-amz-server-side-encryption-aws-kms-key-id", "arn:aws:kms:us-east-1:123:key/abc"),
("x-amz-server-side-encryption-customer-algorithm", "AES256"),
(rustfs_utils::http::INTERNAL_ENCRYPTION_KEY_HEADER, "d3JhcHBlZC1kZWs="),
(rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER, "AAAAAAAAAAAAAAAA"),
(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER, "AES256"),
];
let mut user_defined: HashMap<String, String> = encryption_metadata
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
user_defined,
..Default::default()
},
)
.await
.expect("the encrypted source object should be written");
let original = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the source object should be readable");
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
set_disks
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("the encrypted object should transition");
let transitioned = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the transitioned object should be readable");
let remote_metadata = backend
.metadata(&transitioned.transitioned_object.name)
.await
.expect("the tier must have received the object");
for (key, _) in encryption_metadata {
assert!(
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
"transition must not forward {key} to the tier: {remote_metadata:?}"
);
}
assert!(
remote_metadata
.iter()
.any(|(key, value)| key.eq_ignore_ascii_case("x-amz-meta-owner") && value == "finance"),
"ordinary user metadata must still travel to the tier: {remote_metadata:?}"
);
// Read-through and restore both resolve encryption locally, so the
// stripped keys must survive untouched in the local metadata.
for (key, value) in encryption_metadata {
assert_eq!(
transitioned.user_defined.get(key).map(String::as_str),
Some(value),
"the local copy must keep {key}"
);
}
}
/// Deterministic bytes that do not repeat with a short period, so a slice
/// taken at the wrong offset cannot coincidentally compare equal.
fn stored_representation_bytes(seed: u32, len: usize) -> Vec<u8> {
(0..len)
.map(|index| {
let mixed = (index as u32).wrapping_add(seed).wrapping_mul(2_654_435_761);
(mixed >> 13) as u8
})
.collect()
}
/// Compares two stored representations without dumping megabytes of bytes
/// into the failure output.
fn assert_stored_representation_eq(actual: &[u8], expected: &[u8], what: &str) {
assert_eq!(actual.len(), expected.len(), "{what}: stored length differs");
if let Some(offset) = actual.iter().zip(expected).position(|(left, right)| left != right) {
panic!(
"{what}: stored bytes differ at offset {offset} (found {:#04x}, expected {:#04x})",
actual[offset], expected[offset]
);
}
}
async fn read_stored_representation(set_disks: &Arc<SetDisks>, bucket: &str, object: &str) -> Vec<u8> {
let mut reader = set_disks
.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("stored-representation reader should open");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("stored body should drain");
body
}
/// backlog#2368 B3: the multipart restore loop addresses the tier in STORED
/// coordinates. Accumulating each part's PLAINTEXT length instead handed
/// every part a misaligned slice of the remote object whose length still
/// satisfied the range, the `HashReader` and the completion size check, so
/// the copy-back reported success while silently replacing the bytes.
///
/// The fixture reproduces the encrypted geometry — a stored form LONGER
/// than the plaintext it encodes — because that is what keeps a
/// plaintext-coordinate range inside the tier object and makes the
/// corruption silent rather than a short read.
#[tokio::test]
#[serial_test::serial]
async fn multipart_restore_copies_the_stored_representation_back_verbatim() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "restore-multipart-stored-coordinates-bucket";
let object = "object.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
// The minimum-part-size gate reads the PLAINTEXT length, so part one
// clears 5 MiB there while its stored form carries encoding overhead.
let part_shapes = [(6 * 1024 * 1024_usize, 9_216_usize), (256 * 1024_usize, 512_usize)];
let mut user_defined = HashMap::new();
user_defined.insert(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "AES256".to_string());
user_defined.insert(
rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
"AAAAAAAAAAAAAAAA".to_string(),
);
let upload = set_disks
.new_multipart_upload(
bucket,
object,
&ObjectOptions {
user_defined: user_defined.clone(),
..Default::default()
},
)
.await
.expect("multipart upload should be created");
let mut uploaded_parts = Vec::new();
let mut expected_stored = Vec::new();
for (index, (plaintext_len, overhead)) in part_shapes.iter().enumerate() {
let stored = stored_representation_bytes(index as u32 * 7 + 1, plaintext_len + overhead);
expected_stored.extend_from_slice(&stored);
let stored_len = stored.len() as i64;
let hash_reader =
HashReader::from_stream(std::io::Cursor::new(stored), stored_len, *plaintext_len as i64, None, None, false)
.expect("hash reader over the stored representation");
let mut reader = PutObjReader::new(hash_reader);
let info = set_disks
.put_object_part(bucket, object, &upload.upload_id, index + 1, &mut reader, &ObjectOptions::default())
.await
.expect("stored part should be staged");
assert_eq!(info.size as i64, stored_len, "a part is stored in its encoded length");
uploaded_parts.push(CompletePart {
part_num: info.part_num,
etag: info.etag,
..Default::default()
});
}
let original = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, uploaded_parts, &ObjectOptions::default())
.await
.expect("source multipart upload should complete");
let original_parts: Vec<(usize, usize, i64, String)> = original
.parts
.iter()
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
.collect();
for (_, size, actual_size, _) in &original_parts {
assert!(
*size as i64 > *actual_size,
"the fixture must keep the two coordinate systems apart: stored {size} vs plaintext {actual_size}"
);
}
let stored_before = read_stored_representation(&set_disks, bucket, object).await;
assert_stored_representation_eq(&stored_before, &expected_stored, "the fixture must store its encoded bytes verbatim");
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
set_disks
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("multipart source should transition before restore");
let operation_id = Uuid::new_v4();
set_disks
.put_object_metadata(
bucket,
object,
&ObjectOptions {
eval_metadata: Some(restore_metadata(operation_id, true)),
..Default::default()
},
)
.await
.expect("the restore generation should be installed");
let mut restore_opts = ObjectOptions::default();
restore_opts.transition.restore_request.days = Some(1);
restore_opts.user_defined = restore_operation_id_metadata(operation_id);
set_disks
.clone()
.restore_transitioned_object(bucket, object, &restore_opts)
.await
.expect("multipart restore should complete");
let stored_after = read_stored_representation(&set_disks, bucket, object).await;
assert_stored_representation_eq(
&stored_after,
&expected_stored,
"a multipart restore must copy the stored representation back verbatim",
);
let restored = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the restored object should be readable");
let restored_parts: Vec<(usize, usize, i64, String)> = restored
.parts
.iter()
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
.collect();
assert_eq!(
restored_parts, original_parts,
"restore must rebuild the same part layout, sizes and part ETags"
);
assert_eq!(restored.size, original.size, "restore must keep the stored object size");
// backlog#2369 P7.1: the copy-back digests stored bytes, so the object's
// public ETag has to be carried over rather than recomputed.
assert_eq!(restored.etag, original.etag, "restore must preserve the object ETag");
}
#[tokio::test]
#[serial_test::serial]
async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() {
+317
View File
@@ -18,6 +18,7 @@ use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
use crate::set_disk::get_lock_acquire_timeout;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_lock::NamespaceLockGuard;
use std::collections::BTreeSet;
use tracing::trace;
@@ -378,6 +379,79 @@ impl ECStore {
Ok(result)
}
/// Heal every pool metadata owner in the selected scope without allowing
/// one healthy pool to hide another pool's failed repair.
pub async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
let scopes = self.heal_erasure_set_scopes(opts).await?;
let mut results = Vec::new();
for (pool_index, set_index) in scopes {
if !self.replacement_pool_metadata_applies(pool_index, set_index)? {
continue;
}
let set = &self.pools[pool_index].disk_set[set_index];
let targets = set.set_endpoints.iter().map(ToString::to_string).collect::<Vec<_>>();
if targets.is_empty()
|| targets.len() != set.set_drive_count
|| targets.iter().collect::<BTreeSet<_>>().len() != targets.len()
{
return Err(Error::SlowDown);
}
// Administrative remove/no-lock options apply to user objects,
// never to the cluster's authoritative metadata transaction.
let metadata_opts = HealOpts {
dry_run: opts.dry_run,
recreate: opts.recreate,
scan_mode: opts.scan_mode,
pool: Some(pool_index),
set: Some(set_index),
..Default::default()
};
let (result, error) = self
.handle_heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, "", &metadata_opts)
.await?;
if let Some(error) = error {
return Err(error);
}
if !opts.dry_run {
let ok_state = DriveState::Ok.to_string();
let complete = result.after.drives.len() == targets.len()
&& targets.iter().all(|target| {
let mut outcomes = result.after.drives.iter().filter(|drive| drive.endpoint == *target);
outcomes.next().is_some_and(|drive| drive.state == ok_state) && outcomes.next().is_none()
});
if !complete
|| !set
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, "", &targets)
.await?
{
return Err(Error::SlowDown);
}
}
results.push(result);
}
Ok(results)
}
/// Whether this replacement set owns the pool's metadata replica.
///
/// Pool metadata follows normal object placement within each pool. A valid
/// non-owner set has no replica to repair; missing metadata on the owner
/// set still requires healing and target-specific readback.
pub fn replacement_pool_metadata_applies(&self, pool_index: usize, set_index: usize) -> Result<bool> {
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let selected = pool.get_disks_for_heal_object(
POOL_META_NAME,
&HealOpts {
set: Some(set_index),
..Default::default()
},
)?;
Ok(Arc::ptr_eq(&selected, &pool.get_disks_by_key(POOL_META_NAME)))
}
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
pub async fn replacement_targets_have_version(
&self,
@@ -829,6 +903,249 @@ mod tests {
}
}
#[tokio::test]
async fn replacement_pool_metadata_applies_to_the_written_replica_in_each_pool() {
let mut store = minimal_heal_store().await;
for pool_index in 0..store.pools.len() {
assert!(
store
.replacement_pool_metadata_applies(pool_index, 0)
.expect("a valid single-set pool should have a metadata owner")
);
}
store.ctx = Arc::new(InstanceContext::new());
for algorithm in [
crate::disk::format::DistributionAlgoVersion::V1,
crate::disk::format::DistributionAlgoVersion::V2,
crate::disk::format::DistributionAlgoVersion::V3,
] {
let mut temp_dirs = Vec::new();
for pool_index in 0..store.pools.len() {
let (dirs, mut pool) =
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
temp_dirs.extend(dirs);
Arc::get_mut(&mut pool)
.expect("fixture pool should have one owner")
.distribution_algo = algorithm.clone();
store.pools[pool_index] = pool;
}
for (pool_index, pool) in store.pools.iter().enumerate() {
let mut required_sets = 0;
for set_index in 0..pool.disk_set.len() {
required_sets += usize::from(
store
.replacement_pool_metadata_applies(pool_index, set_index)
.expect("valid replacement topology should be classified before metadata exists"),
);
}
assert_eq!(required_sets, 1, "missing metadata cannot exempt the owner set");
save_config(pool.clone(), POOL_META_NAME, b"pool metadata placement".to_vec())
.await
.expect("normal config writes should persist one metadata replica per pool");
for (set_index, set) in pool.disk_set.iter().enumerate() {
let applies = store
.replacement_pool_metadata_applies(pool_index, set_index)
.expect("valid replacement topology should be classified");
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
let replica = disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await;
if applies {
replica.expect("the metadata owner must match actual persisted shards");
} else {
assert!(
matches!(replica, Err(crate::disk::error::DiskError::FileNotFound)),
"non-owner sets must have no persisted metadata shard; observed error: {:?}",
replica.as_ref().err()
);
}
}
}
assert!(
store
.replacement_pool_metadata_applies(pool_index, pool.disk_set.len())
.is_err()
);
}
}
assert!(store.replacement_pool_metadata_applies(store.pools.len(), 0).is_err());
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_repairs_each_owner_and_preserves_dry_run() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
let first_missing = remove_pool_meta_shard(&store, 0).await;
let second_missing = remove_pool_meta_shard(&store, 1).await;
let destructive_options = HealOpts {
remove: true,
no_lock: true,
..Default::default()
};
let results = store
.heal_pool_metadata(&HealOpts {
dry_run: true,
..destructive_options
})
.await
.expect("dry-run should inspect both metadata owners without requiring a commit");
assert_eq!(results.len(), 2);
assert!(
first_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_err()
);
assert!(
second_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_err()
);
let lock = store.pools[0]
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("metadata namespace lock should be available");
let guard = lock
.get_read_lock(get_lock_acquire_timeout())
.await
.expect("a metadata reader should hold the shared fence");
let error = temp_env::async_with_vars(
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))],
store.heal_pool_metadata(&destructive_options),
)
.await
.expect_err("administrative no-lock cannot bypass the metadata write fence");
assert!(matches!(error, Error::Lock(rustfs_lock::LockError::Timeout { .. })));
drop(guard);
let results = store
.heal_pool_metadata(&destructive_options)
.await
.expect("every metadata owner should be repaired");
assert_eq!(results.len(), 2);
assert!(first_missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok());
assert!(
second_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_ok()
);
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_does_not_hide_missing_later_pool() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
delete_config(store.pools[1].clone(), POOL_META_NAME)
.await
.expect("the second pool metadata replica should be removed");
let error = store
.heal_pool_metadata(&HealOpts::default())
.await
.expect_err("the healthy first pool must not hide the second owner's missing replica");
assert!(!matches!(error, Error::NoHealRequired));
let second_set = store.pools[1].get_disks_by_key(POOL_META_NAME);
for disk in second_set.disks.read().await.iter().flatten() {
assert!(disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err());
}
}
#[tokio::test]
async fn ordinary_pool_metadata_heal_skips_only_valid_non_owner_sets() {
let mut store = minimal_heal_store().await;
store.ctx = Arc::new(InstanceContext::new());
for algorithm in [
crate::disk::format::DistributionAlgoVersion::V1,
crate::disk::format::DistributionAlgoVersion::V2,
crate::disk::format::DistributionAlgoVersion::V3,
] {
let mut temp_dirs = Vec::new();
for pool_index in 0..store.pools.len() {
let (dirs, mut pool) =
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
temp_dirs.extend(dirs);
Arc::get_mut(&mut pool)
.expect("fixture pool should have one owner")
.distribution_algo = algorithm.clone();
store.pools[pool_index] = pool;
}
for pool_index in 0..store.pools.len() {
let owner = (0..store.pools[pool_index].disk_set.len())
.find(|set_index| {
store
.replacement_pool_metadata_applies(pool_index, *set_index)
.expect("valid metadata placement")
})
.expect("every pool must have one metadata owner");
let non_owner = 1 - owner;
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(non_owner),
..Default::default()
})
.await
.expect("valid non-owner should need no metadata write")
.is_empty()
);
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(owner),
..Default::default()
})
.await
.is_err(),
"an owner with no authoritative metadata must fail"
);
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(2),
..Default::default()
})
.await
.is_err(),
"invalid sets cannot claim the non-owner exemption"
);
}
}
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(2),
..Default::default()
})
.await
.is_err()
);
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_requires_every_owner_endpoint() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
let owner = store.pools[0].get_disks_by_key(POOL_META_NAME);
let offline_disk = owner.disks.write().await[0]
.take()
.expect("fixture owner disk should start online");
let result = store
.heal_pool_metadata(&HealOpts {
pool: Some(0),
..Default::default()
})
.await;
assert!(result.is_err(), "a surviving metadata shard must not hide an offline owner endpoint");
owner.disks.write().await[0] = Some(offline_disk);
}
async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore {
let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME);
let missing_disk = target_set.disks.read().await[0]
+118
View File
@@ -18932,6 +18932,124 @@ mod tests {
.await;
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_config_init_recovers_after_initial_reload_failure_without_another_mutation() {
run_large_stack_async_test("tier-config-init-recovery", || async {
use crate::services::tier::tier::{TIER_DRIVER_TEST_FACTORY, TierDriverTestFactory};
let temp_dir = tempfile::tempdir().expect("create tier startup recovery store dir");
let (ctx, store, _shutdown) = build_isolated_test_store(temp_dir.path(), "tier-startup-recovery", &[4]).await;
let manager = ctx.tier_config_mgr();
let candidate = TierConfigMgr::new();
let tier_name = "STARTUP-RECOVERY";
let backend = register_mock_tier(&candidate, tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&candidate, tier_name)
.await
.expect("candidate backend identity should resolve")
.backend_identity();
let candidate_digest = {
let candidate = candidate.read().await;
candidate
.save_tiering_config(store.clone())
.await
.expect("committed tier config should persist");
tier_config_candidate_digest(&candidate).expect("committed candidate digest should build")
};
let config_path = format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE);
let config_bytes = com::read_config(store.clone(), &config_path)
.await
.expect("committed tier config bytes should load");
let config_etag = store
.get_object_info(RUSTFS_META_BUCKET, &config_path, &ObjectOptions::default())
.await
.expect("committed tier config metadata should load")
.etag
.expect("committed tier config should have an ETag");
let mutation_id = uuid::Uuid::new_v4();
let intent = TierMutationIntent {
mutation_id,
revision: 2,
kind: TierMutationIntentKind::Add,
state: TierMutationIntentState::Committed,
old_config_etag: None,
committed_config_etag: Some(config_etag),
candidate_digest,
affected_targets: vec![TierMutationIntentTarget {
tier_name: tier_name.to_string(),
old_backend_identity: None,
new_backend_identity: Some(backend_identity),
}],
expires_at_unix_nanos: 1,
};
// Persist the restart state directly: no peer Commit or runtime block
// installation may leave a notification that masks a missing startup wakeup.
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("committed restart intent should persist without notifying the manager");
com::save_config(store.clone(), &config_path, vec![0])
.await
.expect("controlled invalid config should persist");
runtime_sources::TEST_TIER_CONFIG_MGR
.scope(manager.clone(), async {
let err = runtime_sources::init_tier_config_mgr(store.clone())
.await
.expect_err("initial reload must report the controlled config parse failure");
assert!(err.to_string().contains("tierConfigInit: no data"), "unexpected startup failure: {err}");
})
.await;
assert!(manager.read().await.tiers.is_empty(), "failed startup must not publish the candidate");
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
Ok(_) => panic!("failed startup must retain the recovered committed fence"),
Err(err) => err,
};
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
// Restore only the durable bytes. Recovery must come from the worker
// started by init, after the test-only handle scope has already ended.
com::save_config(store.clone(), &config_path, config_bytes)
.await
.expect("restoring committed config bytes should remove the startup failure");
// Reload only publishes this Add into an empty manager, so it has
// no replaced backend to construct or probe. Only the lease check
// below needs a driver; keep its factory on the observing task.
let driver_factory: TierDriverTestFactory = Arc::new(move |_| Ok(Box::new(backend.clone())));
TIER_DRIVER_TEST_FACTORY
.scope(driver_factory, async {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
Ok(lease) => {
drop(lease);
match load_tier_mutation_intent_record(store.clone(), mutation_id).await {
Err(Error::ConfigNotFound) => break,
Ok(retained) => assert_eq!(retained, intent),
Err(err) => panic!("committed recovery intent should remain readable: {err}"),
}
}
Err(err) => assert!(
TierConfigMgr::operation_lease_blocked_by_mutation(&err),
"recovery must retain the fence until the tier becomes available: {err}"
),
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("startup worker must publish the tier and clean its fence without another mutation");
})
.await;
let recovered = manager.read().await;
assert_eq!(
tier_config_candidate_digest(&recovered).expect("recovered config digest should build"),
candidate_digest,
"startup recovery must publish the committed configuration"
);
});
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
+9 -248
View File
@@ -164,16 +164,6 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
max_keys
}
fn list_versions_scan_limit(max_keys: i32, has_version_marker: bool) -> i32 {
if max_keys <= 0 {
return 0;
}
// The marker object's versions may all be filtered out after gathering.
// Reserve its raw entry in addition to the next-page lookahead entry.
max_keys_plus_one(max_keys, true) + i32::from(has_version_marker)
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum GatherResultsState {
LimitReached,
@@ -2149,19 +2139,15 @@ fn build_list_versions_next_marker(
// here; advertise it as the literal `null` marker so a resumed listing
// parses it back to `VersionMarker::Null` instead of a nil UUID that
// `find_version_index` can never match (issue #6745).
let version_marker = if last.is_dir && last.mod_time.is_none() {
// A CommonPrefix has no version to resume; a version marker would
// make the next page include this same prefix again.
None
} else {
(
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)),
Some(
last.version_id
.filter(|v| !v.is_nil())
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string()),
)
};
(Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), version_marker)
),
)
} else if let Some(last_prefix) = prefixes.last() {
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
} else {
@@ -2880,20 +2866,6 @@ fn listing_entries_supplement_target(
return None;
}
if let Some(directory) = entries.0.iter().flatten().find(|entry| entry.is_dir()) {
let directory_copies = entries
.0
.iter()
.flatten()
.filter(|entry| entry.is_dir() && entry.name == directory.name)
.count();
// A committed child may have some of its directory copies only on
// fallback disks, just like object metadata in a partial primary sample.
if directory_copies < resolver.dir_quorum {
return Some(directory.name.clone());
}
}
for (idx, entry) in entries.0.iter().enumerate() {
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
continue;
@@ -4046,7 +4018,8 @@ impl ECStore {
None
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
// Always request max_keys + 1 to detect if there are more results
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -5352,7 +5325,7 @@ impl Sets {
None
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -6061,7 +6034,7 @@ impl SetDisks {
let has_version_marker = version_marker.is_some();
let version_marker = version_marker.map(parse_version_marker).transpose()?;
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -6275,7 +6248,7 @@ impl SetDisks {
None
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -7468,153 +7441,6 @@ mod test {
assert!(cancel.is_cancelled());
}
#[test]
fn list_versions_pagination_scan_limit_boundaries() {
for has_version_marker in [false, true] {
assert_eq!(super::list_versions_scan_limit(-1, has_version_marker), 0);
assert_eq!(super::list_versions_scan_limit(0, has_version_marker), 0);
let marker_slot = i32::from(has_version_marker);
assert_eq!(super::list_versions_scan_limit(1, has_version_marker), 2 + marker_slot);
assert_eq!(super::list_versions_scan_limit(MAX_OBJECT_LIST, has_version_marker), 1001 + marker_slot);
assert_eq!(super::list_versions_scan_limit(i32::MAX, has_version_marker), 1001 + marker_slot);
}
}
#[tokio::test]
async fn list_versions_pagination_does_not_require_an_empty_final_page() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "version-pagination-bucket";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("pagination bucket should be created");
let mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
for kind in ["objects", "deletes", "null", "mixed", "delimiter"] {
let count = if kind == "mixed" { 5 } else { 10 };
let mut expected = Vec::new();
for index in 0..count {
let name = if kind == "delimiter" && index % 2 == 1 {
format!("{kind}/testobject-{index:02}/child")
} else {
format!("{kind}/testobject-{index:02}")
};
let entry = match kind {
"deletes" => test_delete_marker_meta_entry(&name, mod_time),
"null" => test_object_meta_entry(&name),
"mixed" => test_object_with_delete_marker_meta_entry(&name, mod_time, mod_time + time::Duration::SECOND),
_ => test_object_meta_entry_with_erasure_versions(&name, &[(mod_time, "etag", 2, 2)]),
};
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(&name);
tokio::fs::create_dir_all(&object_dir)
.await
.expect("pagination object directory should be created");
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), &entry.metadata)
.await
.expect("pagination metadata should be written");
}
if kind == "delimiter" && index % 2 == 1 {
expected.push((name.trim_end_matches("child").to_owned(), None, false));
} else {
let versions = entry.file_info_versions(bucket).expect("fixture versions should decode");
expected.extend(
versions
.versions
.iter()
.map(|version| (name.clone(), version.version_id, version.deleted)),
);
}
}
let prefix = format!("{kind}/");
let delimiter = (kind == "delimiter").then(|| "/".to_owned());
// Exercise each public/internal entry point with the reported page size.
// The store entry point also covers exact and one-over limit boundaries.
for (layer, max_keys) in [(0, 0), (0, 1), (0, 5), (0, 9), (0, 10), (0, 11), (1, 5), (2, 5), (3, 5)] {
if layer == 3 && delimiter.is_some() {
continue;
}
let mut marker = None;
let mut version_marker = None;
let expected_pages = if max_keys == 0 {
1
} else {
10usize.div_ceil(usize::try_from(max_keys).expect("positive page size"))
};
let mut actual = Vec::new();
for page in 0..expected_pages {
let result = match layer {
0 => {
store
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
1 => {
store.pools[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
2 => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
_ => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(
bucket,
&prefix,
marker,
version_marker,
max_keys,
)
.await
}
}
.expect("version page should list successfully");
let page_size = usize::try_from(max_keys).expect("nonnegative page size");
assert_eq!(result.objects.len() + result.prefixes.len(), (10 - page * page_size).min(page_size));
let has_more = page + 1 < expected_pages;
assert_eq!(result.is_truncated, has_more, "{kind}, layer {layer}, max_keys {max_keys}, page {page}");
assert_eq!(
result.next_marker.is_some(),
has_more,
"key marker must exist only when another page exists"
);
if !has_more {
assert!(
result.next_version_idmarker.is_none(),
"the final page must not advertise a version marker"
);
}
actual.extend(
result
.objects
.into_iter()
.map(|object| (object.name, object.version_id, object.delete_marker)),
);
actual.extend(result.prefixes.into_iter().map(|prefix| (prefix, None, false)));
marker = result.next_marker;
version_marker = result.next_version_idmarker;
}
// Objects and CommonPrefixes are serialized separately; compare their
// identities without relying on their relative position in the response.
actual.sort();
let mut expected = if max_keys == 0 { Vec::new() } else { expected.clone() };
expected.sort();
assert_eq!(actual, expected, "{kind}, layer {layer}, max_keys {max_keys}");
}
}
}
#[test]
fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
let version_marker = Some(VersionMarker::Null);
@@ -9622,71 +9448,6 @@ mod test {
assert!(supplemented.is_latest_delete_marker());
}
#[tokio::test]
async fn latest_listing_supplement_checks_fallback_disks_for_common_prefix_quorum() {
let mut fallback_disks = Vec::new();
let mut fallback_tempdirs = Vec::new();
for index in 0..4 {
let tempdir = tempfile::tempdir().expect("fallback tempdir should be created");
let endpoint = Endpoint::try_from(tempdir.path().to_str().expect("fallback path should be utf8"))
.expect("fallback endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("fallback disk should be created");
disk.make_volume("bucket").await.expect("fallback bucket should be created");
for copies in [3, 4] {
if index < copies {
let object = format!("quux-{copies}/thud");
let entry = test_object_meta_entry(&object);
disk.write_all("bucket", &format!("{object}/{STORAGE_FORMAT_FILE}"), bytes::Bytes::from(entry.metadata))
.await
.expect("fallback child metadata should be written");
}
}
fallback_disks.push(disk);
fallback_tempdirs.push(tempdir);
}
let supplement = ListingSupplement::new(
ListingSupplementOptions {
bucket: "bucket".to_owned(),
path: String::new(),
recursive: false,
incl_deleted: false,
skip_hidden_prefix_check: false,
filter_prefix: None,
forward_to: None,
per_disk_limit: 100,
skip_total_timeout: true,
walkdir_timeout: None,
walkdir_stall_timeout: None,
},
Arc::new(fallback_disks),
FallbackClaimTracker::default(),
);
// A 16-drive EC:4 set asks 12 primary disks. A committed write may
// exist on eight primary disks and all four remaining fallback disks.
let resolver = list_metadata_resolution_params("bucket".to_owned(), 4, 12, false, 0);
for fallback_copies in [3, 4] {
let prefix = format!("quux-{fallback_copies}/");
let mut primary = vec![Some(test_dir_meta_entry(&prefix)); 8];
primary.extend([None, None, None, None]);
let entry =
resolve_listing_entries_with_supplement(MetaCacheEntries(primary), resolver.clone(), true, supplement.clone())
.await;
assert_eq!(
entry.map(|entry| entry.name),
(fallback_copies == 4).then_some(prefix),
"the common prefix needs all twelve copies, including fallback disks"
);
}
}
#[test]
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
-1
View File
@@ -104,7 +104,6 @@ walkdir = { workspace = true }
http = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
chrono = { workspace = true }
[lib]
doctest = false
+397 -66
View File
@@ -851,7 +851,7 @@ impl ErasureSetHealer {
}
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
self.heal_replacement_pool_metadata(
self.heal_pool_metadata(
set_disk_id,
&mut ErasureSetPassCounters {
processed_objects: &mut processed_objects,
@@ -950,26 +950,43 @@ impl ErasureSetHealer {
Ok(())
}
async fn heal_replacement_pool_metadata(
async fn heal_pool_metadata(
&self,
set_disk_id: &str,
counters: &mut ErasureSetPassCounters<'_>,
resume_manager: &ResumeManager,
checkpoint_manager: &CheckpointManager,
) -> Result<()> {
let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
let mut metadata_opts = self.heal_opts;
metadata_opts.remove = false;
metadata_opts.no_lock = false;
if self.replacement_task_id.is_none() {
let (pool_index, set_index) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
if metadata_opts.pool.is_some_and(|pool| pool != pool_index) || metadata_opts.set.is_some_and(|set| set != set_index)
{
return Err(Error::TaskExecutionFailed {
message: format!("Pool metadata scope does not match resumed set {set_disk_id}"),
});
}
metadata_opts.pool = Some(pool_index);
metadata_opts.set = Some(set_index);
}
let target_endpoints = if self.replacement_task_id.is_some() || self.pool_metadata_target_endpoints.is_empty() {
self.target_endpoints.as_ref()
} else {
self.pool_metadata_target_endpoints.as_ref()
};
let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
if self.replacement_task_id.is_none() && !target_scoped_recreate {
return Ok(());
}
if target_endpoints.is_empty() {
return Err(Error::TaskExecutionFailed {
message: "Replacement pool metadata heal requires target endpoints".to_string(),
});
let target_scoped_recreate = !metadata_opts.dry_run && metadata_opts.recreate && !target_endpoints.is_empty();
let ordinary_heal = self.replacement_task_id.is_none() && !target_scoped_recreate;
if !ordinary_heal {
if target_endpoints.is_empty() {
return Err(Error::TaskExecutionFailed {
message: "Replacement pool metadata heal requires target endpoints".to_string(),
});
}
if !self.storage.replacement_pool_metadata_applies(&metadata_opts).await? {
return Ok(());
}
}
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
@@ -988,61 +1005,88 @@ impl ErasureSetHealer {
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
.await?;
let result = match self
.storage
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
let object_size = result_object_size_u64(&result);
match self
.storage
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
.await
{
Ok(true) => (object_size, Ok(())),
Ok(false) => (
object_size,
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
)),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal because target readback failed: {err}"
))),
),
let result = if ordinary_heal {
match self.storage.heal_pool_metadata(&metadata_opts).await {
Ok(results) if results.is_empty() => return Ok(()),
Ok(results) => {
let [result] = results.as_slice() else {
return Err(Error::TaskExecutionFailed {
message: format!("Pool metadata returned multiple replicas for set {set_disk_id}"),
});
};
(result_object_size_u64(result), Ok(()))
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => {
(0, Err(Error::transient_skip(format!("Pool metadata heal must be retried: {err}"))))
}
HealObjectOutcome::Failed => (0, Err(err)),
},
}
Ok((result, None)) => (
result_object_size_u64(&result),
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because a replacement target was not committed",
)),
),
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
} else {
match self
.storage
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &metadata_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
let object_size = result_object_size_u64(&result);
match self
.storage
.replacement_targets_have_version(
RUSTFS_META_BUCKET,
POOL_META_NAME,
None,
&metadata_opts,
target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(())),
Ok(false) => (
object_size,
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
)),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal because target readback failed: {err}"
))),
),
}
}
Ok((result, None)) => (
result_object_size_u64(&result),
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because a replacement target was not committed",
)),
),
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
object_size,
0,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
HealObjectOutcome::Failed => (0, Err(err)),
},
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
0,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (0, Err(err)),
},
};
let (object_size, result) = result;
@@ -1061,7 +1105,7 @@ impl ErasureSetHealer {
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
state = "healed",
"Replacement pool metadata healed"
"Pool metadata healed"
);
CheckpointObjectOutcome::Processed
}
@@ -1078,7 +1122,7 @@ impl ErasureSetHealer {
object = POOL_META_NAME,
state = "transient_skip",
error = %message,
"Replacement pool metadata heal skipped due to transient error"
"Pool metadata heal skipped due to transient error"
);
CheckpointObjectOutcome::Skipped
}
@@ -1095,7 +1139,7 @@ impl ErasureSetHealer {
object = POOL_META_NAME,
state = "failed",
error = %err,
"Replacement pool metadata heal failed"
"Pool metadata heal failed"
);
CheckpointObjectOutcome::Failed
}
@@ -1536,7 +1580,9 @@ impl ErasureSetHealer {
);
CheckpointObjectOutcome::Processed
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => {
return Err(err);
}
Err(Error::TransientSkip { message }) => {
telemetry_unknown |= !increment_counter(skipped_objects);
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
@@ -2038,6 +2084,8 @@ mod resume_loop_tests {
#[derive(Clone)]
enum HealOutcome {
Ok,
/// The object has no metadata on any disk in the selected set.
FileNotFound,
/// The version vanished before heal ran (deleted mid-heal).
VersionNotFound,
/// A transient infrastructure condition (offline disk / unmet quorum):
@@ -2063,6 +2111,10 @@ mod resume_loop_tests {
/// Target-specific physical readback evidence per `compose_key`; the
/// fake models a healthy backend unless a test explicitly revokes it.
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
ordinary_pool_metadata_required: AtomicBool,
pool_metadata_opts: Mutex<Vec<HealOpts>>,
pool_metadata_not_applicable: AtomicBool,
fail_pool_metadata_scope: AtomicBool,
lifecycle_expired: Mutex<HashSet<String>>,
/// every heal_object call recorded as (name, version_id)
heal_calls: Mutex<Vec<(String, Option<String>)>>,
@@ -2151,11 +2203,14 @@ mod resume_loop_tests {
}
async fn heal_object(
&self,
_bucket: &str,
bucket: &str,
object: &str,
version_id: Option<&str>,
_opts: &HealOpts,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
if bucket == RUSTFS_META_BUCKET && object == POOL_META_NAME {
self.pool_metadata_opts.lock().expect("metadata options").push(*opts);
}
self.heal_calls
.lock()
.unwrap()
@@ -2164,6 +2219,7 @@ mod resume_loop_tests {
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
match outcome {
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
HealOutcome::FileNotFound => Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileNotFound)))),
HealOutcome::VersionNotFound => {
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
}
@@ -2177,6 +2233,30 @@ mod resume_loop_tests {
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
Ok((HealResultItem::default(), None))
}
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
if !self.ordinary_pool_metadata_required.load(Ordering::SeqCst) {
return Ok(Vec::new());
}
if !self.replacement_pool_metadata_applies(opts).await? {
return Ok(Vec::new());
}
let (result, error) = self.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, opts).await?;
if let Some(error) = error {
return Err(error);
}
Ok(vec![result])
}
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
if self.fail_pool_metadata_scope.load(Ordering::SeqCst) {
return Err(Error::other("injected pool metadata scope failure"));
}
if self.pool_metadata_not_applicable.load(Ordering::SeqCst) {
assert_eq!(opts.pool, Some(0));
assert_eq!(opts.set, Some(1));
return Ok(false);
}
Ok(true)
}
async fn replacement_targets_have_version(
&self,
_bucket: &str,
@@ -2668,6 +2748,115 @@ mod resume_loop_tests {
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
}
#[tokio::test]
async fn ordinary_set_heals_pool_metadata_without_replacement_generation_or_targets() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("metadata-disk", POOL_META_NAME));
assert!(env.healer.replacement_task_id.is_none());
assert!(env.healer.target_endpoints.is_empty());
env.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("ordinary set recovery should repair metadata even without user buckets");
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
{
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
}
let state = env.resume.get_state().await;
assert!(state.completed);
assert_eq!(state.successful_objects, 1, "metadata must enter durable completion counters");
}
#[tokio::test]
async fn ordinary_set_pool_metadata_respects_non_owner_and_dry_run() {
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.pool_metadata_not_applicable.store(true, Ordering::SeqCst);
env.healer.heal_opts.pool = Some(0);
env.healer.heal_opts.set = Some(1);
env.healer
.execute_heal_with_resume(&[], "pool_0_set_1", &env.resume, &env.checkpoint)
.await
.expect("a valid non-owner set must not invent a metadata replica");
assert!(env.storage.calls().is_empty());
assert_eq!(env.resume.get_state().await.successful_objects, 0);
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.healer.heal_opts.dry_run = true;
env.healer.heal_opts.remove = true;
env.healer.heal_opts.no_lock = true;
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
env.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("ordinary dry-run metadata work must not require a replacement commit");
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert!(opts[0].dry_run);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn ordinary_set_missing_pool_metadata_preserves_retry_state() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("missing required pool metadata must prevent ordinary set completion");
assert!(matches!(error, Error::TransientSkip { .. }));
let state = env.resume.get_state().await;
assert!(!state.completed);
assert_eq!(state.retry_count, 1);
assert!(CheckpointManager::has_checkpoint(&env.healer.disk, &env.task_id).await);
}
#[tokio::test]
async fn ordinary_set_pool_metadata_timeout_keeps_control_error() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::Timeout);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("metadata timeout must abort the ordinary set pass");
assert!(matches!(error, Error::TaskTimeout));
assert!(!env.resume.get_state().await.completed);
}
#[tokio::test]
async fn ordinary_set_pool_metadata_rejects_mismatched_explicit_scope() {
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.healer.heal_opts.pool = Some(1);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("explicit metadata scope must agree with the resumed set");
assert!(matches!(error, Error::TaskExecutionFailed { .. }));
assert!(env.storage.calls().is_empty(), "scope mismatch must fail before metadata mutation");
assert!(!env.resume.get_state().await.completed);
}
#[tokio::test]
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -2722,6 +2911,111 @@ mod resume_loop_tests {
drop(checkpoint);
}
#[tokio::test]
async fn replacement_pool_metadata_non_owner_completes_but_missing_owner_retries() {
for owns_pool_metadata in [false, true] {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let replacement_task_id = ResumeUtils::generate_task_id();
let set_index = usize::from(!owns_pool_metadata);
let set_disk_id = format!("pool_0_set_{set_index}");
ResumeManager::new_replacement_intent(
env.healer.disk.clone(),
replacement_task_id.clone(),
set_disk_id.clone(),
vec!["b".to_string()],
vec!["replacement-a".to_string()],
vec![crate::heal::resume::ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("replacement intent should persist");
env.storage
.pool_metadata_not_applicable
.store(!owns_pool_metadata, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
pool: Some(0),
set: Some(set_index),
..Default::default()
},
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
let result = healer.heal_erasure_set(&["b".to_string()], &set_disk_id).await;
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("replacement state must remain until marker cleanup")
.get_state()
.await;
if owns_pool_metadata {
let error = result.expect_err("missing metadata in the owner set must keep replacement incomplete");
assert!(error.to_string().contains("Replacement erasure set heal incomplete"));
assert!(!state.completed);
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
assert_eq!(state.retry_count, 1);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
} else {
result.expect("a non-owner set must complete without a pool metadata replica");
assert!(state.completed);
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
assert_eq!(state.retry_count, 0);
assert!(env.storage.calls().is_empty(), "non-owner sets must not attempt pool metadata repair");
}
}
}
#[tokio::test]
async fn replacement_pool_metadata_unknown_scope_cannot_complete() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
env.storage.fail_pool_metadata_scope.store(true, Ordering::SeqCst);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
let mut processed_objects = 0;
let mut successful_objects = 0;
let mut failed_objects = 0;
let mut skipped_objects = 0;
let error = healer
.heal_pool_metadata(
"pool_0_set_0",
&mut super::ErasureSetPassCounters {
processed_objects: &mut processed_objects,
successful_objects: &mut successful_objects,
failed_objects: &mut failed_objects,
skipped_objects: &mut skipped_objects,
},
&env.resume,
&env.checkpoint,
)
.await
.expect_err("unknown metadata placement must keep replacement incomplete");
assert!(error.to_string().contains("injected pool metadata scope failure"));
assert!(env.storage.calls().is_empty());
assert_eq!((processed_objects, successful_objects, failed_objects, skipped_objects), (0, 0, 0, 0));
}
#[tokio::test]
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -2781,8 +3075,8 @@ mod resume_loop_tests {
env.healer.disk.clone(),
HealOpts {
recreate: true,
pool: Some(0),
set: Some(0),
remove: true,
no_lock: true,
..Default::default()
},
HealRequestSource::Admin,
@@ -2798,6 +3092,43 @@ mod resume_loop_tests {
assert!(env.resume.get_state().await.completed);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
assert!(opts[0].recreate);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn admin_recreate_pool_metadata_validates_owner_scope_before_io() {
for (non_owner, unknown_scope, pool) in [(true, false, None), (false, true, None), (false, false, Some(1))] {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.storage.pool_metadata_not_applicable.store(non_owner, Ordering::SeqCst);
env.storage.fail_pool_metadata_scope.store(unknown_scope, Ordering::SeqCst);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
recreate: true,
pool,
..Default::default()
},
HealRequestSource::Admin,
)
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
let set_disk_id = if non_owner { "pool_0_set_1" } else { "pool_0_set_0" };
let result = healer
.execute_heal_with_resume(&[], set_disk_id, &env.resume, &env.checkpoint)
.await;
assert_eq!(result.is_ok(), non_owner, "only a known non-owner may skip metadata: {result:?}");
assert!(env.storage.calls().is_empty(), "scope validation must precede metadata I/O");
assert_eq!(env.resume.get_state().await.completed, non_owner);
}
}
#[tokio::test]
+16 -129
View File
@@ -841,10 +841,6 @@ pub struct HealManager {
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
/// Set IDs whose durable replacement metadata is corrupt or conflicting.
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
/// Durable handoff of interrupted administrator root traversals.
root_recovery: Arc<root_recovery::RootHealRecovery>,
/// Keep forceStart's cancellation side effects inside the shutdown fence.
force_start_shutdown: Mutex<()>,
/// Storage layer interface
storage: Arc<dyn HealStorageAPI>,
/// Cancel token
@@ -880,7 +876,6 @@ struct HealQueueContext<'a> {
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
root_recovery: &'a Arc<root_recovery::RootHealRecovery>,
config: &'a Arc<RwLock<HealConfig>>,
statistics: &'a Arc<RwLock<HealStatistics>>,
storage: &'a Arc<dyn HealStorageAPI>,
@@ -1382,8 +1377,6 @@ impl HealManager {
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
root_recovery: Arc::new(root_recovery::RootHealRecovery::default()),
force_start_shutdown: Mutex::new(()),
storage,
cancel_token: CancellationToken::new(),
statistics: Arc::new(RwLock::new(HealStatistics::new())),
@@ -1419,23 +1412,6 @@ impl HealManager {
"Heal manager starting"
);
// Restore graceful-shutdown root responsibilities before automatic
// repair can admit overlapping work.
if let Err(error) = self.replay_root_heals().await {
// A missing owner or invalid root record must not block existing
// replacement recovery. Keep its file for a later restart after
// the owner is readable or the record has been repaired.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "root_recovery_deferred",
error = %error,
"Root heal restart recovery deferred"
);
}
// start scheduler
self.start_scheduler().await?;
@@ -1473,7 +1449,6 @@ impl HealManager {
/// Stop HealManager
pub async fn stop(&self) -> Result<()> {
let _force_start_guard = self.force_start_shutdown.lock().await;
info!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
@@ -1483,39 +1458,11 @@ impl HealManager {
"Heal manager stopping"
);
// Keep scheduler, cancellation, and retry ownership stable until every
// unfinished root traversal has a durable successor. A failed write
// must leave the manager running and the shutdown marker unclean.
let mut active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for task in active_heals.values() {
if root_recovery::is_root_heal(&task.heal_type, task.source) {
if task.get_status().await == HealTaskStatus::Completed {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
} else {
let mut request = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request,
Err(Error::TaskTimeout) => {
let mut request = task.retry_request();
request.options.timeout = Some(Duration::ZERO);
request
}
Err(error) => return Err(error),
};
request.retry_attempts = task.retry_attempts;
self.root_recovery.persist(&request).await?;
}
}
}
for request in queue.requests().chain(retrying.values().map(|retrying| &retrying.request)) {
self.root_recovery.persist(request).await?;
}
// cancel all tasks
self.cancel_token.cancel();
drop(retrying);
drop(queue);
// cancel active workers after the durable handoff
// wait for all tasks to complete
let mut active_heals = self.active_heals.lock().await;
for task in active_heals.values() {
if let Err(e) = task.cancel().await {
warn!(
@@ -1642,17 +1589,6 @@ impl HealManager {
let admission_start = Instant::now();
let source = request.source;
let force_start = request.force_start;
// A forceStart must not retire an old durable owner if shutdown will
// reject its replacement. Hold the same gate through final admission.
let _force_start_guard = if source == HealRequestSource::Admin && force_start {
let guard = self.force_start_shutdown.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
Some(guard)
} else {
None
};
// HS-06 forceStart semantics (admin only): MinIO stops the old task
// first and then starts the new one. Cancel any active admin task
// overlapping this request's path before entering admission, so the
@@ -1660,9 +1596,7 @@ impl HealManager {
if request.source == HealRequestSource::Admin && request.force_start {
let overlapping: Vec<String> = {
let active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
let mut ids = active_heals
active_heals
.iter()
.filter(|(task_id, task)| {
task.source == HealRequestSource::Admin
@@ -1670,17 +1604,7 @@ impl HealManager {
&& *task_id != &request.id
})
.map(|(task_id, _)| task_id.clone())
.collect::<Vec<_>>();
ids.extend(
queue
.requests()
.chain(retrying.values().map(|retrying| &retrying.request))
.filter(|pending| {
root_recovery::is_root_heal(&pending.heal_type, pending.source) && pending.id != request.id
})
.map(|pending| pending.id.clone()),
);
ids
.collect()
};
for task_id in overlapping {
match self.cancel_task(&task_id).await {
@@ -1694,14 +1618,17 @@ impl HealManager {
result = "force_start_cancelled_overlap",
"Admin forceStart cancelled an overlapping heal task"
),
Err(err) => return Err(err),
}
}
// A failed or timed-out replay may have only its durable owner
// left. Root responsibility overlaps every administrator path.
for pending in self.root_recovery.pending().await? {
if pending.id != request.id {
self.cancel_task(&pending.id).await?;
Err(err) => warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
cancelled_task_id = %task_id,
error = %err,
result = "force_start_cancel_failed",
"Admin forceStart failed to cancel an overlapping heal task"
),
}
}
}
@@ -1714,9 +1641,6 @@ impl HealManager {
// active -> retrying transitions can slip between duplicate checks.
let lock_phase_start = Instant::now();
let active_heals = self.active_heals.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
#[cfg(test)]
pause_duplicate_admission_after_active_lock(&request.id).await;
let mut queue = self.heal_queue.lock().await;
@@ -2221,7 +2145,6 @@ impl HealManager {
{
let mut active_heals = self.active_heals.lock().await;
if let Some(task) = active_heals.get(&canonical_task_id) {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
@@ -2245,11 +2168,6 @@ impl HealManager {
{
let mut retrying_heals = self.retrying_heals.lock().await;
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
self.root_recovery
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
retrying.cancel_token.cancel();
drop(retrying_heals);
@@ -2270,11 +2188,6 @@ impl HealManager {
}
let mut queue = self.heal_queue.lock().await;
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
if queue.remove_request_id(&canonical_task_id).is_some() {
publish_heal_queue_length(&queue);
info!(
@@ -2292,10 +2205,6 @@ impl HealManager {
return Ok(());
}
drop(queue);
if self.root_recovery.cancel_pending(&canonical_task_id).await? {
return Ok(());
}
Err(Error::TaskNotFound {
task_id: task_id.to_string(),
})
@@ -2314,7 +2223,6 @@ impl HealManager {
for task_id in &task_ids {
if let Some(task) = active_heals.get(task_id) {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
@@ -2343,11 +2251,6 @@ impl HealManager {
.collect::<Vec<_>>();
for task_id in &task_ids {
if let Some(retrying) = retrying_heals.get(task_id) {
self.root_recovery
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(task_id) {
retrying.cancel_token.cancel();
cancelled += 1;
@@ -2370,14 +2273,6 @@ impl HealManager {
}
let mut queue = self.heal_queue.lock().await;
for request in queue
.requests()
.filter(|request| heal_type_matches_path(&request.heal_type, heal_path))
{
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
if !queued_cancelled.is_empty() {
publish_heal_queue_length(&queue);
@@ -2389,13 +2284,6 @@ impl HealManager {
self.remove_mrf_repair_notice_targets_for_task(&request.id);
}
if heal_type_matches_path(&HealType::Cluster, heal_path) {
for pending in self.root_recovery.pending().await? {
if self.root_recovery.cancel_pending(&pending.id).await? {
cancelled += 1;
}
}
}
if cancelled == 0 {
return Err(Error::TaskNotFound {
task_id: heal_path.to_string(),
@@ -2507,7 +2395,6 @@ impl std::fmt::Debug for HealManager {
mod auto_scan;
mod queue;
mod root_recovery;
mod scheduler;
mod unclean_shutdown;
@@ -1,342 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Graceful-shutdown handoff for administrator root heals. This namespace is
//! separate from erasure-set checkpoints and replacement generations, which
//! cannot represent a cluster traversal. One coordinator disk owns each
//! record; never create a fallback copy after an uncertain write or deletion.
use super::*;
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskAPI, EcstoreDiskBytes};
use crate::heal::{DiskStore, RUSTFS_META_BUCKET};
use serde::{Deserialize, Serialize};
// The metadata bucket already exists and its parent is durable. Creating a
// nested journal directory here would also require syncing every ancestor.
const ROOT_RECOVERY_PREFIX: &str = "root-heal-";
const ROOT_RECOVERY_SCHEMA: u32 = 1;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RootHealIntent {
schema: u32,
task_id: String,
#[serde(deserialize_with = "decode_options")]
options: HealOptions,
priority: HealPriority,
retry_attempts: u32,
created_at: SystemTime,
}
impl RootHealIntent {
fn from_request(request: &HealRequest) -> Self {
Self {
schema: ROOT_RECOVERY_SCHEMA,
task_id: request.id.clone(),
options: request.options.clone(),
priority: request.priority,
retry_attempts: request.retry_attempts,
created_at: request.created_at,
}
}
fn into_request(self) -> HealRequest {
let mut request = HealRequest::new(HealType::Cluster, self.options, self.priority);
request.id = self.task_id;
request.source = HealRequestSource::Admin;
request.retry_attempts = self.retry_attempts;
request.created_at = self.created_at;
request
}
}
#[derive(Default)]
pub(super) struct RootHealRecovery {
mutation: Mutex<()>,
#[cfg(test)]
disks: Option<Vec<DiskStore>>,
}
pub(super) fn is_root_heal(heal_type: &HealType, source: HealRequestSource) -> bool {
source == HealRequestSource::Admin && matches!(heal_type, HealType::Cluster)
}
fn decode_options<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<HealOptions, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
let object = value
.as_object()
.ok_or_else(|| serde::de::Error::custom("root heal options must be an object"))?;
const FIELDS: &[&str] = &[
"scan_mode",
"remove_corrupted",
"recreate_missing",
"update_parity",
"recursive",
"dry_run",
"no_lock",
"timeout",
"pool_index",
"set_index",
];
if object.keys().any(|key| !FIELDS.contains(&key.as_str())) {
return Err(serde::de::Error::custom("unknown root heal recovery option"));
}
let options: HealOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
if options.no_lock {
return Err(serde::de::Error::custom("administrator root heal cannot skip namespace locking"));
}
Ok(options)
}
fn intent_path(task_id: &str) -> Result<String> {
let parsed = uuid::Uuid::parse_str(task_id).map_err(|_| Error::Other("Invalid root heal recovery task id".to_string()))?;
if parsed.to_string() != task_id {
return Err(Error::Other("Noncanonical root heal recovery task id".to_string()));
}
Ok(format!("{ROOT_RECOVERY_PREFIX}{task_id}.json"))
}
fn decode_intent(task_id: &str, bytes: &[u8]) -> Result<RootHealIntent> {
let _ = intent_path(task_id)?;
let intent: RootHealIntent = serde_json::from_slice(bytes)
.map_err(|error| Error::Other(format!("Invalid root heal recovery record {task_id}: {error}")))?;
if intent.schema != ROOT_RECOVERY_SCHEMA || intent.task_id != task_id {
return Err(Error::Other(format!("Unsupported or mismatched root heal recovery record {task_id}")));
}
Ok(intent)
}
impl RootHealRecovery {
#[cfg(test)]
pub(super) fn with_disks(disks: Vec<DiskStore>) -> Self {
Self {
mutation: Mutex::new(()),
disks: Some(disks),
}
}
async fn disks(&self) -> Result<Vec<DiskStore>> {
#[cfg(test)]
if let Some(disks) = &self.disks {
return Ok(disks.clone());
}
let map = local_disk_map_read().await;
if map.values().any(Option::is_none) {
return Err(Error::Other("Root heal recovery owner may be on an unavailable local disk".to_string()));
}
let mut disks = map.values().flatten().cloned().collect::<Vec<_>>();
disks.sort_by_key(|disk| EcstoreDiskAPI::endpoint(disk.as_ref()).to_string());
Ok(disks)
}
async fn find(disks: &[DiskStore], task_id: &str) -> Result<Option<(DiskStore, EcstoreDiskBytes)>> {
let path = intent_path(task_id)?;
let mut found = None;
for disk in disks {
// read_all reports FileNotFound even when the whole metadata
// volume is absent; that is an unknown owner, not empty state.
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
match EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, &path).await {
Ok(bytes) => {
decode_intent(task_id, &bytes)?;
if found.is_some() {
return Err(Error::Other(format!("Multiple root heal recovery owners for {task_id}")));
}
found = Some((disk.clone(), bytes));
}
Err(DiskError::FileNotFound) => {}
Err(error) => return Err(Error::Disk(error)),
}
}
Ok(found)
}
pub(super) async fn persist(&self, request: &HealRequest) -> Result<()> {
if !is_root_heal(&request.heal_type, request.source) {
return Ok(());
}
let _guard = self.mutation.lock().await;
let disks = self.disks().await?;
let existing = Self::find(&disks, &request.id).await?;
let (disk, expected) = match existing {
Some((disk, bytes)) => (disk, Some(bytes)),
None => {
let disk = disks
.first()
.cloned()
.ok_or_else(|| Error::Other("No local disk available for root heal shutdown recovery".to_string()))?;
(disk, None)
}
};
if request.options.no_lock {
return Err(Error::Other("Administrator root heal cannot skip namespace locking".to_string()));
}
let bytes = serde_json::to_vec(&RootHealIntent::from_request(request))
.map_err(|error| Error::Other(format!("Serialize root heal recovery record: {error}")))?;
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(&request.id)?,
expected,
Some(bytes.into()),
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(()),
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
}
}
pub(super) async fn remove(&self, task_id: &str, heal_type: &HealType, source: HealRequestSource) -> Result<bool> {
if !is_root_heal(heal_type, source) {
return Ok(false);
}
let _guard = self.mutation.lock().await;
let Some((disk, bytes)) = Self::find(&self.disks().await?, task_id).await? else {
return Ok(false);
};
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(task_id)?,
Some(bytes),
None,
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(true),
_ => Err(Error::Other(format!("Root heal recovery record changed while retiring {task_id}"))),
}
}
pub(super) async fn checkpoint_failed_execution(&self, task: &HealTask) -> Result<()> {
if !is_root_heal(&task.heal_type, task.source) {
return Ok(());
}
let remaining = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request.options.timeout,
Err(Error::TaskTimeout) => Some(Duration::ZERO),
Err(error) => return Err(error),
};
let _guard = self.mutation.lock().await;
let Some((disk, expected)) = Self::find(&self.disks().await?, &task.id).await? else {
// A first execution that failed has no restart handoff to update.
return Ok(());
};
let mut intent = decode_intent(&task.id, &expected)?;
let mut expected_options = intent.options.clone();
expected_options.timeout = task.options.timeout;
if intent.created_at != task.created_at || intent.priority != task.priority || expected_options != task.options {
return Err(Error::Other(format!("Root heal recovery owner changed for {}", task.id)));
}
// A terminal timeout leaves no runtime owner for stop() to snapshot.
// Checkpoint its consumed budget before publishing terminal status;
// never refund time if an earlier checkpoint is already stricter.
intent.options.timeout = match (intent.options.timeout, remaining) {
(Some(previous), Some(remaining)) => Some(previous.min(remaining)),
(previous, remaining) => previous.or(remaining),
};
intent.retry_attempts = intent.retry_attempts.max(task.retry_attempts);
let bytes = serde_json::to_vec(&intent)
.map_err(|error| Error::Other(format!("Serialize root heal recovery checkpoint: {error}")))?;
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(&task.id)?,
Some(expected),
Some(bytes.into()),
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(()),
_ => Err(Error::Other(format!("Root heal recovery record changed while checkpointing {}", task.id))),
}
}
pub(super) async fn cancel_pending(&self, task_id: &str) -> Result<bool> {
if intent_path(task_id).is_err() {
return Ok(false);
}
self.remove(task_id, &HealType::Cluster, HealRequestSource::Admin).await
}
pub(super) async fn pending(&self) -> Result<Vec<HealRequest>> {
let _guard = self.mutation.lock().await;
let disks = self.disks().await?;
let mut ids = HashSet::new();
for disk in &disks {
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
let entries = match EcstoreDiskAPI::list_dir(disk.as_ref(), "", RUSTFS_META_BUCKET, "", -1).await {
Ok(entries) => entries,
Err(DiskError::FileNotFound) => continue,
Err(error) => return Err(Error::Disk(error)),
};
for entry in entries {
let Some(task_id) = entry
.strip_prefix(ROOT_RECOVERY_PREFIX)
.and_then(|entry| entry.strip_suffix(".json"))
else {
continue;
};
let _ = intent_path(task_id)?;
ids.insert(task_id.to_string());
}
}
let mut requests = Vec::new();
for task_id in ids {
if let Some((_, bytes)) = Self::find(&disks, &task_id).await? {
requests.push(decode_intent(&task_id, &bytes)?.into_request());
}
}
requests.sort_by(|left, right| left.created_at.cmp(&right.created_at).then_with(|| left.id.cmp(&right.id)));
Ok(requests)
}
}
impl HealManager {
pub(super) async fn replay_root_heals(&self) -> Result<()> {
// Decode every record before admitting anything. These are already
// accepted responsibilities, so restore distinct IDs even when their
// paths overlap or the configured admission capacity has changed.
let requests = self.root_recovery.pending().await?;
let active = self.active_heals.lock().await;
let mut queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for mut request in requests {
request.force_start = true;
let existing = active
.get(&request.id)
.map(|task| request_matches_task(&request, task))
.or_else(|| {
queue
.requests()
.find(|queued| queued.id == request.id)
.map(|queued| request_matches_request(&request, queued))
})
.or_else(|| {
retrying
.get(&request.id)
.map(|retrying| request_matches_request(&request, &retrying.request))
});
match existing {
Some(true) => continue,
Some(false) => return Err(Error::Other(format!("Conflicting root heal recovery task {}", request.id))),
None => {}
}
queue.push(request);
}
publish_heal_queue_length(&queue);
Ok(())
}
}
-40
View File
@@ -26,7 +26,6 @@ impl HealManager {
let retrying_heals = self.retrying_heals.clone();
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
let root_recovery = self.root_recovery.clone();
let cancel_token = self.cancel_token.clone();
let statistics = self.statistics.clone();
let storage = self.storage.clone();
@@ -60,7 +59,6 @@ impl HealManager {
retrying_heals: &retrying_heals,
mrf_repair_notice_targets: &mrf_repair_notice_targets,
replacement_recovery_anchors: &replacement_recovery_anchors,
root_recovery: &root_recovery,
config: &config,
statistics: &statistics,
storage: &storage,
@@ -80,7 +78,6 @@ impl HealManager {
retrying_heals: &retrying_heals,
mrf_repair_notice_targets: &mrf_repair_notice_targets,
replacement_recovery_anchors: &replacement_recovery_anchors,
root_recovery: &root_recovery,
config: &config,
statistics: &statistics,
storage: &storage,
@@ -109,7 +106,6 @@ impl HealManager {
retrying_heals,
mrf_repair_notice_targets,
replacement_recovery_anchors,
root_recovery,
config,
statistics,
storage,
@@ -121,9 +117,6 @@ impl HealManager {
let config = config.read().await;
let mainline_pressure = Self::mainline_throttle_active(&config, workload_provider);
let mut active_heals_guard = active_heals.lock().await;
if cancel_token.is_cancelled() {
return;
}
publish_active_heal_count(&active_heals_guard);
// Check if new heal tasks can be started
@@ -213,7 +206,6 @@ impl HealManager {
let replacement_recovery_anchors_clone = replacement_recovery_anchors.clone();
let statistics_clone = statistics.clone();
let notify_clone = notify.clone();
let root_recovery_clone = root_recovery.clone();
let manager_cancel_token = cancel_token.clone();
let task_type_label_for_spawn = task_type_label.clone();
let task_set_label_for_spawn = task_set_label.clone();
@@ -302,38 +294,6 @@ impl HealManager {
tests::pause_completed_retention_before_publish(&task_id, &completed_status).await;
let mut active_heals_guard = active_heals_clone.lock().await;
let owns_completion = active_heals_guard.contains_key(&task_id);
if owns_completion
&& result.is_ok()
&& let Err(error) = root_recovery_clone.remove(&task_id, &task.heal_type, task.source).await
{
// Keep the durable responsibility if retirement fails.
// Replaying a completed traversal is idempotent.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_SCHEDULER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
state = "root_recovery_retirement_failed",
error = %error,
"Failed to retire root heal recovery record"
);
}
if owns_completion
&& result.is_err()
&& let Err(error) = root_recovery_clone.checkpoint_failed_execution(&task).await
{
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_SCHEDULER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
state = "root_recovery_checkpoint_failed",
error = %error,
"Failed to checkpoint root heal recovery execution budget"
);
}
let cancelled_completion = if owns_completion {
false
} else {
+8 -4
View File
@@ -26,7 +26,6 @@ use rustfs_madmin::heal_commands::HealResultItem;
use std::sync::Mutex as StdMutex;
use tempfile::TempDir;
mod root_recovery;
mod running_mainline;
use super::super::{DiskOption, DiskStore, Endpoint, new_disk, storage_api::status::BucketInfo};
@@ -95,7 +94,6 @@ async fn process_manager_queue_once(manager: &HealManager) {
retrying_heals: &manager.retrying_heals,
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
replacement_recovery_anchors: &manager.replacement_recovery_anchors,
root_recovery: &manager.root_recovery,
config: &manager.config,
statistics: &manager.statistics,
storage: &manager.storage,
@@ -557,6 +555,10 @@ async fn completed_retention_scheduler_preserves_progress_aliases_and_atomic_han
#[async_trait::async_trait]
impl HealStorageAPI for MockStorage {
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
Ok(Vec::new())
}
async fn get_object_meta(&self, _bucket: &str, _object: &str) -> Result<Option<HealObjectInfo>> {
Ok(None)
}
@@ -1871,7 +1873,7 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new(storage, None);
let request = HealRequest::new(
let mut request = HealRequest::new(
HealType::Object {
bucket: "bucket".to_string(),
object: "object".to_string(),
@@ -1888,6 +1890,7 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
.expect("first request should be accepted"),
HealAdmissionResult::Accepted
);
request.id = uuid::Uuid::new_v4().to_string();
assert_eq!(
manager
.submit_heal_request(request)
@@ -3727,7 +3730,7 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
}),
);
let request = HealRequest::new(
let mut request = HealRequest::new(
HealType::Object {
bucket: "bucket".to_string(),
object: "object".to_string(),
@@ -3744,6 +3747,7 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
.expect("first request should be accepted"),
HealAdmissionResult::Accepted
);
request.id = uuid::Uuid::new_v4().to_string();
assert_eq!(
manager
.submit_heal_request(request)
@@ -1,465 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::super::root_recovery::RootHealRecovery;
use super::*;
use crate::heal::RUSTFS_META_BUCKET;
async fn recovery_disk() -> (TempDir, DiskStore) {
let temp = TempDir::new().expect("temporary root recovery disk");
let endpoint = Endpoint::try_from(temp.path().to_string_lossy().as_ref()).expect("disk endpoint");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("local recovery disk");
match disk.make_volume(RUSTFS_META_BUCKET).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(error) => panic!("metadata volume: {error}"),
}
(temp, disk)
}
fn recovery_manager(disks: Vec<DiskStore>) -> HealManager {
let mut manager = HealManager::new(
Arc::new(MockStorage),
Some(HealConfig {
enable_auto_heal: false,
..Default::default()
}),
);
manager.root_recovery = Arc::new(RootHealRecovery::with_disks(disks));
manager
}
fn root_request() -> HealRequest {
let mut request = HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::High);
request.source = HealRequestSource::Admin;
request
}
async fn active_root(manager: &HealManager, request: HealRequest) -> Arc<HealTask> {
let task = Arc::new(HealTask::from_request(request, manager.storage.clone()));
*task.status.write().await = HealTaskStatus::Running;
task.progress.write().await.update_object_progress(1, 1, 0, 0, 128);
manager.active_heals.lock().await.insert(task.id.clone(), task.clone());
task
}
#[tokio::test]
async fn root_recovery_shutdown_restart_replays_same_id_and_success_retires_intent() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.recursive = true;
let task = active_root(&manager, request.clone()).await;
manager.stop().await.expect("durable shutdown handoff");
assert!(task.cancel_token.is_cancelled());
drop(manager);
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("replay durable root");
restarted.replay_root_heals().await.expect("replay is idempotent");
assert_eq!(restarted.get_queue_length().await, 1);
let restored = restarted
.heal_queue
.lock()
.await
.requests()
.next()
.cloned()
.expect("restored request");
assert_eq!(restored.id, request.id);
assert_eq!(restored.options, request.options);
assert_eq!(restored.priority, request.priority);
assert_eq!(restored.retry_attempts, request.retry_attempts);
assert_eq!(restored.created_at, request.created_at);
process_manager_queue_once(&restarted).await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Completed))
&& !restarted.active_heals.lock().await.contains_key(&request.id)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("restored root executes successfully");
assert!(restarted.root_recovery.pending().await.expect("read completion").is_empty());
}
#[tokio::test]
async fn root_recovery_explicit_cancel_covers_active_queued_retrying_and_durable_only() {
for state in ["active", "queued", "retrying", "durable_only", "root_path"] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let request = root_request();
manager.root_recovery.persist(&request).await.expect("durable responsibility");
match state {
"active" => {
active_root(&manager, request.clone()).await;
}
"queued" => {
manager.replay_root_heals().await.expect("queued recovery");
}
"retrying" => {
insert_retrying_request(&manager, request.clone()).await;
}
_ => {}
}
if state == "root_path" {
assert_eq!(manager.cancel_tasks_for_path("").await.expect("cancel durable root path"), 1);
} else {
manager.cancel_task(&request.id).await.expect("cancel root responsibility");
}
drop(manager);
let restarted = recovery_manager(vec![disk]);
restarted
.replay_root_heals()
.await
.expect("restart after explicit cancellation");
assert_eq!(restarted.get_queue_length().await, 0, "state={state}");
}
}
#[tokio::test]
async fn root_recovery_force_start_cancels_durable_only_responsibility() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let old = root_request();
manager
.root_recovery
.persist(&old)
.await
.expect("old terminal responsibility");
let mut new = root_request();
new.force_start = true;
assert_eq!(
manager
.submit_heal_request(new.clone())
.await
.expect("force start replacement"),
HealAdmissionResult::Accepted
);
assert!(manager.root_recovery.pending().await.expect("old owner retired").is_empty());
manager.stop().await.expect("persist new root only");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restart replacement");
let ids = restarted
.heal_queue
.lock()
.await
.requests()
.map(|request| request.id.clone())
.collect::<Vec<_>>();
assert_eq!(ids, [new.id]);
}
#[tokio::test]
async fn root_recovery_force_start_replaces_fresh_queued_and_retrying_admin_roots() {
for retrying in [false, true] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let old = root_request();
if retrying {
insert_retrying_request(&manager, old.clone()).await;
} else {
manager.submit_heal_request(old.clone()).await.expect("queue original root");
}
assert!(manager.root_recovery.pending().await.expect("not handed off yet").is_empty());
let mut new = root_request();
new.force_start = true;
assert_eq!(
manager.submit_heal_request(new.clone()).await.expect("force replacement"),
HealAdmissionResult::Accepted
);
manager.stop().await.expect("handoff only the new responsibility");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restart after forceStart");
let ids = restarted
.heal_queue
.lock()
.await
.requests()
.map(|request| request.id.clone())
.collect::<Vec<_>>();
assert_eq!(ids, [new.id], "retrying={retrying}; old={}", old.id);
}
}
#[tokio::test]
async fn root_recovery_invalid_records_are_retained_without_partial_replay() {
for kind in ["truncated", "schema", "identity", "option", "no_lock"] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let valid = root_request();
let invalid = root_request();
manager.root_recovery.persist(&valid).await.expect("valid root record");
manager
.root_recovery
.persist(&invalid)
.await
.expect("record before corruption");
let path = format!("root-heal-{}.json", invalid.id);
let original = disk.read_all(RUSTFS_META_BUCKET, &path).await.expect("read root record");
let mut value: serde_json::Value = serde_json::from_slice(&original).expect("record JSON");
match kind {
"schema" => value["schema"] = 2.into(),
"identity" => value["task_id"] = valid.id.clone().into(),
"option" => value["options"]["future_delete_mode"] = true.into(),
"no_lock" => value["options"]["no_lock"] = true.into(),
_ => {}
}
let bytes = if kind == "truncated" {
b"{".to_vec()
} else {
serde_json::to_vec(&value).expect("modified record")
};
disk.write_all(RUSTFS_META_BUCKET, &path, bytes.clone().into())
.await
.expect("inject bad record");
assert!(manager.replay_root_heals().await.is_err(), "kind={kind}");
assert_eq!(manager.get_queue_length().await, 0, "no partial admission for {kind}");
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, &path)
.await
.expect("bad record retained")
.as_ref(),
bytes
);
let mut forced = root_request();
forced.force_start = true;
assert!(
manager.submit_heal_request(forced).await.is_err(),
"forceStart must not discard unknown state"
);
}
}
#[tokio::test]
async fn root_recovery_failed_handoff_keeps_runtime_owner_and_does_not_try_another_disk() {
let (_temp, disk) = recovery_disk().await;
let (unavailable_temp, unavailable) = recovery_disk().await;
std::fs::remove_dir_all(unavailable_temp.path().join(RUSTFS_META_BUCKET)).expect("make owner volume unavailable");
let manager = recovery_manager(vec![unavailable, disk.clone()]);
let task = active_root(&manager, root_request()).await;
assert!(manager.stop().await.is_err());
assert!(
manager.cancel_task(&task.id).await.is_err(),
"missing owner cannot acknowledge cancellation"
);
assert!(!manager.cancel_token.is_cancelled());
assert!(!task.cancel_token.is_cancelled());
assert!(manager.active_heals.lock().await.contains_key(&task.id));
assert!(
RootHealRecovery::with_disks(vec![disk])
.pending()
.await
.expect("other disk remains empty")
.is_empty()
);
}
#[tokio::test]
async fn root_recovery_shutdown_fences_new_admission_and_preserves_later_cancellation() {
for operation_kind in ["submit", "force_start", "cancel"] {
let cancel = operation_kind == "cancel";
let (_temp, disk) = recovery_disk().await;
let manager = Arc::new(recovery_manager(vec![disk.clone()]));
let request = root_request();
active_root(&manager, request.clone()).await;
let queue = manager.heal_queue.lock().await;
let stopping = manager.clone();
let stop = tokio::spawn(async move { stopping.stop().await });
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if manager.active_heals.try_lock().is_err() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("shutdown owns active lock while waiting for queue");
let concurrent = manager.clone();
let operation = tokio::spawn(async move {
if cancel {
concurrent.cancel_task(&request.id).await
} else {
let mut new = root_request();
new.force_start = operation_kind == "force_start";
concurrent.submit_heal_request(new).await.map(|_| ())
}
});
drop(queue);
stop.await.expect("shutdown task").expect("durable shutdown");
let result = operation.await.expect("concurrent operation");
assert_eq!(result.is_ok(), cancel, "operation={operation_kind}");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("read final responsibility");
assert_eq!(restarted.get_queue_length().await, usize::from(!cancel));
}
}
#[tokio::test]
async fn root_recovery_exhausted_timeout_is_not_reset_by_restart() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.timeout = Some(Duration::from_secs(10));
let task = active_root(&manager, request.clone()).await;
task.set_execution_elapsed_for_test(Duration::from_secs(11)).await;
manager.stop().await.expect("persist exhausted execution budget");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restore bounded request");
assert_eq!(
restarted
.heal_queue
.lock()
.await
.requests()
.next()
.expect("restored root")
.options
.timeout,
Some(Duration::ZERO)
);
process_manager_queue_once(&restarted).await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout)) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("exhausted request stays timed out");
restarted
.cancel_task(&request.id)
.await
.expect("timeout responsibility remains cancellable");
assert!(restarted.root_recovery.pending().await.expect("retired timeout").is_empty());
}
#[tokio::test]
async fn root_recovery_shutdown_preserves_remaining_execution_budget() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.timeout = Some(Duration::from_secs(60));
let task = active_root(&manager, request).await;
task.set_execution_elapsed_for_test(Duration::from_secs(20)).await;
manager.stop().await.expect("handoff with consumed execution time");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restore remaining budget");
let queue = restarted.heal_queue.lock().await;
let remaining = queue
.requests()
.next()
.expect("restored root")
.options
.timeout
.expect("remaining timeout");
assert!(remaining <= Duration::from_secs(40), "elapsed execution must not be refunded");
assert!(
remaining >= Duration::from_secs(30),
"shutdown fixture should retain most of its remaining budget"
);
}
#[tokio::test]
async fn root_recovery_force_start_after_shutdown_does_not_retire_original_owner() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let old = root_request();
active_root(&manager, old.clone()).await;
manager.stop().await.expect("handoff original root");
let mut new = root_request();
new.force_start = true;
assert!(manager.submit_heal_request(new).await.is_err());
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("original responsibility remains");
let ids = restarted
.heal_queue
.lock()
.await
.requests()
.map(|request| request.id.clone())
.collect::<Vec<_>>();
assert_eq!(ids, [old.id]);
}
#[tokio::test]
async fn root_recovery_terminal_timeout_updates_only_existing_journal_before_second_restart() {
for durable in [false, true] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.timeout = Some(Duration::from_nanos(1));
if durable {
manager
.root_recovery
.persist(&request)
.await
.expect("persist nonzero execution budget");
manager.replay_root_heals().await.expect("first restart");
} else {
manager
.submit_heal_request(request.clone())
.await
.expect("first root execution");
}
process_manager_queue_once(&manager).await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(manager.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout))
&& !manager.active_heals.lock().await.contains_key(&request.id)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("real execution exhausts a nonzero budget");
assert!(!manager.active_heals.lock().await.contains_key(&request.id));
let restarted = recovery_manager(vec![disk]);
restarted
.replay_root_heals()
.await
.expect("second restart after terminal timeout");
let queue = restarted.heal_queue.lock().await;
if durable {
assert_eq!(
queue
.requests()
.next()
.expect("remaining timeout responsibility")
.options
.timeout,
Some(Duration::ZERO)
);
} else {
assert!(queue.is_empty(), "terminal failure must not create a new durable responsibility");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1140,54 +1140,6 @@ mod tests {
);
}
#[tokio::test]
async fn committed_snapshot_writer_payload_failure_does_not_publish_manifest() {
let root = TempDir::new().expect("test directory");
let store = disk(&root, "disk").await;
let owner = Uuid::new_v4();
let old = payload("old");
let next = payload("next");
commit(&store, 0, owner, 1, &old).await;
std::fs::create_dir(root.path().join("disk").join(RUSTFS_META_BUCKET).join(PAYLOAD_PATHS[1]))
.expect("payload path blocks successor staging");
let result = publish_committed_snapshot(std::slice::from_ref(&store), owner, 2, &next, 4096).await;
assert!(
matches!(result, Err(SnapshotError::Disk(_) | SnapshotError::Read(_))),
"payload failure must be visible before manifest publication: {result:?}"
);
let reopened = disk(&root, "disk").await;
let recovered = read_committed(std::slice::from_ref(&reopened), 4096)
.await
.expect("read previous committed snapshot")
.expect("old anchor remains committed");
assert_eq!(recovered.sequence(), 1);
assert_eq!(recovered.slot(), 0);
assert_eq!(recovered.payload(), old.as_slice());
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
.await
.expect("old payload retained")
.as_ref(),
old.as_slice()
);
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
.await
.expect("old manifest retained")
.as_ref(),
manifest(owner, 1, &old).as_slice()
);
assert!(
matches!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[1]).await,
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
),
"successor manifest must not be published when payload staging fails"
);
}
#[tokio::test]
async fn committed_snapshot_writer_capacity_failure_preserves_previous_anchor() {
let root = TempDir::new().expect("test directory");
+33
View File
@@ -436,6 +436,23 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("target-scoped replacement format is unsupported"))
}
/// Heal each pool metadata replica owned by the selected live scope.
///
/// A successful result requires every applicable owner to finish; an empty
/// result is valid only for a known scope with no metadata replica. Backends
/// without pool metadata must explicitly implement that empty result.
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
Err(Error::other("pool metadata healing is unsupported"))
}
/// Whether the selected replacement set owns the pool metadata replica.
///
/// Only a topology-aware backend may exempt a valid non-owner set. The
/// conservative default requires the existing repair and readback checks.
async fn replacement_pool_metadata_applies(&self, _opts: &HealOpts) -> Result<bool> {
Ok(true)
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
@@ -1268,6 +1285,22 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
self.ecstore.heal_pool_metadata(opts).await.map_err(Error::Storage)
}
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
let pool_index = opts
.pool
.ok_or_else(|| Error::other("replacement pool metadata is missing pool scope"))?;
let set_index = opts
.set
.ok_or_else(|| Error::other("replacement pool metadata is missing set scope"))?;
self.ecstore
.replacement_pool_metadata_applies(pool_index, set_index)
.map_err(Error::Storage)
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
+1 -6
View File
@@ -45,7 +45,7 @@ use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, POOL_META_NAME, RUSTFS_META_BUCKET};
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, RUSTFS_META_BUCKET};
#[cfg(test)]
pub(crate) struct OutcomeFinishTestHook {
@@ -513,11 +513,6 @@ impl HealTask {
}
}
#[cfg(test)]
pub(crate) async fn set_execution_elapsed_for_test(&self, elapsed: Duration) {
*self.task_start_instant.write().await = Some(Instant::now() - elapsed);
}
pub(crate) async fn retry_request_with_remaining_timeout(&self) -> Result<HealRequest> {
let mut request = self.retry_request();
if self.options.timeout.is_some() {
+17 -79
View File
@@ -328,6 +328,23 @@ impl HealTask {
}
}
if !self.options.dry_run {
let metadata_opts = HealOpts {
dry_run: self.options.dry_run,
recreate: self.options.recreate_missing,
scan_mode: self.options.scan_mode,
pool: self.options.pool_index,
set: self.options.set_index,
..Default::default()
};
for result in self
.await_with_control(self.storage.heal_pool_metadata(&metadata_opts))
.await?
{
self.record_result_item(result).await;
}
}
if failed > 0 {
let failure = BatchHealFailure {
scope: "cluster".to_string(),
@@ -340,88 +357,9 @@ impl HealTask {
return Err(self.record_batch_failure(failure).await);
}
if self.options.recreate_missing && !self.options.dry_run {
self.heal_cluster_pool_metadata().await?;
}
Ok(())
}
async fn heal_cluster_pool_metadata(&self) -> Result<()> {
let heal_opts = HealOpts {
recursive: false,
dry_run: self.options.dry_run,
remove: false,
recreate: self.options.recreate_missing,
scan_mode: self.options.scan_mode,
update_parity: self.options.update_parity,
no_lock: self.options.no_lock,
read_repair: false,
pool: self.options.pool_index,
set: self.options.set_index,
};
let heal_result = self
.await_with_control(self.storage.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &heal_opts))
.await;
match heal_result {
Ok((result, None)) => {
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
drives_healed = result.drives_healed(),
drives_total = result.drives_reported(),
result = "pool_metadata_ok",
"Heal cluster pool metadata repaired"
);
self.record_result_item(result).await;
Ok(())
}
Ok((result, Some(err))) => {
self.record_result_item(result).await;
warn!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
result = "pool_metadata_failed",
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
}
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
Err(err) => {
warn!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
result = "pool_metadata_failed",
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
}
}
}
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
debug!(
target: "rustfs::heal::task",
+344 -6
View File
@@ -14,6 +14,7 @@
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
use super::*;
use crate::heal::POOL_META_NAME;
use crate::heal::storage::HealStorageObjectResult;
mod deferred_retry;
@@ -42,6 +43,7 @@ mod canonical_outcome {
#[tokio::test(start_paused = true)]
async fn cluster_retries_only_the_failed_listing_page() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
recoverable_second_page_failures: Mutex::new(Some(1)),
..Default::default()
});
@@ -1325,6 +1327,7 @@ struct MockStorage {
retry_test_events: Mutex<Vec<String>>,
listed: Mutex<bool>,
list_each_bucket: bool,
pool_metadata_required: bool,
fail_second_listing_page: bool,
recoverable_second_page_failures: Mutex<Option<usize>>,
listing_tokens: Mutex<Vec<Option<String>>>,
@@ -1339,6 +1342,10 @@ struct MockStorage {
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
bucket_incarnation_id: Mutex<Option<Uuid>>,
bucket_incarnation_calls: AtomicU64,
bucket_incarnation_error: Mutex<Option<Error>>,
block_bucket_incarnation: bool,
bucket_incarnation_started: tokio::sync::Notify,
bucket_incarnation_after_object_heal: Mutex<Option<Uuid>>,
bucket_incarnation_unavailable: Mutex<bool>,
format_no_heal_required: Mutex<bool>,
@@ -1482,7 +1489,7 @@ async fn object_heal_records_matching_positive_storage_receipt() {
});
let task = HealTask::from_request(
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
storage,
storage.clone(),
);
task.execute().await.expect("mock object heal should complete");
@@ -1495,6 +1502,114 @@ async fn object_heal_records_matching_positive_storage_receipt() {
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
assert!(object.identity.bucket_incarnation_id.is_some());
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
assert_eq!(
storage.bucket_incarnation_calls.load(Ordering::Relaxed),
1,
"latch the owner exactly once before repair"
);
}
#[tokio::test]
async fn object_heal_owner_lookup_failure_preserves_unverified_repair() {
let storage = Arc::new(MockStorage {
bucket_incarnation_error: Mutex::new(Some(Error::other("owner metadata unavailable"))),
heal_object_receipts: Mutex::new(HashMap::from([(
"object-a".to_string(),
VecDeque::from([object_receipt(
"object-a",
None,
HealObjectDisposition::Repaired,
Uuid::new_v4(),
)]),
)])),
..Default::default()
});
let task = HealTask::from_request(HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None), storage.clone());
task.execute().await.expect("missing receipt owner must not prevent repair");
assert_eq!(storage.heal_object_calls.lock().expect("heal calls").as_slice(), ["object-a"]);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.healed, 0);
assert_eq!(outcome.counters.unknown, 1);
assert_eq!(
outcome.objects.front().expect("unverified outcome").disposition,
HealObjectDisposition::Unknown
);
}
#[tokio::test]
async fn object_heal_dry_run_skips_owner_lookup_and_positive_receipts() {
let storage = Arc::new(MockStorage {
bucket_incarnation_error: Mutex::new(Some(Error::other("dry-run must not query the receipt owner"))),
heal_object_receipts: Mutex::new(HashMap::from([(
"object-a".to_string(),
VecDeque::from([object_receipt(
"object-a",
None,
HealObjectDisposition::Repaired,
Uuid::new_v4(),
)]),
)])),
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
request.options.dry_run = true;
let task = HealTask::from_request(request, storage.clone());
task.execute().await.expect("dry-run should complete without owner metadata");
assert!(storage.object_heal_opts.lock().expect("heal options")[0].dry_run);
assert_eq!(storage.bucket_incarnation_calls.load(Ordering::Relaxed), 0);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.healed, 0);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.counters.skipped, 1);
assert_eq!(
outcome.objects.front().expect("dry-run outcome").disposition,
HealObjectDisposition::DryRunObserved
);
}
#[tokio::test(start_paused = true)]
async fn object_heal_owner_lookup_obeys_task_timeout() {
let storage = Arc::new(MockStorage {
block_bucket_incarnation: true,
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
request.options.timeout = Some(Duration::from_secs(5));
let task = HealTask::from_request(request, storage.clone());
let result = tokio::time::timeout(Duration::from_secs(60), task.execute())
.await
.expect("owner lookup must honor the task deadline");
assert!(matches!(result, Err(Error::TaskTimeout)));
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
}
#[tokio::test]
async fn object_heal_owner_lookup_obeys_cancellation() {
let storage = Arc::new(MockStorage {
block_bucket_incarnation: true,
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
request.options.timeout = None;
let task = HealTask::from_request(request, storage.clone());
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(task.execute(), async {
storage.bucket_incarnation_started.notified().await;
task.cancel().await.expect("cancel pending owner lookup");
})
})
.await
.expect("cancellation must interrupt owner lookup");
assert!(matches!(result, Err(Error::TaskCancelled)));
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
}
#[tokio::test]
@@ -1832,6 +1947,34 @@ impl HealStorageAPI for MockStorage {
.collect())
}
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
if !self.pool_metadata_required {
return Ok(Vec::new());
}
let scopes = self.erasure_set_scopes.lock().expect("metadata scopes").clone();
let scopes = if scopes.is_empty() {
vec![(opts.pool.unwrap_or(0), opts.set.unwrap_or(0))]
} else {
scopes
};
let mut results = Vec::new();
for (pool, set) in scopes {
let scoped_opts = HealOpts {
pool: Some(pool),
set: Some(set),
..*opts
};
let (result, error) = self
.heal_object(RUSTFS_META_BUCKET, crate::heal::POOL_META_NAME, None, &scoped_opts)
.await?;
if let Some(error) = error {
return Err(error);
}
results.push(result);
}
Ok(results)
}
async fn object_exists(&self, _bucket: &str, object: &str) -> Result<bool> {
if let Some(result) = self.object_exists_by_name.lock().unwrap().get(object).copied() {
return match result {
@@ -1844,6 +1987,14 @@ impl HealStorageAPI for MockStorage {
}
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
self.bucket_incarnation_calls.fetch_add(1, Ordering::Relaxed);
self.bucket_incarnation_started.notify_one();
if self.block_bucket_incarnation {
std::future::pending::<()>().await;
}
if let Some(error) = self.bucket_incarnation_error.lock().expect("owner lookup error").take() {
return Err(error);
}
if *self.bucket_incarnation_unavailable.lock().unwrap() {
return Err(Error::Other("bucket incarnation unavailable".to_string()));
}
@@ -2564,9 +2715,187 @@ async fn test_recursive_bucket_heal_treats_missing_continuation_token_as_end() {
);
}
#[tokio::test]
async fn root_heal_restores_pool_metadata_without_user_buckets() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
assert!(storage.pool_metadata_required);
let task = HealTask::from_request(
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
storage.clone(),
);
task.execute().await.expect("root heal should restore required pool metadata");
assert_eq!(
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
[crate::heal::POOL_META_NAME]
);
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn root_heal_pool_metadata_cannot_hide_a_later_owner_failure() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
erasure_set_scopes: Mutex::new(vec![(0, 0), (1, 1)]),
..Default::default()
});
storage.heal_object_outcomes.lock().expect("metadata outcomes").insert(
crate::heal::POOL_META_NAME.to_string(),
VecDeque::from([
MockHealObjectOutcome::UnavailableDrive(DriveState::Ok),
MockHealObjectOutcome::OkWithReadQuorum,
]),
);
let task = HealTask::from_request(
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
storage.clone(),
);
let error = task
.execute()
.await
.expect_err("one healthy owner cannot satisfy another owner's recovery");
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
{
let opts = storage.object_heal_opts.lock().expect("owner options");
assert_eq!(
opts.iter().map(|opts| (opts.pool, opts.set)).collect::<Vec<_>>(),
vec![(Some(0), Some(0)), (Some(1), Some(1))]
);
}
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn root_heal_pool_metadata_does_not_inherit_remove_or_no_lock() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
remove_corrupted: true,
no_lock: true,
..Default::default()
},
HealPriority::Normal,
),
storage.clone(),
);
task.execute()
.await
.expect("metadata repair should retain its write fence and reject destructive options");
let opts = storage.object_heal_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1, "an empty user namespace must still inspect metadata");
assert!(!opts[0].dry_run);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn root_heal_pool_metadata_failure_does_not_prevent_user_repairs() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
crate::heal::POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
);
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
..Default::default()
},
HealPriority::Normal,
),
storage.clone(),
);
let error = task
.execute()
.await
.expect_err("unrecovered metadata must still fail root completion");
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
assert_eq!(
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
["object-a", "object-b", crate::heal::POOL_META_NAME]
);
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn root_heal_pool_metadata_preserves_typed_quorum_failure() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
crate::heal::POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
);
let task = HealTask::from_request(HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal), storage);
let error = task
.execute()
.await
.expect_err("metadata quorum failure must prevent root completion");
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test(start_paused = true)]
async fn root_heal_pool_metadata_obeys_task_timeout() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
retry_test_delays: HashMap::from([(crate::heal::POOL_META_NAME.to_string(), Duration::from_secs(10))]),
..Default::default()
});
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
timeout: Some(Duration::from_millis(10)),
..Default::default()
},
HealPriority::Normal,
),
storage,
);
let error = task
.execute()
.await
.expect_err("metadata work must stay inside the root task budget");
assert!(matches!(error, Error::TaskTimeout));
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn test_cluster_heal_visits_bucket_objects() {
let storage = Arc::new(MockStorage::default());
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
@@ -2589,7 +2918,10 @@ async fn test_cluster_heal_visits_bucket_objects() {
#[tokio::test]
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
let storage = Arc::new(MockStorage::default());
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
@@ -2616,7 +2948,10 @@ async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
#[tokio::test]
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
let storage = Arc::new(MockStorage::default());
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
@@ -2638,7 +2973,7 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
.await
.expect_err("cluster recreate heal must not hide pool metadata failure");
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
assert!(matches!(err, Error::Other(message) if message == "pool metadata missing"));
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
@@ -2647,7 +2982,10 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
#[tokio::test]
async fn cluster_dry_run_does_not_heal_pool_metadata() {
let storage = Arc::new(MockStorage::default());
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
+1 -5
View File
@@ -61,14 +61,10 @@ pub fn create_ahm_services_cancel_token() -> CancellationToken {
}
/// Shutdown all heal services gracefully
pub async fn shutdown_ahm_services() -> Result<()> {
if let Some(manager) = get_heal_manager() {
manager.stop().await?;
}
pub fn shutdown_ahm_services() {
if let Some(cancel_token) = GLOBAL_AHM_SERVICES_CANCEL_TOKEN.get() {
cancel_token.cancel();
}
Ok(())
}
struct HealRuntime {
+29 -118
View File
@@ -22,7 +22,7 @@
//! Under `cargo nextest` each test runs in its own process, which keeps the
//! process-global MRF channel singleton safe.
use rustfs_common::mrf_channel::{self, MrfIngressResult, MrfKind, MrfScope};
use rustfs_common::mrf_channel::{self, MrfKind};
use rustfs_heal::heal::{
manager::{HealConfig, HealManager},
mrf_queue,
@@ -237,33 +237,6 @@ fn journal_matches_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, exp
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
}
fn journal_contains_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
disk_paths.iter().all(|path| {
std::fs::read(path.join(META_BUCKET).join(relative_path))
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
})
}
fn journal_contains_on_any_disk(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
disk_paths.iter().any(|path| {
std::fs::read(path.join(META_BUCKET).join(relative_path))
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
})
}
fn committed_payload_contains_on_all_disks(disk_paths: &[PathBuf], needles: &[&[u8]]) -> bool {
disk_paths.iter().all(|path| {
let root = path.join(META_BUCKET);
COMMITTED_PAYLOAD_RELS.into_iter().any(|payload_rel| {
std::fs::read(root.join(payload_rel)).is_ok_and(|payload| {
needles
.iter()
.all(|needle| payload.windows(needle.len()).any(|window| window == *needle))
})
})
})
}
fn committed_checkpoint_matches_on_all_disks(disk_paths: &[PathBuf], sequence: u64, expected_payload: &[u8]) -> bool {
disk_paths.iter().all(|path| {
let root = path.join(META_BUCKET);
@@ -642,73 +615,6 @@ async fn journal_replay_retains_file_when_manager_is_full() {
);
}
/// Rollback mirrors are for v1 readers only: the committed and scoped
/// snapshots remain authoritative, while the legacy journal omits scoped-only
/// records that an older binary cannot represent safely.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn rollback_legacy_mirror_persists_only_v1_compatible_records() {
let (disk_paths, storage) = heal_env().await;
register_local_disks(&disk_paths, "mrf-rollback-mirror-test").await;
let manager = Arc::new(HealManager::new(
storage,
Some(HealConfig {
queue_size: 0,
heal_interval: Duration::from_secs(3600),
enable_auto_heal: false,
..Default::default()
}),
));
mrf_queue::spawn_mrf_consumer(manager.clone());
let scoped_only = b"rollback-scoped-only-object";
let v1_compatible = b"rollback-v1-compatible-object";
assert_eq!(
mrf_channel::try_send_mrf_intent_typed(
MrfKind::PartialWrite,
"rollback-bucket",
std::str::from_utf8(scoped_only).expect("fixture object is UTF-8"),
None,
Some(MrfScope {
pool_index: 3,
set_index: 7,
}),
),
MrfIngressResult::Enqueued,
"scoped-only intent should be accepted by the live consumer"
);
assert_eq!(
mrf_channel::try_send_mrf_intent_typed(
MrfKind::PartialWrite,
"rollback-bucket",
std::str::from_utf8(v1_compatible).expect("fixture object is UTF-8"),
None,
None,
),
MrfIngressResult::Enqueued,
"v1-compatible intent should be accepted by the live consumer"
);
let flushed = wait_until(Duration::from_secs(10), || async {
committed_payload_contains_on_all_disks(&disk_paths, &[scoped_only, v1_compatible])
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, scoped_only)
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, v1_compatible)
&& journal_contains_on_all_disks(&disk_paths, JOURNAL_REL, v1_compatible)
&& !journal_contains_on_any_disk(&disk_paths, JOURNAL_REL, scoped_only)
})
.await;
assert!(
flushed,
"runtime flush must persist rollback-safe mirrors without leaking scoped-only records into the legacy journal"
);
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
0,
"zero-capacity manager keeps both intents in the MRF runtime so the persisted snapshot is observable"
);
}
#[test]
fn mrf_journal_child_process_fixture() {
let Ok(root) = std::env::var("RUSTFS_MRF_REPLAY_CHILD_ROOT") else {
@@ -760,7 +666,8 @@ fn mrf_successor_flush_child_process_fixture() {
}),
));
mrf_queue::spawn_mrf_consumer(manager.clone());
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
let flushed = wait_until(Duration::from_secs(10), || async {
manager.operations_snapshot().await.queued_by_source.mrf == 1
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
@@ -808,7 +715,8 @@ fn mrf_successor_flush_waiting_child_process_fixture() {
}),
));
mrf_queue::spawn_mrf_consumer(manager.clone());
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
let flushed = wait_until(Duration::from_secs(10), || async {
manager.operations_snapshot().await.queued_by_source.mrf == 1
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
@@ -849,7 +757,8 @@ fn mrf_authoritative_fsync_waiting_child_process_fixture() {
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
let successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
let mut successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
write_journal_path_to_disks_synced(&disk_paths, SCOPED_JOURNAL_REL, &successor);
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
@@ -906,9 +815,8 @@ async fn journal_replay_retains_child_process_anchor_when_manager_is_full() {
);
}
/// If a process crashes after flushing a smaller successor snapshot but before
/// deleting the startup anchor, the restarted process must replay the
/// successor tail rather than losing it or merging it with stale records.
/// A successor flush must preserve both pending work and accepted work whose
/// repair has not been proven when the process restarts.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn journal_replay_survives_successor_flush_before_delete() {
@@ -923,7 +831,8 @@ async fn journal_replay_survives_successor_flush_before_delete() {
assert_eq!(status.code(), Some(78), "child process did not reach the successor flush boundary");
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
"restarted process must see the pending successor snapshot"
@@ -931,11 +840,11 @@ async fn journal_replay_survives_successor_flush_before_delete() {
let restarted = make_manager(storage);
let replayed = mrf_queue::replay_journal_once(&restarted).await;
assert_eq!(replayed, 1, "restart after successor flush must replay only the still-pending tail");
assert_eq!(replayed, 2, "restart must replay both the admitted and pending responsibilities");
assert_eq!(
restarted.operations_snapshot().await.queued_by_source.mrf,
1,
"the successor tail must be accepted after restart"
2,
"both unproven successor responsibilities must be accepted after restart"
);
assert!(
disk_paths.iter().all(|path| {
@@ -947,8 +856,8 @@ async fn journal_replay_survives_successor_flush_before_delete() {
}
/// A service-style hard kill after successor flush must be equivalent to a
/// crash at the flush-before-delete boundary: restart may replay the smaller
/// successor snapshot, but must not lose or merge stale startup records.
/// crash at the flush-before-delete boundary: restart must recover every
/// unproven responsibility from the successor snapshot.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[cfg(unix)]
@@ -976,7 +885,8 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
"restarted process must see the successor snapshot produced before the kill"
@@ -984,11 +894,11 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
let restarted = make_manager(storage);
let replayed = mrf_queue::replay_journal_once(&restarted).await;
assert_eq!(replayed, 1, "restart after service kill must replay only the still-pending tail");
assert_eq!(replayed, 2, "service-kill restart must preserve every unproven responsibility");
assert_eq!(
restarted.operations_snapshot().await.queued_by_source.mrf,
1,
"the successor tail must be accepted after service kill restart"
2,
"both unproven responsibilities must be accepted after service kill restart"
);
assert!(
disk_paths.iter().all(|path| {
@@ -1000,9 +910,9 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
}
/// A hard kill between the authoritative successor fsync and the legacy mirror
/// rewrite must prefer the canonical successor tail over the stale legacy
/// startup epoch. This models the mixed-version boundary conservatively: new
/// readers must not merge epochs, while the old mirror remains crash-visible.
/// rewrite must prefer the canonical successor over the stale legacy startup
/// epoch while retaining every unproven responsibility. New readers must not
/// merge epochs, while the old mirror remains crash-visible.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[cfg(unix)]
@@ -1030,7 +940,8 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
let expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
let mut expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
let stale_startup = {
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
@@ -1047,11 +958,11 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
let restarted = make_manager(storage);
let replayed = mrf_queue::replay_journal_once(&restarted).await;
assert_eq!(replayed, 1, "new reader must replay only the authoritative successor tail");
assert_eq!(replayed, 2, "new reader must recover every responsibility in the authoritative successor");
assert_eq!(
restarted.operations_snapshot().await.queued_by_source.mrf,
1,
"the successor tail must be accepted after the fsync-boundary restart"
2,
"both responsibilities must be accepted after the fsync-boundary restart"
);
assert!(
disk_paths.iter().all(|path| {
+1 -3
View File
@@ -819,9 +819,7 @@ impl KmsBackend for AwsKmsBackend {
.with_rotate(true)
.with_enable_disable(true)
.with_schedule_deletion(true)
// AWS KMS exposes rotation state but does not enumerate key
// versions through this backend's API contract.
.with_versioning(false)
.with_versioning(true)
.with_physical_delete(false)
.with_production_supported(true)
}
+107 -10
View File
@@ -2098,11 +2098,7 @@ impl KmsBackend for LocalKmsBackend {
let _write_guard = self.client.lock_key_for_write(key_id).await;
// First, load the key from disk to get the master key
let mut master_key = self
.client
.load_master_key(key_id)
.await
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
let mut master_key = self.client.load_master_key(key_id).await?;
let (deletion_date_str, deletion_date_dt) = if request.force_immediate.unwrap_or(false) {
// Tombstone first: mark the record Deleted before removing the
@@ -2205,11 +2201,7 @@ impl KmsBackend for LocalKmsBackend {
let _write_guard = self.client.lock_key_for_write(key_id).await;
// Load the key from disk to get the master key
let mut master_key = self
.client
.load_master_key(key_id)
.await
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
let mut master_key = self.client.load_master_key(key_id).await?;
if master_key.status != KeyStatus::PendingDeletion {
return Err(KmsError::invalid_key_state(format!("Key {key_id} is not pending deletion")));
@@ -3038,6 +3030,111 @@ mod tests {
assert!(matches!(error, KmsError::InvalidKey { .. }));
}
#[tokio::test]
async fn delete_key_preserves_directory_io_error() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("existing-key", "AES_256", None).await.expect("create key");
let backend = LocalKmsBackend { client };
let offline_dir = TempDir::new().expect("create offline directory");
let offline_key_dir = offline_dir.path().join("keys");
fs::rename(temp_dir.path(), &offline_key_dir)
.await
.expect("move key directory offline");
fs::write(temp_dir.path(), b"not a directory")
.await
.expect("replace key directory with a file");
let error = backend
.delete_key(DeleteKeyRequest {
key_id: "existing-key".to_string(),
..Default::default()
})
.await
.expect_err("unreadable storage must prevent scheduling deletion");
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
fs::rename(&offline_key_dir, temp_dir.path())
.await
.expect("restore key directory");
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
let key = backend
.client
.load_master_key("existing-key")
.await
.expect("read retained key");
assert_eq!(key.status, KeyStatus::Active, "failed deletion must not mutate key state");
}
#[tokio::test]
async fn cancel_key_deletion_preserves_directory_io_error() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("existing-key", "AES_256", None).await.expect("create key");
let backend = LocalKmsBackend { client };
backend
.delete_key(DeleteKeyRequest {
key_id: "existing-key".to_string(),
..Default::default()
})
.await
.expect("schedule key deletion");
let offline_dir = TempDir::new().expect("create offline directory");
let offline_key_dir = offline_dir.path().join("keys");
fs::rename(temp_dir.path(), &offline_key_dir)
.await
.expect("move key directory offline");
fs::write(temp_dir.path(), b"not a directory")
.await
.expect("replace key directory with a file");
let error = backend
.cancel_key_deletion(CancelKeyDeletionRequest {
key_id: "existing-key".to_string(),
})
.await
.expect_err("unreadable storage must prevent cancelling deletion");
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
fs::rename(&offline_key_dir, temp_dir.path())
.await
.expect("restore key directory");
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
let key = backend
.client
.load_master_key("existing-key")
.await
.expect("read retained key");
assert_eq!(
key.status,
KeyStatus::PendingDeletion,
"failed cancellation must retain the deletion state"
);
}
#[tokio::test]
async fn test_load_master_key_directory_replaced_by_file_is_io_error() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("existing-key", "AES_256", None).await.expect("create key");
let offline_dir = TempDir::new().expect("create offline directory");
let offline_key_dir = offline_dir.path().join("keys");
fs::rename(temp_dir.path(), &offline_key_dir)
.await
.expect("move key directory offline");
fs::write(temp_dir.path(), b"not a directory")
.await
.expect("replace key directory with a file");
let error = client
.load_master_key("existing-key")
.await
.expect_err("a file in place of the key directory must fail");
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
fs::rename(&offline_key_dir, temp_dir.path())
.await
.expect("restore key directory");
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
}
#[tokio::test]
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
let (client, _temp_dir) = create_dev_mode_client().await;
-59
View File
@@ -74,39 +74,6 @@ impl ScriptedResponse {
}
}
/// The `auth/token/lookup-self` answer every scripted Vault serves for free.
///
/// A Vault client now probes its token's remaining lifetime at login
/// (backlog#2369 P3), which is credential plumbing rather than the protocol any
/// of these tests is scripting. Answering it out of band keeps every existing
/// script meaningful: `ttl` 0 is Vault's "this token does not expire", so the
/// probe changes nothing about how a scripted test behaves.
pub(crate) fn token_lookup_self_response() -> String {
serde_json::json!({
"data": {
"accessor": "scripted-accessor",
"creation_time": 1_700_000_000u64,
"creation_ttl": 0,
"display_name": "token",
"entity_id": "",
"explicit_max_ttl": 0,
"id": "scripted-token",
"num_uses": 0,
"orphan": true,
"path": "auth/token/create",
"policies": ["default"],
"renewable": false,
"ttl": 0
}
})
.to_string()
}
/// Whether a recorded request line addresses the token self-lookup.
pub(crate) fn is_token_lookup_self(request_line: &str) -> bool {
request_line.contains("/v1/auth/token/lookup-self")
}
/// A scripted stand-in Vault listening on a loopback port.
pub(crate) struct ScriptedVault {
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
@@ -135,19 +102,6 @@ impl ScriptedVault {
let Some((request_line, body, mut stream)) = read_request(stream).await else {
continue;
};
if is_token_lookup_self(&request_line) {
// Served out of band so the credential probe does not
// consume a scripted response meant for the protocol under
// test, and is not recorded as one of its requests.
let body = token_lookup_self_response();
let payload = format!(
"HTTP/1.1 200 Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len(),
);
let _ = stream.write_all(payload.as_bytes()).await;
let _ = stream.shutdown().await;
continue;
}
recorded
.lock()
.expect("scripted vault request log poisoned")
@@ -201,19 +155,6 @@ impl ScriptedVault {
let Some((request_line, body, stream)) = read_request(stream).await else {
return;
};
if is_token_lookup_self(&request_line) {
// Credential plumbing, not part of the KV2 protocol
// this responder models; see token_lookup_self_response.
write_response(
stream,
ScriptedResponse::Http {
status: 200,
body: token_lookup_self_response(),
},
)
.await;
return;
}
recorded
.lock()
.expect("scripted vault request log poisoned")
@@ -13,5 +13,5 @@ expression: capabilities_snapshot(backend.capabilities())
"rotate": true,
"schedule_deletion": true,
"update_key_metadata": false,
"versioning": false
"versioning": true
}
+21 -137
View File
@@ -114,7 +114,7 @@ impl fmt::Debug for SecretString {
}
/// Expiry attributes of a lease-bound token.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy)]
pub(crate) struct LeaseInfo {
/// Time-to-live granted at issue or renewal.
pub(crate) ttl: Duration,
@@ -207,102 +207,31 @@ pub(crate) trait TokenSource: fmt::Debug + Send + Sync {
}
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
/// at configuration time.
///
/// The token itself is never re-issued, but it usually still expires:
/// `vault token create` defaults to a 768-hour TTL. Hard-coding "no lease"
/// here left the renewal task unstarted and published no remaining-TTL gauge,
/// so a healthy-looking cluster turned every KMS call into a 403 a month later
/// and could only be recovered by a restart or a reconfigure (backlog#2369 P3).
/// The source therefore asks Vault what it is holding, once per client
/// generation, and lets the existing renewal loop take over whenever the answer
/// carries a TTL.
/// Map a `lookup-self` answer onto a lease.
///
/// A zero TTL is Vault's answer for a token that never expires (root and
/// periodic-root tokens), which keeps the pre-probe behaviour exactly: no
/// lease, no renewal task, no expiry gate. A response that omits `renewable`
/// is treated as not renewable, so the renewal loop falls back to re-reading
/// the remaining TTL instead of assuming it can extend it.
fn static_token_lease(ttl_secs: u64, renewable: Option<bool>) -> Option<LeaseInfo> {
(ttl_secs > 0).then_some(LeaseInfo {
ttl: Duration::from_secs(ttl_secs),
renewable: renewable.unwrap_or(false),
})
}
/// at configuration time. The token carries no lease, so it is never renewed
/// and never expires from the provider's point of view.
pub(crate) struct StaticToken {
token: TokenLease,
/// Client authenticated with the configured token, used only for
/// `lookup-self`. Per-generation renewals use the generation's own client.
lookup_client: VaultClient,
}
impl StaticToken {
pub(crate) fn new(settings: &VaultConnectionSettings, token: String) -> Result<Self> {
let lookup_client = settings.build_client(&token)?;
Ok(Self {
pub(crate) fn new(token: String) -> Self {
Self {
token: TokenLease::new(token, None),
lookup_client,
})
}
}
}
#[async_trait]
impl TokenSource for StaticToken {
async fn acquire(&self) -> AttemptResult<TokenLease> {
// A lookup failure must not fail the login. The token itself may well
// be valid: a policy can omit `lookup-self`, and Vault may simply be
// unreachable for the moment. Failing here would take down deployments
// that work today, so the probe degrades to the pre-probe behaviour —
// no lease, no renewal — and says so loudly instead.
let lease = match vaultrs::token::lookup_self(&self.lookup_client).await {
Ok(lookup) => static_token_lease(lookup.ttl, lookup.renewable),
Err(error) => {
warn!(
event = "vault_static_token_lookup_failed",
error = %error,
"Could not read the configured Vault token's remaining lifetime, so it will not be \
renewed and its expiry will not be tracked. Grant the token `lookup-self` (Vault's \
default policy does) or switch to AppRole, Kubernetes or an agent-managed token file"
);
None
}
};
if let Some(lease) = lease
&& !lease.renewable
{
warn!(
event = "vault_static_token_not_renewable",
ttl_secs = lease.ttl.as_secs(),
"The configured Vault token expires and cannot be renewed; RustFS will fail closed as it \
approaches expiry. Switch to AppRole, Kubernetes or an agent-managed token file, or \
reconfigure with a fresh token before it lapses"
);
}
Ok(TokenLease::new(self.token.expose().to_string(), lease))
}
async fn renew(&self, client: &VaultClient) -> AttemptResult<TokenLease> {
// Vault refuses renew-self on a non-renewable token; the renewal loop
// then falls back to `acquire`, which re-reads the remaining TTL and
// keeps the gauge honest until the fail-closed window is reached.
let auth = vaultrs::token::renew_self(client, None)
.await
.map_err(|error| attempt_error("token renewal", error))?;
Ok(TokenLease::from_auth(auth))
Ok(self.token.clone())
}
}
impl fmt::Debug for StaticToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// TokenLease::fmt already redacts the token value; VaultClient embeds
// its settings, including the token, so it must stay out of Debug.
f.debug_struct("StaticToken")
.field("token", &self.token)
.finish_non_exhaustive()
// TokenLease::fmt already redacts the token value.
f.debug_struct("StaticToken").field("token", &self.token).finish()
}
}
@@ -612,7 +541,7 @@ pub(crate) fn token_source_for(
settings: &VaultConnectionSettings,
) -> Result<Box<dyn TokenSource>> {
match auth_method {
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(settings, token.clone())?)),
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))),
VaultAuthMethod::AppRole {
role_id,
secret_id,
@@ -1272,22 +1201,14 @@ mod tests {
(Arc::new(provider), state)
}
/// A provider whose token reports no expiry, which is what `lookup-self`
/// answers for a root or periodic-root token. Scripted rather than backed
/// by [`StaticToken`] because the real source now asks Vault what it holds.
async fn static_provider() -> VaultCredentialProvider {
VaultCredentialProvider::new(
test_settings(),
Box::new(ScriptedSource {
state: Arc::new(ScriptedState::default()),
ttl: Duration::ZERO,
renewable: false,
login_delay: Duration::ZERO,
}),
Box::new(StaticToken::new(TEST_TOKEN.to_string())),
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await
.expect("a token without an expiry must build without a live Vault")
.expect("static provider must build without a live Vault")
}
#[tokio::test]
@@ -1310,57 +1231,20 @@ mod tests {
assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew");
}
#[test]
fn test_static_token_source_builds_without_contacting_vault() {
token_source_for(
#[tokio::test]
async fn test_static_token_source_yields_configured_token() {
let settings = test_settings();
let source = token_source_for(
&VaultAuthMethod::Token {
token: TEST_TOKEN.to_string(),
},
&test_settings(),
&settings,
)
.expect("token auth must map to a source");
}
/// backlog#2369 P3: `vault token create` defaults to a 768-hour TTL, so
/// hard-coding "no lease" for token auth left the renewal task unstarted
/// and turned a healthy cluster into one that answers 403 a month later.
/// The lease now comes from what Vault reports.
#[test]
fn static_token_lease_follows_what_vault_reports() {
assert_eq!(
static_token_lease(0, Some(true)),
None,
"a token Vault reports as non-expiring must keep behaving as one"
);
assert_eq!(
static_token_lease(0, None),
None,
"a non-expiring token stays non-expiring whatever renewable says"
);
assert_eq!(
static_token_lease(2_764_800, Some(true)),
Some(LeaseInfo {
ttl: Duration::from_secs(2_764_800),
renewable: true,
}),
"the default 768-hour token must be tracked and renewed"
);
assert_eq!(
static_token_lease(3_600, Some(false)),
Some(LeaseInfo {
ttl: Duration::from_secs(3_600),
renewable: false,
}),
"an expiring token that cannot be renewed still needs its expiry tracked"
);
assert_eq!(
static_token_lease(3_600, None),
Some(LeaseInfo {
ttl: Duration::from_secs(3_600),
renewable: false,
}),
"an omitted renewable flag must not be read as renewable"
);
let lease = source.acquire().await.expect("static acquire cannot fail");
assert_eq!(lease.expose(), TEST_TOKEN);
assert!(lease.lease_info().is_none(), "static tokens must not carry a lease");
}
#[tokio::test]
@@ -1875,7 +1759,7 @@ mod tests {
renewable: true,
}),
);
let static_source = StaticToken::new(&test_settings(), TEST_TOKEN.to_string()).expect("static source");
let static_source = StaticToken::new(TEST_TOKEN.to_string());
let approle_source = AppRoleLogin::new(
&test_settings(),
"approle".to_string(),
+1 -60
View File
@@ -166,24 +166,6 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
}
}
fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap<String, String>) -> bool {
let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
(None, None) => true,
(Some(key), _) if key.is_empty() => true,
(Some(key), Some(value)) => object_tags.get(key) == Some(value),
_ => false,
};
filter
.and
.as_ref()
.and_then(|and| and.tags.as_deref())
.into_iter()
.flatten()
.chain(filter.tag.iter())
.all(tag_matches)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplicationTargetValidationError {
RoleWithMultipleDestinations,
@@ -722,7 +704,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
if let Some(filter) = &rule.filter {
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
if replication_filter_tags_match(filter, &object_tags) {
if filter.test_tags(&object_tags) {
rules.push(rule.clone());
}
} else {
@@ -1157,47 +1139,6 @@ mod tests {
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
}
#[test]
fn actionable_rules_require_every_and_tag_to_match() {
let mut rule = replication_rule("rule-1", "arn:target:a");
rule.filter = Some(s3s::dto::ReplicationRuleFilter {
and: Some(s3s::dto::ReplicationRuleAndOperator {
prefix: None,
tags: Some(vec![
s3s::dto::Tag {
key: Some("env".to_string()),
value: Some("prod".to_string()),
},
s3s::dto::Tag {
key: Some("tier".to_string()),
value: Some("gold".to_string()),
},
]),
}),
..Default::default()
});
let config = structure_config(vec![rule]);
let object = |user_tags: &str| ObjectOpts {
name: "object".to_string(),
user_tags: user_tags.to_string(),
..Default::default()
};
assert!(config.filter_target_arns(&object("env=prod")).is_empty());
assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
assert!(config.filter_target_arns(&object("")).is_empty());
let mut malformed = config;
malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
key: Some("env".to_string()),
value: None,
}]);
assert!(
malformed.filter_target_arns(&object("env=prod")).is_empty(),
"a malformed tag filter must fail closed"
);
}
#[test]
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
let mut rule = replication_rule("rule-1", "arn:target:a");
-44
View File
@@ -580,30 +580,6 @@ impl FailStats {
FailedMetric { count, size }
}
/// Both rolling windows from one walk of the samples. `short` must be the
/// narrower window; the walk stops at `long`. Callers that need both (the
/// per-node site snapshot) would otherwise scan the deque twice while
/// holding the bucket-stats read lock, and the deque is only bounded by
/// the one-hour window - an unreachable target under load fills it.
pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
let now = Instant::now();
let mut short_metric = FailedMetric::default();
let mut long_metric = FailedMetric::default();
for sample in self.recent.iter().rev() {
let age = now.duration_since(sample.observed_at);
if age > long {
break;
}
if age <= short {
short_metric.count += 1;
short_metric.size += sample.size;
}
long_metric.count += 1;
long_metric.size += sample.size;
}
(short_metric, long_metric)
}
pub fn merge(&self, other: &FailStats) -> Self {
Self {
count: self.count.saturating_add(other.count),
@@ -936,26 +912,6 @@ mod tests {
assert_eq!(last_hour.size, 96);
}
#[test]
fn fail_stats_recent_windows_matches_two_separate_scans() {
let mut stats = FailStats::default();
stats.add_size(64, None::<&()>);
stats.add_size(32, None::<&()>);
let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
let expected_minute = stats.recent_since(Duration::from_secs(60));
let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
assert_eq!(minute.count, 2);
assert_eq!(hour.size, 96);
let empty = FailStats::default();
let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
}
#[test]
fn fail_stats_saturate_instead_of_wrapping() {
let mut stats = FailStats {
-1
View File
@@ -90,7 +90,6 @@ s3s = { workspace = true, features = ["minio"] }
hex-simd.workspace = true
[dev-dependencies]
temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util"] }
tokio-test = { workspace = true }
criterion = { workspace = true, features = ["html_reports"] }
+17 -241
View File
@@ -327,55 +327,6 @@ where
}
}
/// Read-side switch for the pre-`1.0.0-alpha.91` nonce layout, in which a whole
/// v1 segment reused the part nonce for every block.
///
/// On by default, because turning it off refuses to decrypt objects written
/// before that release. Block zero's derived nonce equals that base nonce, so
/// the layout also lets a frame encrypted at index zero authenticate anywhere
/// in its segment; the in-segment layout lock catches that as soon as a later
/// frame disagrees, but a stream that is nothing but repeats of frame zero has
/// no such later frame. A deployment with no pre-alpha.91 objects should set
/// this to `false` to remove that surface outright (backlog#2369 P2).
///
// RUSTFS_COMPAT_TODO(backlog-2369-legacy-nonce-fallback): Remove after the
// minimum supported direct-upgrade release and after migration tooling has
// rewritten every pre-alpha.91 encrypted object.
pub const ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: &str = "RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK";
const DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: bool = true;
fn legacy_nonce_fallback_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
)
}
#[cfg(not(test))]
{
static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
)
})
}
}
/// The nonce layout selected while decoding a legacy v1 segment.
///
/// A historical writer used one of these layouts consistently for every
/// block in a segment. Once a non-zero block identifies that layout, accepting
/// another layout would let an attacker replay a block encrypted at index zero.
#[derive(Clone, Copy, PartialEq, Eq)]
enum V1NonceLayout {
Current,
LegacyBlock,
ReusedPart,
}
pin_project! {
/// A reader wrapper that decrypts data on the fly using AES-256-GCM.
/// This is a demonstration. For production, use a secure and audited crypto library.
@@ -407,8 +358,6 @@ pin_project! {
segment_frames: usize,
stream_saw_v2: bool,
segments_completed: usize,
v1_nonce_layout: Option<V1NonceLayout>,
legacy_nonce_fallback: bool,
}
}
@@ -442,8 +391,6 @@ where
segment_frames: 0,
stream_saw_v2: false,
segments_completed: 0,
v1_nonce_layout: None,
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
}
}
@@ -494,8 +441,6 @@ where
segment_frames: 0,
stream_saw_v2: false,
segments_completed: 0,
v1_nonce_layout: None,
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
}
}
}
@@ -602,7 +547,6 @@ where
*this.segment_frame_version = None;
*this.saw_final_frame = false;
*this.segment_frames = 0;
*this.v1_nonce_layout = None;
if *this.multipart_mode {
let next_part = if *this.current_part_index + 1 < this.multipart_parts.len() {
@@ -752,46 +696,26 @@ where
*this.base_nonce
};
let legacy_block_nonce = derive_block_nonce(&legacy_part_nonce, *this.block_index);
let legacy_part_nonce =
Nonce::try_from(legacy_part_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
let legacy_block_nonce =
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
let layouts = [
(V1NonceLayout::Current, &nonce),
(V1NonceLayout::LegacyBlock, &legacy_block_nonce),
(V1NonceLayout::ReusedPart, &legacy_part_nonce),
];
let selected = if *this.block_index == 0 { None } else { *this.v1_nonce_layout };
let mut plaintext = None;
let mut last_error = None;
for (layout, candidate_nonce) in layouts {
if selected.is_some_and(|expected| expected != layout) {
continue;
}
if layout == V1NonceLayout::ReusedPart && !*this.legacy_nonce_fallback {
continue;
}
match this.cipher.decrypt(candidate_nonce, ciphertext) {
Ok(value) => {
plaintext = Some((value, layout));
break;
match this.cipher.decrypt(&nonce, ciphertext) {
Ok(plaintext) => plaintext,
Err(primary_err) => {
let legacy_nonce =
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
match this.cipher.decrypt(&legacy_nonce, ciphertext) {
Ok(plaintext) => plaintext,
Err(_) => {
// Accept previously written streams that reused the part nonce
// for every block inside a segment.
let legacy_part_nonce = Nonce::try_from(legacy_part_nonce.as_slice())
.map_err(|_| Error::other("invalid nonce length"))?;
this.cipher
.decrypt(&legacy_part_nonce, ciphertext)
.map_err(|_| Error::other(format!("decrypt error: {primary_err}")))?
}
}
Err(error) => last_error = Some(error),
}
}
let (plaintext, layout) = plaintext.ok_or_else(|| {
Error::new(
std::io::ErrorKind::InvalidData,
format!(
"decrypt error: {}",
last_error.map_or_else(|| "nonce layout rejected".to_string(), |error| error.to_string())
),
)
})?;
if *this.block_index > 0 && this.v1_nonce_layout.is_none() {
*this.v1_nonce_layout = Some(layout);
}
plaintext
};
if *this.current_frame_type == FRAME_TYPE_V2_FINAL {
*this.saw_final_frame = true;
@@ -1079,154 +1003,6 @@ mod tests {
assert_eq!(&decrypted, data);
}
/// Encrypts `block_count` full v1 blocks, then overwrites frame one with a
/// verbatim copy of frame zero. Every frame is the same length, so the
/// stream keeps its original size and the forgery is invisible to any
/// length check.
async fn v1_stream_with_frame_zero_replayed_at_index_one(key: [u8; 32], nonce: [u8; 12], block_count: usize) -> Vec<u8> {
assert!(block_count >= 2, "a replay needs at least two frames");
let mut data = Vec::with_capacity(ENCRYPTION_BLOCK_SIZE * block_count);
for index in 0..block_count {
data.extend(std::iter::repeat_n(0xA1u8.wrapping_add(index as u8 * 17), ENCRYPTION_BLOCK_SIZE));
}
let mut encrypt_reader = EncryptReader::new(Cursor::new(data), key, nonce);
let mut encrypted = Vec::new();
encrypt_reader.read_to_end(&mut encrypted).await.expect("encrypt v1 frames");
// Header layout: [type][len:24][crc:32]; `len` counts the payload plus
// its own 4-byte CRC field, so the frame occupies 8 + (len - 4) bytes.
let declared_len = (encrypted[1] as usize) | ((encrypted[2] as usize) << 8) | ((encrypted[3] as usize) << 16);
let frame_len = 8 + declared_len - 4;
let replayed_first = encrypted[..frame_len].to_vec();
encrypted[frame_len..frame_len * 2].copy_from_slice(&replayed_first);
encrypted
}
#[tokio::test]
async fn decrypt_reader_rejects_a_replayed_first_v1_frame() {
let key = [0x11; 32];
let nonce = [0x22; 12];
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 3).await;
let mut decrypt_reader = DecryptReader::new(Cursor::new(encrypted), key, nonce);
let error = decrypt_reader
.read_to_end(&mut Vec::new())
.await
.expect_err("a repeated index-zero frame must not authenticate at index one");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
/// The in-segment layout lock must not cost compatibility: every legacy v1
/// shape the fallback chain exists for still decrypts under the default.
#[tokio::test]
async fn legacy_v1_streams_still_decrypt_under_the_default_fallback() {
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
assert!(legacy_nonce_fallback_enabled(), "the legacy nonce fallback must stay on by default");
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
rand::rng().fill(&mut data[..]);
// Modern single-part stream.
let mut encrypted = Vec::new();
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
.read_to_end(&mut encrypted)
.await
.expect("modern v1 stream should encrypt");
let mut decrypted = Vec::new();
DecryptReader::new(Cursor::new(encrypted), key, nonce)
.read_to_end(&mut decrypted)
.await
.expect("modern v1 stream should decrypt");
assert_eq!(decrypted, data);
// Pre-alpha.91 stream that reused the part nonce for every block.
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
let mut decrypted = Vec::new();
DecryptReader::new(Cursor::new(legacy), key, nonce)
.read_to_end(&mut decrypted)
.await
.expect("a reused-nonce legacy stream should still decrypt");
assert_eq!(decrypted, data);
})
.await;
}
/// The residual after the layout lock: a stream that is nothing but repeats
/// of frame zero has no later frame to disagree with the reused-part
/// layout, so only turning the fallback off rejects it.
#[tokio::test]
async fn a_two_frame_replay_is_closed_only_by_disabling_the_legacy_fallback() {
let key = [0x33; 32];
let nonce = [0x44; 12];
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 2).await;
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
let mut forged = Vec::new();
DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
.read_to_end(&mut forged)
.await
.expect("with the fallback on this forgery is still accepted");
assert_eq!(forged.len(), ENCRYPTION_BLOCK_SIZE * 2);
assert_eq!(
&forged[..ENCRYPTION_BLOCK_SIZE],
&forged[ENCRYPTION_BLOCK_SIZE..],
"the accepted forgery is frame zero's plaintext twice over"
);
})
.await;
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
let error = DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
.read_to_end(&mut Vec::new())
.await
.expect_err("with the fallback off the replayed frame must not authenticate");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
})
.await;
}
/// Turning the fallback off removes exactly the third layout: modern v1
/// streams keep decrypting, pre-alpha.91 reused-nonce streams stop.
#[tokio::test]
async fn disabling_the_legacy_nonce_fallback_refuses_only_reused_part_nonces() {
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
rand::rng().fill(&mut data[..]);
let mut modern = Vec::new();
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
.read_to_end(&mut modern)
.await
.expect("modern v1 stream should encrypt");
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
assert!(!legacy_nonce_fallback_enabled(), "the switch must be observed");
let mut decrypted = Vec::new();
DecryptReader::new(Cursor::new(modern), key, nonce)
.read_to_end(&mut decrypted)
.await
.expect("modern v1 streams must keep decrypting with the fallback off");
assert_eq!(decrypted, data);
let error = DecryptReader::new(Cursor::new(legacy), key, nonce)
.read_to_end(&mut Vec::new())
.await
.expect_err("the third layout must be gone when the fallback is off");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
})
.await;
}
#[tokio::test]
async fn test_decrypt_reader_only() {
// Encrypt some data first
-432
View File
@@ -1,432 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use bytes::Bytes;
use datafusion::object_store::{Error, Result};
use futures::{Stream, StreamExt, stream::BoxStream};
use transform_stream::AsyncTryStream;
use crate::SelectError;
/// Arrow accepts byte-sized CSV controls. Unicode quotes need streaming normalization.
pub fn csv_input_requires_normalization(quote: Option<&str>, escape: Option<&str>) -> bool {
quote.is_some_and(|quote| quote.len() > 1) || escape.is_some_and(|escape| escape.len() > 1)
}
/// CSV syntax independent of request headers, serialization formats, or S3 DTOs.
#[derive(Default)]
pub(crate) struct CsvSyntax<'a> {
pub quote: Option<&'a str>,
pub escape: Option<&'a str>,
pub field: Option<&'a str>,
pub record: Option<&'a str>,
pub comment: Option<u8>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
FieldStart,
Unquoted,
Quoted,
AfterQuote,
Escaped,
Comment,
}
/// Emits ordinary CSV with every field quoted. This avoids reserving a sentinel
/// byte that might also appear in a UTF-8 field. Only a partial control token is
/// retained between chunks; neither records nor objects are buffered.
struct CsvInputNormalizer {
quote: Vec<u8>,
escape: Vec<u8>,
field: Vec<u8>,
record: Vec<u8>,
comment: Option<u8>,
default_records: bool,
state: State,
record_start: bool,
carry: Vec<u8>,
token_size: usize,
}
impl CsvInputNormalizer {
fn new(csv: &CsvSyntax<'_>) -> Self {
let quote = csv
.quote
.filter(|value| !value.is_empty())
.unwrap_or("\"")
.as_bytes()
.to_vec();
let escape = csv
.escape
.filter(|value| !value.is_empty())
.unwrap_or("\"")
.as_bytes()
.to_vec();
let field = csv.field.filter(|value| !value.is_empty()).unwrap_or(",").as_bytes().to_vec();
let record = csv
.record
.filter(|value| !value.is_empty())
.unwrap_or("\n")
.as_bytes()
.to_vec();
let token_size = quote.len().max(escape.len()).max(field.len()).max(record.len()).max(2);
Self {
quote,
escape,
field,
record,
comment: csv.comment,
default_records: csv.record.is_none(),
state: State::FieldStart,
record_start: true,
carry: Vec::new(),
token_size,
}
}
fn record_len(&self, bytes: &[u8]) -> usize {
if self.default_records && bytes.starts_with(b"\r\n") {
2
} else if self.default_records && bytes.starts_with(b"\r") {
1
} else if bytes.starts_with(&self.record) {
self.record.len()
} else {
0
}
}
fn push_value(output: &mut Vec<u8>, bytes: &[u8]) {
for byte in bytes {
if *byte == b'"' {
output.push(b'"');
}
output.push(*byte);
}
}
fn convert(&mut self, chunk: &[u8], last: bool) -> std::result::Result<Vec<u8>, SelectError> {
let mut bytes = std::mem::take(&mut self.carry);
bytes.extend_from_slice(chunk);
let end = if last {
bytes.len()
} else {
bytes.len().saturating_sub(self.token_size - 1)
};
let mut output = Vec::with_capacity(bytes.len());
let mut pos = 0;
while pos < end {
let rest = &bytes[pos..];
let record_len = self.record_len(rest);
let field = rest.starts_with(&self.field) && self.field.len() > record_len;
match self.state {
State::Comment => {
if record_len > 0 {
self.state = State::FieldStart;
pos += record_len;
} else {
pos += 1;
}
}
State::Escaped => {
if record_len > 0 {
return Err(SelectError::CsvParsingError);
}
Self::push_value(&mut output, &rest[..1]);
self.state = State::Quoted;
pos += 1;
}
State::Quoted if rest.starts_with(&self.quote) => {
self.state = State::AfterQuote;
pos += self.quote.len();
}
State::Quoted if rest.starts_with(&self.escape) => {
self.state = State::Escaped;
pos += self.escape.len();
}
State::Quoted => {
if record_len > 0 {
return Err(SelectError::CsvParsingError);
}
Self::push_value(&mut output, &rest[..1]);
pos += 1;
}
State::AfterQuote if rest.starts_with(&self.quote) => {
Self::push_value(&mut output, &self.quote);
self.state = State::Quoted;
pos += self.quote.len();
}
State::FieldStart if self.record_start && self.comment == Some(rest[0]) => {
self.state = State::Comment;
pos += 1;
}
State::FieldStart if rest.starts_with(&self.quote) => {
output.push(b'"');
self.state = State::Quoted;
self.record_start = false;
pos += self.quote.len();
}
_ if field || record_len > 0 => {
if self.state == State::FieldStart {
if field || !self.record_start {
output.extend_from_slice(b"\"\"");
}
} else {
output.push(b'"');
}
output.push(if field { b',' } else { b'\n' });
self.state = State::FieldStart;
self.record_start = !field;
pos += if field { self.field.len() } else { record_len };
}
_ => {
if self.state == State::FieldStart {
output.push(b'"');
}
self.state = State::Unquoted;
self.record_start = false;
Self::push_value(&mut output, &rest[..1]);
pos += 1;
}
}
}
self.carry.extend_from_slice(&bytes[pos..]);
if last {
match self.state {
State::Quoted | State::Escaped => return Err(SelectError::CsvParsingError),
State::Unquoted | State::AfterQuote => output.push(b'"'),
State::FieldStart if !self.record_start => output.extend_from_slice(b"\"\""),
State::FieldStart | State::Comment => {}
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn normalize_chunks(csv: &CsvSyntax<'_>, input: &[u8], chunk_size: usize) -> Vec<u8> {
let mut normalizer = CsvInputNormalizer::new(csv);
let mut output = Vec::new();
for chunk in input.chunks(chunk_size) {
output.extend(normalizer.convert(chunk, false).expect("normalize complete CSV input"));
assert!(normalizer.carry.len() < normalizer.token_size, "only a partial token may be retained");
}
output.extend(normalizer.convert(&[], true).expect("finish complete CSV input"));
output
}
#[test]
fn unicode_csv_quotes_preserve_values_at_every_chunk_boundary() {
let cases = [
("ع", "\"", "عcol1ع,عcol2ع,عcol3ع\n", "\"col1\",\"col2\",\"col3\"\n"),
("ع", "\"", "\"left\",tail\n", "\"\"\"left\"\"\",\"tail\"\n"),
("ع", "\"", "عA,Bع,plain\n", "\"A,B\",\"plain\"\n"),
("ع", "\"", "عAععBع,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\"", "عA\"عBع,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\\", "عA\\\"Bع,tail\n", "\"A\"\"B\",\"tail\"\n"),
("\"", "", "\"A界\"B\",\"C\"\n", "\"A\"\"B\",\"C\"\n"),
("🦀", "🦀", "🦀A🦀🦀B🦀,C\n", "\"A🦀B\",\"C\"\n"),
("ع", "\"", "a\0b,عc\0\n", "\"a\0b\",\"c\0d\"\n"),
("ع", "\"", "AعB,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\"", "عaعsuffix,tail\n", "\"asuffix\",\"tail\"\n"),
("ع", "\"", ",\n", "\"\",\"\"\n"),
("ع", "\"", "a,", "\"a\",\"\""),
("ع", "\"", "عع", "\"\""),
("ع", "\"", "\n", "\n"),
("ع", "\"", "", ""),
];
for (quote, escape, input, expected) in cases {
let csv = CsvSyntax {
quote: Some(quote),
escape: Some(escape),
record: Some("\n"),
..Default::default()
};
for chunk_size in 1..=input.len().max(1) {
assert_eq!(
normalize_chunks(&csv, input.as_bytes(), chunk_size),
expected.as_bytes(),
"input={input:?}, chunk_size={chunk_size}"
);
}
}
}
#[test]
fn unicode_csv_quotes_keep_custom_delimiters_and_comments_out_of_values() {
let csv = CsvSyntax {
quote: Some("ع"),
escape: Some("\\"),
field: Some(""),
record: Some("^Y"),
comment: Some(b'#'),
};
let input = "#skipع界^Yعa界bع界\"literal\"^Yعline\nbreakع界end^Y";
let expected = "\"a界b\",\"\"\"literal\"\"\"\n\"line\nbreak\",\"end\"\n";
for chunk_size in 1..=input.len() {
assert_eq!(normalize_chunks(&csv, input.as_bytes(), chunk_size), expected.as_bytes());
}
}
#[test]
fn unicode_csv_quotes_reject_unterminated_fields_and_quoted_record_delimiters() {
for input in ["عunfinished", "عescape\\", "عline\nbreakع\n", "عline\\\nbreakع\n"] {
let csv = CsvSyntax {
quote: Some("ع"),
escape: Some("\\"),
..Default::default()
};
let mut normalizer = CsvInputNormalizer::new(&csv);
assert_eq!(normalizer.convert(input.as_bytes(), true), Err(SelectError::CsvParsingError));
}
}
#[test]
fn unicode_csv_quotes_preserve_omitted_syntax_defaults() {
assert!(!csv_input_requires_normalization(None, None));
assert!(!csv_input_requires_normalization(Some("\""), Some("\\")));
assert!(csv_input_requires_normalization(Some("ع"), None));
assert!(csv_input_requires_normalization(None, Some("")));
let quote_only = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
assert_eq!(
normalize_chunks(&quote_only, "عA\"عBع,tail\r\n".as_bytes(), 1),
"\"AعB\",\"tail\"\n".as_bytes()
);
let escape_only = CsvSyntax {
escape: Some(""),
..Default::default()
};
assert_eq!(
normalize_chunks(&escape_only, "\"A界\"B\",tail\r\n".as_bytes(), 1),
b"\"A\"\"B\",\"tail\"\n"
);
}
#[test]
fn unicode_csv_quotes_stream_large_fields_without_retaining_records() {
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut normalizer = CsvInputNormalizer::new(&csv);
let chunk = vec![b'x'; 64 * 1024];
let mut output_len = normalizer.convert("ع".as_bytes(), false).expect("opening quote").len();
for _ in 0..64 {
let output = normalizer.convert(&chunk, false).expect("stream field chunk");
assert!(output.len() >= chunk.len() - 3, "field data must be emitted before its closing quote");
assert!(normalizer.carry.len() < 4);
output_len += output.len();
}
output_len += normalizer.convert("ع\n".as_bytes(), true).expect("close field").len();
assert_eq!(output_len, chunk.len() * 64 + 3);
}
}
pub(crate) fn normalize_csv_stream<S>(stream: S, csv: &CsvSyntax<'_>) -> BoxStream<'static, Result<Bytes>>
where
S: Stream<Item = Result<Bytes>> + Send + 'static,
{
let mut normalizer = CsvInputNormalizer::new(csv);
AsyncTryStream::<Bytes, Error, _>::new(|mut y| async move {
futures::pin_mut!(stream);
while let Some(chunk) = stream.next().await {
let converted = normalizer.convert(&chunk?, false).map_err(|source| Error::Generic {
store: "EcObjectStore",
source: Box::new(source),
})?;
if !converted.is_empty() {
y.yield_ok(Bytes::from(converted)).await;
}
}
let converted = normalizer.convert(&[], true).map_err(|source| Error::Generic {
store: "EcObjectStore",
source: Box::new(source),
})?;
if !converted.is_empty() {
y.yield_ok(Bytes::from(converted)).await;
}
Ok(())
})
.boxed()
}
#[cfg(test)]
mod stream_tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
struct DropProbe(Arc<AtomicBool>);
impl Drop for DropProbe {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[tokio::test]
async fn unicode_csv_quotes_drop_the_source_without_reading_ahead() {
let polls = Arc::new(AtomicUsize::new(0));
let dropped = Arc::new(AtomicBool::new(false));
let source =
futures::stream::unfold((DropProbe(Arc::clone(&dropped)), Arc::clone(&polls)), |(guard, polls)| async move {
polls.fetch_add(1, Ordering::SeqCst);
Some((Ok(Bytes::from_static("عvalueع\n".as_bytes())), (guard, polls)))
});
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut stream = normalize_csv_stream(source, &csv);
assert!(!stream.next().await.expect("first output").expect("valid CSV").is_empty());
assert_eq!(polls.load(Ordering::SeqCst), 1);
drop(stream);
assert!(dropped.load(Ordering::SeqCst), "cancellation must release the source reader");
assert_eq!(polls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn unicode_csv_quotes_preserve_source_errors_after_partial_output() {
let source = futures::stream::iter([
Ok(Bytes::from_static("عvalueع\n".as_bytes())),
Err(Error::Generic {
store: "fixture",
source: std::io::Error::other("source read failed").into(),
}),
]);
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut stream = normalize_csv_stream(source, &csv);
assert!(!stream.next().await.expect("partial output").expect("valid prefix").is_empty());
let error = stream
.next()
.await
.expect("source failure must remain visible")
.expect_err("must not return a successful tail");
assert!(error.to_string().contains("source read failed"));
assert!(stream.next().await.is_none());
}
}
-2
View File
@@ -23,14 +23,12 @@ use datafusion::{
use std::{error::Error as StdError, fmt::Display};
use thiserror::Error;
mod csv_input;
mod input_stream;
mod metrics;
pub mod object_store;
pub mod query;
pub mod server;
mod storage_api;
pub use csv_input::csv_input_requires_normalization;
pub use metrics::{SelectInputMetrics, SelectInputMetricsSnapshot};
pub use storage_api::SelectObjectSnapshot;
+7 -109
View File
@@ -64,7 +64,6 @@ use tokio::{io::AsyncReadExt, sync::OnceCell};
use tokio_util::io::ReaderStream;
use transform_stream::AsyncTryStream;
use crate::csv_input::{CsvSyntax, csv_input_requires_normalization, normalize_csv_stream};
use crate::storage_api::object_store::HTTPRangeSpec;
mod json_document;
@@ -346,29 +345,6 @@ impl EcObjectStore {
(self.need_convert || (delimiter.len() == 2 && delimiter != NORMALIZED_RECORD_DELIMITER)).then_some(delimiter)
}
fn convert_csv_stream<S>(&self, stream: S) -> BoxStream<'static, Result<Bytes>>
where
S: Stream<Item = Result<Bytes>> + Send + 'static,
{
if let Some(csv) = self.input.request.input_serialization.csv.as_ref()
&& csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
{
let syntax = CsvSyntax {
quote: csv.quote_character.as_deref(),
escape: csv.quote_escape_character.as_deref(),
field: csv.field_delimiter.as_deref(),
record: csv.record_delimiter.as_deref(),
comment: csv.comments.as_ref().and_then(|comment| comment.as_bytes().first().copied()),
};
return normalize_csv_stream(stream, &syntax);
}
convert_csv_delimiter_stream(
stream,
self.record_delimiter_for_conversion(),
self.need_convert.then(|| self.delimiter.clone()),
)
}
fn csv_has_header(&self) -> bool {
self.input
.request
@@ -844,6 +820,7 @@ impl ObjectStore for EcObjectStore {
});
}
let record_delimiter = self.record_delimiter_for_conversion();
let needs_scan_context = options.range.is_none() && has_effective_request_range;
let scan_context = if needs_scan_context {
if let Some(scan_range) = self.scan_range(original_size)? {
@@ -906,7 +883,8 @@ impl ObjectStore for EcObjectStore {
max_processed_bytes,
query_guard,
)?;
let stream = self.convert_csv_stream(stream);
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
GetResultPayload::Stream(stream)
}
} else if options.range.is_some() {
@@ -959,7 +937,8 @@ impl ObjectStore for EcObjectStore {
} else {
stream
};
let stream = self.convert_csv_stream(stream);
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
GetResultPayload::Stream(stream)
} else {
let stream_size = usize::try_from(original_size).map_err(|err| o_Error::Generic {
@@ -969,7 +948,8 @@ impl ObjectStore for EcObjectStore {
let stream = bytes_stream(ReaderStream::with_capacity(reader.stream, SELECT_DEFAULT_READ_BUFFER_SIZE), stream_size);
if meter_input {
let stream = meter_uncompressed_input_stream(stream, Arc::clone(&self.input_metrics));
let stream = self.convert_csv_stream(stream);
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
GetResultPayload::Stream(stream)
} else {
GetResultPayload::Stream(stream.boxed())
@@ -2886,88 +2866,6 @@ mod test {
assert_eq!(input_metrics.snapshot().bytes_processed, 2);
}
#[tokio::test]
async fn unicode_csv_quotes_preserve_raw_offsets_and_metrics() {
const BUCKET: &str = "s3select-unicode-csv-stream";
const HEADER: &str = "عnameع,عkindع\n";
const SKIP: &str = "عskipع,عzeroع\n";
const ROW: &str = "عA,Bع,عAععBع\n";
let data = format!("{HEADER}{SKIP}{ROW}");
let env = crate::storage_api::select_test_ecstore_env().await;
env.make_bucket(BUCKET, false).await;
for (object, compression, range_offset) in [
("plain.csv", None, None),
("range.csv", None, Some(0)),
("range-mid-character.csv", None, Some(1)),
("gzip.csv", Some(CompressionFormat::Gzip), None),
("bzip.csv", Some(CompressionFormat::Bzip2), None),
] {
let bytes = match compression {
Some(format) => encode_compressed_fixture(format, data.as_bytes()).await,
None => data.as_bytes().to_vec(),
};
let raw_size = bytes.len();
let mut reader = SelectPutObjReader::from_vec(bytes);
env.ecstore
.put_object(BUCKET, object, &mut reader, &Default::default())
.await
.expect("write Unicode CSV fixture");
let mut input = (*csv_input(BUCKET, object)).clone();
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
csv.quote_character = Some("ع".to_owned());
csv.quote_escape_character = Some("\\".to_owned());
csv.record_delimiter = Some("\n".to_owned());
input.request.input_serialization.compression_type = compression.map(|format| {
CompressionType::from_static(match format {
CompressionFormat::Gzip => CompressionType::GZIP,
CompressionFormat::Bzip2 => CompressionType::BZIP2,
})
});
let start = HEADER.len() + SKIP.len();
if let Some(range_offset) = range_offset {
let offset = i64::try_from(start + range_offset).expect("fixture offset");
input.request.scan_range = Some(ScanRange {
start: Some(offset),
end: Some(offset),
});
}
let metrics = Arc::new(SelectInputMetrics::default());
let store = EcObjectStore::build_with_snapshot(
Arc::new(input),
Arc::new(GreedyMemoryPool::new(1024 * 1024)),
None,
Arc::clone(&metrics),
prepare_test_snapshot(BUCKET, object).await,
JsonSource::default(),
)
.expect("snapshot store");
let result = store
.get_opts(&Path::from(object), GetOptions::default())
.await
.expect("open Unicode CSV stream");
let GetResultPayload::Stream(stream) = result.payload else { panic!("CSV must remain streaming") };
let output = stream.try_collect::<Vec<_>>().await.expect("normalize CSV stream").concat();
let expected = match range_offset {
Some(0) => "\"name\",\"kind\"\n\"A,B\",\"AعB\"\n",
Some(_) => "\"name\",\"kind\"\n",
None => "\"name\",\"kind\"\n\"skip\",\"zero\"\n\"A,B\",\"AعB\"\n",
};
assert_eq!(output, expected.as_bytes(), "object={object}");
let measured = metrics.snapshot();
if let Some(range_offset) = range_offset {
// The range reader includes one byte of delimiter context and a
// separate header read; offsets always refer to the original CSV.
let processed = u64::try_from(ROW.len() + 1 - range_offset + HEADER.len()).expect("raw range length");
assert_eq!(measured.bytes_scanned, processed);
assert_eq!(measured.bytes_processed, processed);
} else {
assert_eq!(measured.bytes_scanned, u64::try_from(raw_size).expect("raw length"));
assert_eq!(measured.bytes_processed, u64::try_from(data.len()).expect("decoded length"));
}
}
}
#[tokio::test]
async fn compressed_object_uses_one_full_stream_and_rejects_internal_ranges() {
const BUCKET: &str = "s3select-compressed-object";
+1 -25
View File
@@ -456,12 +456,7 @@ impl SessionCtxFactory {
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
let metered_input_requires_single_file_scan =
input_metrics.is_some() && context.input.request.input_serialization.parquet.is_none();
let normalized_csv_requires_single_file_scan =
context.input.request.input_serialization.csv.as_ref().is_some_and(|csv| {
crate::csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
});
let config = if normalized_csv_requires_single_file_scan
|| custom_two_byte_record_delimiter
let config = if custom_two_byte_record_delimiter
|| scan_range_requires_single_file_scan
|| json_document_requires_single_file_scan
|| compressed_input_requires_single_file_scan
@@ -911,25 +906,6 @@ mod tests {
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
}
#[tokio::test]
async fn unicode_csv_quotes_disable_file_scan_repartition() {
let mut context = test_context();
Arc::get_mut(&mut context.input)
.expect("unique context")
.request
.input_serialization
.csv
.as_mut()
.expect("CSV input")
.quote_character = Some("ع".to_owned());
let session = SessionCtxFactory::new(true)
.with_target_partitions(4)
.create_session_ctx(&context)
.await
.expect("Unicode CSV session");
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
}
#[tokio::test]
async fn two_byte_csv_record_delimiter_disables_file_scan_repartition() {
let mut context = test_context();
+23 -139
View File
@@ -53,7 +53,7 @@ use rustfs_s3select_api::{
},
},
};
use s3s::dto::{FileHeaderInfo, JSONType, SelectObjectContentInput};
use s3s::dto::{CompressionType, FileHeaderInfo, JSONType, SelectObjectContentInput};
use std::sync::LazyLock;
use tokio::{
sync::Semaphore,
@@ -430,6 +430,13 @@ impl SimpleQueryDispatcher {
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
let table_path = ListingTableUrl::parse(path)?;
let compressed_input = self
.input
.request
.input_serialization
.compression_type
.as_ref()
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
let mut need_rename_volume_name = false;
@@ -478,27 +485,28 @@ impl SimpleQueryDispatcher {
if let Some(quote) = csv.quote_character.as_ref() {
file_format = file_format.with_quote(quote.as_bytes().first().copied().unwrap_or_default());
}
if rustfs_s3select_api::csv_input_requires_normalization(
csv.quote_character.as_deref(),
csv.quote_escape_character.as_deref(),
) {
file_format = file_format
.with_quote(b'"')
.with_escape(None)
.with_delimiter(b',')
.with_terminator(Some(b'\n'))
.with_comment(None)
.with_newlines_in_values(true);
}
(
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
ListingOptions::new(Arc::new(file_format)).with_file_extension(if compressed_input {
EXACT_OBJECT_FILE_EXTENSION
} else {
".csv"
}),
need_rename_volume_name,
need_ignore_volume_name,
)
} else if self.input.request.input_serialization.json.is_some() {
let file_format = JsonFormat::default();
let file_extension = if compressed_input {
EXACT_OBJECT_FILE_EXTENSION.to_string()
} else {
std::path::Path::new(&self.input.key)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| format!(".{extension}"))
.unwrap_or_else(|| ".json".to_string())
};
(
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
ListingOptions::new(Arc::new(file_format)).with_file_extension(file_extension),
false,
false,
)
@@ -1523,130 +1531,6 @@ mod tests {
assert_eq!(error.select_error(), SelectError::InvalidDataSource);
}
#[tokio::test]
async fn unicode_csv_quotes_reach_arrow_without_changing_field_values() {
let cases = [
("ع", "\"", ",", "\n", "عcol1ع,عcol2ع,عcol3ع\n", vec![vec!["col1", "col2", "col3"]]),
(
"ع",
"\\",
",",
"\n",
"\"literal\",عA\\\"Bع,عAععBع\n",
vec![vec!["\"literal\"", "A\"B", "AعB"]],
),
("\"", "", ",", "\n", "\"A界\"B\",🦀\n", vec![vec!["A\"B", "🦀"]]),
("ع", "\\", "", "^Y", "عa界bع界عline\nbreakع^Y", vec![vec!["a界b", "line\nbreak"]]),
];
let env = snapshot_test_env().await;
for (index, (quote, escape, field, record, data, expected)) in cases.into_iter().enumerate() {
let mut input = test_input();
input.bucket = format!("select-unicode-quotes-{index}");
input.key = "records".to_owned();
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::NONE));
csv.quote_character = Some(quote.to_owned());
csv.quote_escape_character = Some(escape.to_owned());
csv.field_delimiter = Some(field.to_owned());
csv.record_delimiter = Some(record.to_owned());
env.make_bucket(&input.bucket, false).await;
env.put_object_bytes(&input.bucket, &input.key, data.as_bytes().to_vec())
.await;
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
let input = Arc::new(input);
let dispatcher = production_dispatcher(Arc::clone(&input));
let query = Query::new_with_snapshot(QueryContext { input }, "SELECT * FROM S3Object".to_owned(), snapshot);
let output = dispatcher.execute_query(&query).await.expect("execute Unicode CSV query");
let mut stream = output.into_record_batch_stream().expect("record stream");
let mut rows = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.expect("Arrow must receive valid UTF-8 fields");
for row in 0..batch.num_rows() {
rows.push(
batch
.columns()
.iter()
.map(|column| {
column
.as_any()
.downcast_ref::<StringArray>()
.expect("CSV string column")
.value(row)
.to_owned()
})
.collect::<Vec<_>>(),
);
}
}
assert_eq!(rows, expected, "fixture={index}");
}
}
#[tokio::test]
async fn select_uses_input_serialization_independently_of_object_extension() {
for (key, json) in [
("records", false),
("records.bin", false),
("records", true),
("records.csv", true),
] {
let mut input = test_input();
input.key = key.to_owned();
let data = if json {
input.request.input_serialization.csv = None;
input.request.input_serialization.json = Some(s3s::dto::JSONInput {
type_: Some(JSONType::from_static(JSONType::LINES)),
});
b"{\"value\":\"selected\"}\n".as_slice()
} else {
b"value\nselected\n".as_slice()
};
let input = Arc::new(input);
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
let dispatcher = test_dispatcher_for_input(
Arc::clone(&input),
Arc::new(Semaphore::new(1)),
Duration::from_secs(30),
Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))),
);
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_owned());
let machine = dispatcher.build_query_state_machine(query).await.expect("build query state");
let store_url = ObjectStoreUrl::parse("s3://test-bucket").expect("test store URL");
let store = machine
.session
.inner()
.runtime_env()
.object_store(&store_url)
.expect("test store");
store.put(&Path::from(key), data.into()).await.expect("write selected object");
store
.put(&Path::from(format!("{key}.other")), b"unrelated\nwrong\n".as_slice().into())
.await
.expect("write neighboring object");
let plan = dispatcher
.build_logical_plan(Arc::clone(&machine))
.await
.expect("infer schema without an extension filter")
.expect("select plan");
let output = dispatcher
.execute_logical_plan(plan, machine)
.await
.expect("execute selected object");
let mut stream = output.into_record_batch_stream().expect("record stream");
let mut values = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.expect("selected batch");
let column = batch
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::StringArray>()
.expect("string column");
values.extend(column.iter().map(|value| value.expect("selected value").to_owned()));
}
assert_eq!(values, ["selected"], "key={key}, json={json}");
}
}
#[tokio::test]
async fn csv_query_uses_custom_record_delimiter_across_file_partitions() {
const ROW_COUNT: usize = 200_000;
+4 -13
View File
@@ -28,10 +28,10 @@ use metrics::{counter, describe_counter, describe_histogram, histogram};
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
pub use rustfs_data_usage::{
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSegmentInvalidationProof, DataUsageSnapshotSetState,
LEGACY_DATA_USAGE_OBJECT_NAME, PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary,
SizeReconciliationEntry, SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER,
UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSnapshotSetState, LEGACY_DATA_USAGE_OBJECT_NAME,
PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary, SizeReconciliationEntry,
SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP,
UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
};
use rustfs_heal_contracts::heal_channel::HealScanMode;
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
@@ -657,11 +657,6 @@ pub struct DataUsageCacheInfo {
/// structural plan remains reusable across ordinary bucket writes.
#[serde(default)]
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
/// Process-epoch and generation window that produced a complete set cache
/// with all known segment invalidation producers wired. This proof is
/// additive compatibility metadata; absence keeps segment reuse disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
/// Durable bucket incarnations captured for a complete set aggregate.
/// Missing or nil entries are legacy/unproven and cannot authorize
/// skipping an unselected bucket in a later scoped set scan.
@@ -691,7 +686,6 @@ impl Serialize for DataUsageCacheInfo {
+ usize::from(self.lkg_leader_epoch.is_some())
+ usize::from(self.lkg_scan_plan_digest.is_some())
+ usize::from(self.scan_execution_digest.is_some())
+ usize::from(self.segment_invalidation_proof.is_some())
+ usize::from(!self.scan_bucket_incarnations.is_empty());
let mut state = serializer.serialize_map(Some(field_count))?;
state.serialize_entry("name", &self.name)?;
@@ -752,9 +746,6 @@ impl Serialize for DataUsageCacheInfo {
if let Some(scan_execution_digest) = self.scan_execution_digest {
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
}
if let Some(proof) = &self.segment_invalidation_proof {
state.serialize_entry("segment_invalidation_proof", proof)?;
}
if !self.scan_bucket_incarnations.is_empty() {
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
}
@@ -1095,7 +1095,6 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
assert!(!decoded.snapshot_complete);
assert!(decoded.scan_plan_digest.is_none());
assert!(decoded.scan_execution_digest.is_none());
assert!(decoded.segment_invalidation_proof.is_none());
assert_eq!(decoded.cache_key_format, 0);
}
@@ -1184,13 +1183,6 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
segment_invalidation_proof: Some(DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 7,
generation_end: 9,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
}),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
@@ -1220,16 +1212,6 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
assert!(current.info.snapshot_complete);
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
assert_eq!(
current.info.segment_invalidation_proof,
Some(DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 7,
generation_end: 9,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
})
);
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
+3 -3
View File
@@ -97,9 +97,9 @@ pub use scanner::{
pub use scanner_io::{
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
record_dirty_usage_bucket_from_producer, record_dirty_usage_object, record_dirty_usage_object_from_producer,
record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot, scanner_dirty_usage_state,
scanner_maintenance_generation,
};
pub use segment_invalidation::SegmentInvalidationProducerIdentity;
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
+2 -14
View File
@@ -964,10 +964,9 @@ async fn run_scanner_usage_recovery_intents_for_startup(
Ok(attempted)
}
/// Start normal scanning when enabled, or one bounded recovery attempt.
/// Start normal scanning when enabled, or one resume-only cleanup attempt.
/// The disabled branch returns a finite task for the startup owner to join;
/// it never enables ordinary namespace scanning while it replays durable reset
/// intents and cleanup markers.
/// it never enables ordinary namespace scanning or accepts a new reset intent.
pub async fn init_scanner_with_recovery(
ctx: CancellationToken,
storeapi: Arc<ECStore>,
@@ -989,17 +988,6 @@ pub async fn init_scanner_with_recovery(
return None;
}
Some(tokio::spawn(async move {
if let Err(error) = run_scanner_usage_recovery_intents_for_startup(ctx.clone(), storeapi.clone()).await {
warn!(
target: "rustfs::scanner",
event = EVENT_SCANNER_PERSIST_STATE,
component = LOG_COMPONENT_SCANNER,
subsystem = LOG_SUBSYSTEM_RUNTIME,
state = "recovery_intent_startup_discovery_failed",
error = %error,
"Scanner recovery intent startup discovery failed"
);
}
if let Err(error) = resume_scanner_cycle_cleanup(ctx, storeapi).await {
warn!(
target: "rustfs::scanner",
+1 -98
View File
@@ -128,79 +128,6 @@ pub(super) mod cleanup_io_fault {
}
}
#[cfg(test)]
pub(super) mod recovery_intent_accept_fault {
use super::*;
enum Fault {
Corrupt,
Running,
}
static NEXT_ACCEPT_READBACK_FAULT: StdMutex<Option<Fault>> = StdMutex::new(None);
pub(in crate::scanner) struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
*NEXT_ACCEPT_READBACK_FAULT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
}
pub(in crate::scanner) fn corrupt_next_accept_readback() -> Guard {
install(Fault::Corrupt)
}
pub(in crate::scanner) fn advance_next_accept_readback_to_running() -> Guard {
install(Fault::Running)
}
fn install(fault: Fault) -> Guard {
let mut slot = NEXT_ACCEPT_READBACK_FAULT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert!(slot.is_none(), "only one recovery intent accept readback fault may be installed");
*slot = Some(fault);
Guard
}
pub(super) async fn maybe_apply<S>(storeapi: Arc<S>, path: &str) -> Result<(), ScannerError>
where
S: ScannerObjectIO,
{
let Some(fault) = NEXT_ACCEPT_READBACK_FAULT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.take()
else {
return Ok(());
};
match fault {
Fault::Corrupt => save_config(storeapi, path, b"{corrupt".to_vec()).await.map_err(|err| {
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
}),
Fault::Running => {
let mut record = read_recovery_intent_record(storeapi.clone(), path).await?.ok_or_else(|| {
ScannerError::Other("scanner recovery intent disappeared before fault injection".to_string())
})?;
record.state = SCANNER_RECOVERY_INTENT_STATE_RUNNING.to_string();
save_config(
storeapi,
path,
serde_json::to_vec(&record)
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent fault: {err}")))?,
)
.await
.map_err(|err| {
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
})
}
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ScannerCycleRecoveryStatus {
/// The immutable primary object whose revision is being guarded.
@@ -637,17 +564,6 @@ fn compare_recovery_intent(
}
}
fn confirm_recovery_intent_acceptance(
expected: ScannerRecoveryIntentRecord,
persisted: ScannerRecoveryIntentRecord,
) -> ScannerRecoveryIntentAcceptResult {
if persisted == expected {
ScannerRecoveryIntentAcceptResult::Accepted { record: persisted }
} else {
compare_recovery_intent(&expected, persisted)
}
}
async fn read_recovery_intent_record(
storeapi: Arc<impl ScannerObjectIO>,
path: &str,
@@ -841,20 +757,7 @@ pub async fn accept_scanner_usage_recovery_intent(
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent: {err}")))?;
match save_config_with_preconditions(storeapi.clone(), &path, encoded, DataUsageCacheRevision::Missing.preconditions()).await
{
Ok(_) => {
#[cfg(test)]
recovery_intent_accept_fault::maybe_apply(storeapi.clone(), &path).await?;
let persisted = match read_recovery_intent_record(storeapi.clone(), &path).await {
Ok(Some(record)) => record,
Ok(None) => {
return Err(ScannerError::Other(
"scanner recovery intent disappeared before acceptance confirmation".to_string(),
));
}
Err(error) => return Err(error),
};
Ok(confirm_recovery_intent_acceptance(candidate, persisted))
}
Ok(_) => Ok(ScannerRecoveryIntentAcceptResult::Accepted { record: candidate }),
Err(EcstoreError::PreconditionFailed) => {
let existing = read_recovery_intent_record(storeapi, &path).await?;
let Some(existing) = existing else {
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::super::cycle_state::{cleanup_io_fault, recovery_intent_accept_fault};
use super::super::cycle_state::cleanup_io_fault;
use super::*;
use crate::storage_api::owner::{EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats};
@@ -256,100 +256,6 @@ async fn scanner_recovery_intent_accept_is_durable_and_idempotent() {
assert_eq!(replay, ScannerRecoveryIntentAcceptResult::Replayed { record });
}
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_accept_requires_confirmed_readback() {
let (_dir, store) = setup_scanner_cycle_store().await;
let _fault = recovery_intent_accept_fault::corrupt_next_accept_readback();
let error =
accept_scanner_usage_recovery_intent(store.clone(), recovery_intent_request("intent-key-0001-readback", "operator-a"))
.await
.expect_err("accept must fail when the just-written intent cannot be confirmed");
assert!(error.to_string().contains("scanner recovery intent is invalid"), "{error}");
let restarted = restart_scanner_cycle_store_from(&store).await;
let error = scanner_usage_recovery_intents_for_startup(&CancellationToken::new(), restarted)
.await
.expect_err("unconfirmed corrupt intent must remain a fail-closed startup error");
assert!(error.to_string().contains("scanner recovery intent is invalid"), "{error}");
}
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_accept_replays_if_execution_advances_before_readback() {
let (_dir, store) = setup_scanner_cycle_store().await;
let request = recovery_intent_request("intent-key-0001-running", "operator-a");
let _fault = recovery_intent_accept_fault::advance_next_accept_readback_to_running();
let replay = accept_scanner_usage_recovery_intent(store.clone(), request.clone())
.await
.expect("same request advanced by execution remains idempotent");
let record = match replay {
ScannerRecoveryIntentAcceptResult::Replayed { record } => record,
other => panic!("advanced same-request record must replay instead of conflict: {other:?}"),
};
assert_eq!(record.state, "running");
let retry = accept_scanner_usage_recovery_intent(store, request)
.await
.expect("lost response retry observes the running durable record");
assert_eq!(retry, ScannerRecoveryIntentAcceptResult::Replayed { record });
}
#[tokio::test]
#[serial]
async fn concurrent_scanner_recovery_intent_acceptance_uses_one_durable_record() {
let (_dir, store) = setup_scanner_cycle_store().await;
let request = recovery_intent_request("intent-key-0001-concurrent", "operator-a");
let mut tasks = Vec::new();
for _ in 0..12 {
let store = store.clone();
let request = request.clone();
tasks.push(tokio::spawn(async move {
accept_scanner_usage_recovery_intent(store, request)
.await
.expect("concurrent same-key accept should converge")
}));
}
let mut accepted = 0usize;
let mut replayed = 0usize;
let mut records = Vec::new();
for task in tasks {
match task.await.expect("accept task should not panic") {
ScannerRecoveryIntentAcceptResult::Accepted { record } => {
accepted += 1;
records.push(record);
}
ScannerRecoveryIntentAcceptResult::Replayed { record } => {
replayed += 1;
records.push(record);
}
other => panic!("same-key accepts must not conflict: {other:?}"),
}
}
assert_eq!(accepted, 1, "exactly one request may win the missing-record CAS");
assert_eq!(replayed, 11, "all other same-key requests must replay the durable winner");
assert!(
records.windows(2).all(|pair| pair[0] == pair[1]),
"all accepts must return the same durable identity"
);
let restarted = restart_scanner_cycle_store_from(&store).await;
let replayable = scanner_usage_recovery_intents_for_startup(&CancellationToken::new(), restarted.clone())
.await
.expect("startup should rediscover the single indexed intent");
assert_eq!(replayable, vec![records[0].intent_id.clone()]);
let replay = accept_scanner_usage_recovery_intent(restarted, request)
.await
.expect("lost response after restart should replay the same record");
assert_eq!(
replay,
ScannerRecoveryIntentAcceptResult::Replayed {
record: records[0].clone()
}
);
}
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_executor_persists_completed_progress() {
@@ -551,7 +457,7 @@ async fn scanner_recovery_intent_startup_rejects_corrupt_pending_record() {
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_disabled_startup_replays_non_terminal_intent() {
async fn scanner_recovery_intent_disabled_startup_preserves_non_terminal_intent() {
let (_dir, store) = setup_scanner_cycle_store().await;
let record = match accept_scanner_usage_recovery_intent(
store.clone(),
@@ -567,12 +473,12 @@ async fn scanner_recovery_intent_disabled_startup_replays_non_terminal_intent()
let restarted = restart_scanner_cycle_store_from(&store).await;
run_disabled_startup(CancellationToken::new(), restarted.clone()).await;
let completed = get_scanner_usage_recovery_intent(restarted, &record.intent_id)
let preserved = get_scanner_usage_recovery_intent(restarted, &record.intent_id)
.await
.expect("startup-replayed intent should read")
.expect("startup-replayed intent should remain durable");
assert_eq!(completed.state, "completed");
assert_eq!(completed.intent_id, record.intent_id);
.expect("startup-skipped intent should read")
.expect("startup-skipped intent should remain durable");
assert_eq!(preserved.state, "accepted");
assert_eq!(preserved.intent_id, record.intent_id);
}
#[tokio::test]
@@ -641,17 +547,6 @@ async fn scanner_recovery_intent_query_rejects_corrupt_or_unknown_records() {
.await
.expect_err("corrupt intent must not decode as absent");
assert!(error.to_string().contains("scanner recovery intent is invalid"));
let mut future = serde_json::to_value(&record).expect("record value");
future["future_writer_capability"] = serde_json::json!("durable-accept-v2");
save_config(store.clone(), &path, serde_json::to_vec(&future).expect("future record should encode"))
.await
.expect("future durable record");
let error = get_scanner_usage_recovery_intent(store.clone(), &record.intent_id)
.await
.expect_err("future writer payload must not decode as a known terminal state");
assert!(error.to_string().contains("scanner recovery intent is invalid"));
let unknown = get_scanner_usage_recovery_intent(store, &scanner_recovery_actor_sha256("missing"))
.await
.expect("missing intent should read as absent");
+7 -321
View File
@@ -39,7 +39,7 @@ use s3s::dto::{
BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration,
};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
@@ -98,24 +98,6 @@ const METRIC_SCANNER_SET_SCANS_QUEUED: &str = "rustfs_scanner_set_scans_queued";
const METRIC_SCANNER_DISK_BUCKET_SCANS_ACTIVE: &str = "rustfs_scanner_disk_bucket_scans_active";
const METRIC_SCANNER_DISK_BUCKET_SCANS_QUEUED: &str = "rustfs_scanner_disk_bucket_scans_queued";
pub(crate) const SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS: [&str; 7] = [
"source",
"bucket_incarnation",
"key_format",
"baseline_scan_plan_digest",
"process_epoch",
"generation_window",
"producer_identities",
];
pub(crate) const SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS: [&str; 6] = [
"missing_producer_identity",
"restart_gap",
"generation_gap",
"overflow",
"missing_cold_zero_walk_oracle",
"distributed_without_peer_invalidation",
];
pub type DirtyUsageBuckets = HashMap<String, u64>;
#[derive(Clone, Debug)]
@@ -181,52 +163,11 @@ struct ScannerPeerDirtyUsageExpectation {
struct VerifiedRemoteDirtyUsage {
dirty_buckets: HashSet<String>,
acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
peer_count: usize,
dirty_peer_count: usize,
}
struct ScannerBucketScopeResolutionResult {
scope: ScannerBucketScanScope,
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct DistributedSegmentInvalidationEvidence {
pub(crate) invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain,
pub(crate) distributed_ec_invalidation: bool,
pub(crate) peer_count: usize,
pub(crate) dirty_peer_count: usize,
pub(crate) same_window_remote_proof: bool,
pub(crate) all_peers_bound_to_generation_window: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct ScannerSegmentReuseActivationProof {
pub(crate) production_activation: bool,
pub(crate) producer_identity_coverage_complete: bool,
pub(crate) durable_producer_identity: bool,
pub(crate) restart_gap_absent: bool,
pub(crate) generation_window_bound: bool,
pub(crate) overflow_absent: bool,
pub(crate) cold_zero_walk_oracle: bool,
pub(crate) distributed_peer_invalidation: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ScannerSegmentReuseActivationPreflight {
pub(crate) production_activation: bool,
pub(crate) scanner_segment_reuse_activated: bool,
pub(crate) proof_inputs: &'static [&'static str],
pub(crate) fail_closed_checks: &'static [&'static str],
pub(crate) fail_closed_blockers: [Option<&'static str>; 6],
}
impl ScannerSegmentReuseActivationPreflight {
#[cfg(test)]
pub(crate) fn fail_closed_blockers(&self) -> impl Iterator<Item = &'static str> + '_ {
self.fail_closed_blockers.iter().filter_map(|blocker| *blocker)
}
}
fn verified_remote_dirty_usage(
@@ -250,7 +191,6 @@ fn verified_remote_dirty_usage(
|| !snapshot.complete
|| snapshot.pending_bucket_count != u64::try_from(snapshot.buckets.len()).unwrap_or(u64::MAX)
|| (expected.pending && snapshot.pending_bucket_count == 0)
|| (!expected.pending && snapshot.pending_bucket_count != 0)
{
return None;
}
@@ -276,13 +216,9 @@ fn verified_remote_dirty_usage(
}
}
let peer_count = received_peers.len();
let dirty_peer_count = acknowledgements.len();
(received_peers.len() == expected_peers.len()).then_some(VerifiedRemoteDirtyUsage {
dirty_buckets,
acknowledgements,
peer_count,
dirty_peer_count,
})
}
@@ -308,11 +244,8 @@ fn resolve_remote_dirty_usage_scope(
let default_result = |scope: ScannerBucketScanScope| ScannerBucketScopeResolutionResult {
scope,
remote_dirty_usage_acknowledgements: Vec::new(),
distributed_segment_invalidation_evidence: None,
};
let peer_count = remote_dirty_usage.peer_count;
let dirty_peer_count = remote_dirty_usage.dirty_peer_count;
dirty_buckets.extend(remote_dirty_usage.dirty_buckets);
// Peer snapshots contribute bucket names only; the local prefix scopes
// would narrow a bucket a peer dirtied elsewhere, so the merged scope
@@ -350,21 +283,10 @@ fn resolve_remote_dirty_usage_scope(
if scanner_scoped_dirty_usage_ack_exceeds_cost_threshold(&scoped_acknowledgements) {
return default_result(ScannerBucketScanScope::default());
}
let has_scoped_acknowledgements = !scoped_acknowledgements.is_empty();
ScannerBucketScopeResolutionResult {
scope,
remote_dirty_usage_acknowledgements: scoped_acknowledgements,
distributed_segment_invalidation_evidence: (dirty_peer_count > 0 && has_scoped_acknowledgements).then_some(
DistributedSegmentInvalidationEvidence {
invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc,
distributed_ec_invalidation: true,
peer_count,
dirty_peer_count,
same_window_remote_proof: true,
all_peers_bound_to_generation_window: true,
},
),
}
}
@@ -436,60 +358,6 @@ fn complete_scanner_cache_baseline_plan_digest(proof: ScannerCacheBaselineProof<
complete_scanner_cache_snapshot_plan_digest(&observed, proof, false)
}
fn scanner_segment_reuse_baseline_producer_evidence(
dirty_usage_snapshot: &DirtyUsageSnapshot,
baseline_proof: ScannerCacheBaselineProof<'_>,
) -> (DirtyUsageProducerEvidence, bool) {
let mut evidence = dirty_usage_producer_evidence(dirty_usage_snapshot);
if !evidence.generation_window_bound || !evidence.producer_identity_coverage_complete {
return (evidence, false);
}
let Some(authoritative_data) = baseline_proof.authoritative_data else {
return (evidence, false);
};
let Ok(authoritative) = serde_json::from_slice::<DataUsageInfo>(authoritative_data) else {
return (evidence, false);
};
if complete_scanner_cache_snapshot_plan_digest(&authoritative, baseline_proof, true).is_none()
|| !scanner_snapshot_set_states_have_segment_reuse_activation_proof(
&authoritative,
&evidence,
baseline_proof.expected_sources,
)
{
return (evidence, false);
}
evidence.durable_producer_identity = true;
evidence.restart_gap_absent = true;
(evidence, true)
}
fn scanner_snapshot_set_states_have_segment_reuse_activation_proof(
snapshot: &DataUsageInfo,
evidence: &DirtyUsageProducerEvidence,
expected_sources: &HashSet<DataUsageCacheSource>,
) -> bool {
let mut covered_sources = HashSet::with_capacity(expected_sources.len());
let all_sets_proved = snapshot.usage_snapshot_set_states.iter().all(|state| {
let (Ok(pool_index), Ok(set_index)) = (usize::try_from(state.pool_index), usize::try_from(state.set_index)) else {
return false;
};
let source = DataUsageCacheSource::new(pool_index, set_index);
state.complete
&& !state.tombstone
&& expected_sources.contains(&source)
&& covered_sources.insert(source)
&& scanner_segment_invalidation_proof_matches(state.segment_invalidation_proof.as_ref(), evidence)
&& state
.segment_invalidation_proof
.as_ref()
.is_some_and(|proof| proof.cold_zero_walk_oracle)
});
all_sets_proved && covered_sources.len() == expected_sources.len()
}
fn scoped_scan_scope_from_dirty_buckets(
requested_scope: ScannerBucketScanScope,
dirty_buckets: HashSet<String>,
@@ -538,152 +406,10 @@ fn scoped_scan_scope_from_dirty_buckets(
ScannerBucketScanScope::from_dirty_buckets(selected_buckets, selected_bucket_prefixes, baseline_scan_plan_digest)
}
fn scanner_segment_reuse_activation_preflight() -> ScannerSegmentReuseActivationPreflight {
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
production_activation: true,
..Default::default()
})
}
fn scanner_segment_reuse_activation_preflight_from_proof(
proof: ScannerSegmentReuseActivationProof,
) -> ScannerSegmentReuseActivationPreflight {
ScannerSegmentReuseActivationPreflight {
production_activation: proof.production_activation,
scanner_segment_reuse_activated: proof.production_activation
&& proof.producer_identity_coverage_complete
&& proof.durable_producer_identity
&& proof.restart_gap_absent
&& proof.generation_window_bound
&& proof.overflow_absent
&& proof.cold_zero_walk_oracle
&& proof.distributed_peer_invalidation,
proof_inputs: &SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS,
fail_closed_checks: &SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS,
fail_closed_blockers: [
(!proof.producer_identity_coverage_complete || !proof.durable_producer_identity)
.then_some("missing_producer_identity"),
(!proof.restart_gap_absent).then_some("restart_gap"),
(!proof.generation_window_bound).then_some("generation_gap"),
(!proof.overflow_absent).then_some("overflow"),
(!proof.cold_zero_walk_oracle).then_some("missing_cold_zero_walk_oracle"),
(!proof.distributed_peer_invalidation).then_some("distributed_without_peer_invalidation"),
],
}
}
fn scanner_segment_reuse_activation_preflight_for_cycle(
dirty_usage_snapshot: &DirtyUsageSnapshot,
dirty_usage_producer_evidence: DirtyUsageProducerEvidence,
distributed: bool,
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
cold_zero_walk_oracle: bool,
) -> ScannerSegmentReuseActivationPreflight {
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
production_activation: true,
producer_identity_coverage_complete: dirty_usage_producer_evidence.producer_identity_coverage_complete,
durable_producer_identity: dirty_usage_producer_evidence.durable_producer_identity,
restart_gap_absent: dirty_usage_producer_evidence.restart_gap_absent,
generation_window_bound: dirty_usage_snapshot.covers_all_pending
&& dirty_usage_snapshot.generation != 0
&& dirty_usage_snapshot.generation != u64::MAX
&& dirty_usage_producer_evidence.generation_window_bound,
overflow_absent: dirty_usage_snapshot.covers_all_pending,
cold_zero_walk_oracle,
distributed_peer_invalidation: scanner_distributed_segment_invalidation_admitted(
distributed,
distributed_segment_invalidation_evidence,
),
})
}
fn scanner_segment_reuse_activation_preflight_for_baseline(
dirty_usage_snapshot: &DirtyUsageSnapshot,
distributed: bool,
baseline_proof: ScannerCacheBaselineProof<'_>,
) -> ScannerSegmentReuseActivationPreflight {
let (dirty_usage_producer_evidence, cold_zero_walk_oracle) =
scanner_segment_reuse_baseline_producer_evidence(dirty_usage_snapshot, baseline_proof);
scanner_segment_reuse_activation_preflight_for_cycle(
dirty_usage_snapshot,
dirty_usage_producer_evidence,
distributed,
None,
cold_zero_walk_oracle,
)
}
fn scanner_distributed_segment_invalidation_admitted(
distributed: bool,
evidence: Option<DistributedSegmentInvalidationEvidence>,
) -> bool {
if !distributed {
return true;
}
evidence.is_some_and(|evidence| {
evidence.invalidation_domain == crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc
&& evidence.distributed_ec_invalidation
&& evidence.same_window_remote_proof
&& evidence.all_peers_bound_to_generation_window
&& evidence.dirty_peer_count > 0
&& evidence.dirty_peer_count <= evidence.peer_count
})
}
fn scanner_durable_segment_invalidation_evidence(
dirty_usage_snapshot: &DirtyUsageSnapshot,
results: &[DataUsageCache],
expected_sources: &HashSet<DataUsageCacheSource>,
) -> DirtyUsageProducerEvidence {
let mut evidence = dirty_usage_producer_evidence(dirty_usage_snapshot);
if !evidence.generation_window_bound
|| !evidence.producer_identity_coverage_complete
|| !scanner_results_form_complete_snapshot(results, expected_sources)
{
return evidence;
}
let mut covered_sources = HashSet::with_capacity(expected_sources.len());
let all_sets_proved = results.iter().all(|result| {
let Some(source) = result.info.source else {
return false;
};
expected_sources.contains(&source)
&& covered_sources.insert(source)
&& scanner_segment_invalidation_proof_matches(result.info.segment_invalidation_proof.as_ref(), &evidence)
});
if all_sets_proved && covered_sources.len() == expected_sources.len() {
evidence.durable_producer_identity = true;
evidence.restart_gap_absent = true;
}
evidence
}
fn scanner_segment_invalidation_proof_matches(
proof: Option<&crate::DataUsageSegmentInvalidationProof>,
evidence: &DirtyUsageProducerEvidence,
) -> bool {
proof.is_some_and(|proof| {
proof.process_epoch == scanner_activity_epoch()
&& proof.generation_start == evidence.generation_start
&& proof.generation_end == evidence.generation_end
&& proof.producer_identity_coverage_complete
})
}
fn scanner_completed_set_segment_invalidation_proof(
proof: &Option<crate::DataUsageSegmentInvalidationProof>,
cold_zero_walk_reuse_candidate: bool,
) -> Option<crate::DataUsageSegmentInvalidationProof> {
proof.clone().map(|mut proof| {
proof.cold_zero_walk_oracle = cold_zero_walk_reuse_candidate;
proof
})
}
#[cfg(test)]
fn scanner_segment_reuse_activated() -> bool {
scanner_segment_reuse_activation_preflight().scanner_segment_reuse_activated
// Production segment reuse stays disabled until a durable mutation-stream
// proof satisfies the segment invalidation contract.
false
}
pub(crate) fn is_scanner_metadata_corrupt_error(err: &StorageError) -> bool {
@@ -745,8 +471,6 @@ pub struct ScannerBucketScanPlan {
bucket_failures: ScannerBucketFailureState,
pending_maintenance_work: Arc<AtomicBool>,
cache_cycle_floor: Arc<AtomicU64>,
cold_zero_walk_reuse_observed: Arc<AtomicBool>,
segment_invalidation_proof: Option<crate::DataUsageSegmentInvalidationProof>,
}
#[derive(Clone, Default)]
@@ -879,25 +603,6 @@ fn scanner_bucket_scan_status(has_failed: bool, has_partial: bool, has_namespace
}
}
fn scanner_cycle_cold_zero_walk_oracle(
scan_scope: &ScannerBucketScanScope,
all_buckets: &[BucketInfo],
completed_all_sets: bool,
scan_scope_matches: bool,
bucket_scan_status: ScannerBucketScanStatus,
cold_zero_walk_reuse_observed: bool,
) -> bool {
let Some(selected_buckets) = scan_scope.selected_buckets.as_deref() else {
return false;
};
cold_zero_walk_reuse_observed
&& !selected_buckets.is_empty()
&& completed_all_sets
&& scan_scope_matches
&& bucket_scan_status == ScannerBucketScanStatus::Complete
&& all_buckets.iter().any(|bucket| !selected_buckets.contains(&bucket.name))
}
fn classify_nsscanner_cycle(
completed_all_sets: bool,
budget_elapsed: bool,
@@ -1351,8 +1056,6 @@ pub(crate) struct ScannerCycleResult {
observational_snapshot_published: bool,
dirty_usage_clear: Option<DirtyUsageBuckets>,
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
segment_reuse_activation_preflight: ScannerSegmentReuseActivationPreflight,
remote_publication_lease_targets: Vec<(String, String, u64)>,
failed_dirty_usage: bool,
pending_maintenance_work: bool,
@@ -1369,8 +1072,6 @@ impl ScannerCycleResult {
observational_snapshot_published: false,
dirty_usage_clear,
remote_dirty_usage_acknowledgements: Vec::new(),
distributed_segment_invalidation_evidence: None,
segment_reuse_activation_preflight: scanner_segment_reuse_activation_preflight(),
remote_publication_lease_targets: Vec::new(),
failed_dirty_usage: false,
pending_maintenance_work: false,
@@ -1436,21 +1137,6 @@ impl ScannerCycleResult {
self
}
fn with_distributed_segment_invalidation_evidence(
mut self,
evidence: Option<DistributedSegmentInvalidationEvidence>,
) -> Self {
self.publication_expectation = None;
self.distributed_segment_invalidation_evidence = evidence;
self
}
fn with_segment_reuse_activation_preflight(mut self, preflight: ScannerSegmentReuseActivationPreflight) -> Self {
self.publication_expectation = None;
self.segment_reuse_activation_preflight = preflight;
self
}
pub(crate) fn with_remote_publication_lease_targets(mut self, targets: Vec<(String, String, u64)>) -> Self {
self.publication_expectation = None;
self.remote_publication_lease_targets = targets;
@@ -1538,9 +1224,9 @@ pub(crate) use cache::{
pub use dirty_usage::{
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
record_dirty_usage_bucket_from_producer, record_dirty_usage_object, record_dirty_usage_object_from_producer,
record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot, scanner_dirty_usage_state,
scanner_maintenance_generation,
};
#[cfg(test)]
pub(crate) use dirty_usage::{clear_dirty_usage_buckets_for_tests, dirty_usage_buckets_for_tests};
+1 -6
View File
@@ -464,7 +464,6 @@ pub(super) fn completed_usage_candidate(
scan_plan_digest: Some(result.info.scan_plan_digest?.0),
complete: true,
tombstone: false,
segment_invalidation_proof: result.info.segment_invalidation_proof.clone(),
})
})
.collect::<Option<Vec<_>>>()?;
@@ -643,14 +642,13 @@ pub(super) fn observational_data_usage_info(
let current_snapshot = current.is_some();
let selected = current.or(lkg);
if let Some(selected) = selected {
let (cycle, epoch, digest, last_update, complete, segment_invalidation_proof) = if current_snapshot {
let (cycle, epoch, digest, last_update, complete) = if current_snapshot {
(
Some(selected.info.next_cycle),
Some(selected.info.leader_epoch),
selected.info.scan_plan_digest.map(|digest| digest.0),
selected.info.last_update,
true,
selected.info.segment_invalidation_proof.clone(),
)
} else {
(
@@ -659,7 +657,6 @@ pub(super) fn observational_data_usage_info(
selected.info.lkg_scan_plan_digest.map(|digest| digest.0),
selected.info.lkg_last_update,
false,
None,
)
};
set_states.push(DataUsageSnapshotSetState {
@@ -670,7 +667,6 @@ pub(super) fn observational_data_usage_info(
scan_plan_digest: digest,
complete,
tombstone: false,
segment_invalidation_proof,
});
usable.push((selected, last_update));
} else {
@@ -682,7 +678,6 @@ pub(super) fn observational_data_usage_info(
scan_plan_digest: Some(expected_plan_digest.0),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
});
}
}
+23 -122
View File
@@ -16,16 +16,17 @@ use super::*;
pub(super) static DIRTY_USAGE_BUCKET_GENERATION: AtomicU64 = AtomicU64::new(0);
pub(super) static DIRTY_USAGE_BUCKETS: LazyLock<StdMutex<DirtyUsageBuckets>> = LazyLock::new(|| StdMutex::new(HashMap::new()));
// Lock order when dirty usage state is updated is `DIRTY_USAGE_BUCKETS`,
// `DIRTY_USAGE_BUCKET_SCOPES`, then `DIRTY_USAGE_PRODUCER_IDENTITIES`. All
// guards are held only for synchronous map updates, so no scanner task can
// observe a bucket generation without its matching scope and producer evidence.
// Lock order when both dirty maps are needed is `DIRTY_USAGE_BUCKETS` followed
// by `DIRTY_USAGE_BUCKET_SCOPES`. Both are held only for synchronous map
// updates, so no scanner task can observe a bucket generation without its
// matching scope.
pub(super) static DIRTY_USAGE_BUCKET_SCOPES: LazyLock<StdMutex<DirtyUsageBucketScopes>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
// Non-authoritative process-local producer coverage. Any future segment reuse
// activation must bind this to the exact generation window and durable proof.
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<StdMutex<DirtyUsageProducerIdentities>> =
LazyLock::new(|| StdMutex::new(BTreeMap::new()));
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<
StdMutex<BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>>,
> = LazyLock::new(|| StdMutex::new(BTreeSet::new()));
pub(super) static DIRTY_USAGE_BUCKET_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
pub(super) static SCANNER_ACTIVITY_EPOCH: LazyLock<String> = LazyLock::new(|| format!("{:032x}", rand::random::<u128>()));
pub(super) static SCANNER_MAINTENANCE_GENERATION: AtomicU64 = AtomicU64::new(0);
@@ -58,39 +59,6 @@ pub(super) type DirtyUsageBucketScopes = HashMap<String, DirtyUsageBucketScope>;
const MAX_DIRTY_USAGE_TOP_LEVEL_ENTRIES_PER_BUCKET: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct DirtyUsageProducerIdentityState {
first_generation: u64,
last_generation: u64,
}
pub(super) type DirtyUsageProducerIdentities =
BTreeMap<crate::segment_invalidation::SegmentInvalidationProducerIdentity, DirtyUsageProducerIdentityState>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct DirtyUsageProducerEvidence {
pub(super) producer_identity_coverage_complete: bool,
pub(super) durable_producer_identity: bool,
pub(super) restart_gap_absent: bool,
pub(super) generation_window_bound: bool,
pub(super) generation_start: u64,
pub(super) generation_end: u64,
}
impl DirtyUsageProducerEvidence {
pub(super) fn segment_invalidation_proof(self) -> Option<crate::DataUsageSegmentInvalidationProof> {
(self.generation_window_bound && self.producer_identity_coverage_complete).then(|| {
crate::DataUsageSegmentInvalidationProof {
process_epoch: scanner_activity_epoch().to_string(),
generation_start: self.generation_start,
generation_end: self.generation_end,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: false,
}
})
}
}
/// A point-in-time view of the local dirty bucket generations.
///
/// `complete == false` is an all-or-nothing overflow signal: `buckets` is
@@ -258,21 +226,12 @@ mod scoped_dirty_usage_tests {
record_dirty_usage_object_from_producer("photos", "hot/object", SegmentInvalidationProducerIdentity::PutObject);
record_dirty_usage_object_from_producer("photos", "archive/object", SegmentInvalidationProducerIdentity::DeleteObject);
record_dirty_usage_bucket_from_producer("photos", SegmentInvalidationProducerIdentity::Unknown);
record_dirty_usage_bucket_from_producers(
"photos",
[
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
],
);
assert_eq!(
dirty_usage_producer_identities_for_tests(),
BTreeSet::from([
SegmentInvalidationProducerIdentity::PutObject,
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::AbortMultipartUpload
SegmentInvalidationProducerIdentity::DeleteObject
])
);
assert_eq!(
@@ -296,7 +255,8 @@ fn dirty_usage_bucket_scopes() -> MutexGuard<'static, DirtyUsageBucketScopes> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn dirty_usage_producer_identities() -> MutexGuard<'static, DirtyUsageProducerIdentities> {
fn dirty_usage_producer_identities()
-> MutexGuard<'static, BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>> {
DIRTY_USAGE_PRODUCER_IDENTITIES
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -317,7 +277,7 @@ pub fn record_dirty_usage_bucket(bucket: &str) {
return;
}
record_dirty_usage_bucket_inner(bucket, std::iter::empty());
record_dirty_usage_bucket_inner(bucket);
}
pub fn record_dirty_usage_bucket_from_producer(
@@ -328,32 +288,17 @@ pub fn record_dirty_usage_bucket_from_producer(
return;
}
record_dirty_usage_bucket_inner(bucket, [producer]);
record_segment_invalidation_producer_identity(producer);
record_dirty_usage_bucket_inner(bucket);
}
pub fn record_dirty_usage_bucket_from_producers<I>(bucket: &str, producers: I)
where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
if bucket.is_empty() {
return;
}
record_dirty_usage_bucket_inner(bucket, producers);
}
fn record_dirty_usage_bucket_inner<I>(bucket: &str, producers: I)
where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
fn record_dirty_usage_bucket_inner(bucket: &str) {
let pending_buckets = {
let mut dirty_buckets = dirty_usage_buckets();
let mut dirty_scopes = dirty_usage_bucket_scopes();
let mut producer_identities = dirty_usage_producer_identities();
let generation = advance_generation(&DIRTY_USAGE_BUCKET_GENERATION);
dirty_buckets.insert(bucket.to_string(), generation);
dirty_scopes.insert(bucket.to_string(), DirtyUsageBucketScope::WholeBucket);
record_segment_invalidation_producer_identities_for_generation(&mut producer_identities, generation, producers);
dirty_buckets.len()
};
global_metrics().record_scanner_dirty_usage_pending(usize_to_u64_saturated(pending_buckets));
@@ -371,7 +316,7 @@ where
/// local: after restart or any unverified distributed path the scanner falls
/// back to its ordinary bucket scan.
pub fn record_dirty_usage_object(bucket: &str, object: &str) {
record_dirty_usage_object_inner(bucket, object, std::iter::empty());
record_dirty_usage_object_inner(bucket, object);
}
pub fn record_dirty_usage_object_from_producer(
@@ -383,16 +328,13 @@ pub fn record_dirty_usage_object_from_producer(
return;
}
record_dirty_usage_object_inner(bucket, object, [producer]);
record_segment_invalidation_producer_identity(producer);
record_dirty_usage_object_inner(bucket, object);
}
fn record_dirty_usage_object_inner<I>(bucket: &str, object: &str, producers: I)
where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
let producers = producers.into_iter().collect::<Vec<_>>();
fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
let Some(top_level_entry) = dirty_usage_top_level_entry(object) else {
record_dirty_usage_bucket_inner(bucket, producers);
record_dirty_usage_bucket(bucket);
return;
};
if bucket.is_empty() {
@@ -402,7 +344,6 @@ where
let pending_buckets = {
let mut dirty_buckets = dirty_usage_buckets();
let mut dirty_scopes = dirty_usage_bucket_scopes();
let mut producer_identities = dirty_usage_producer_identities();
let generation = advance_generation(&DIRTY_USAGE_BUCKET_GENERATION);
dirty_buckets.insert(bucket.to_string(), generation);
let scope = dirty_scopes
@@ -418,7 +359,6 @@ where
if overflowed {
*scope = DirtyUsageBucketScope::WholeBucket;
}
record_segment_invalidation_producer_identities_for_generation(&mut producer_identities, generation, producers);
dirty_buckets.len()
};
global_metrics().record_scanner_dirty_usage_pending(usize_to_u64_saturated(pending_buckets));
@@ -426,29 +366,15 @@ where
DIRTY_USAGE_BUCKET_NOTIFY.notify_one();
}
fn record_segment_invalidation_producer_identities_for_generation<I>(
identities: &mut DirtyUsageProducerIdentities,
generation: u64,
producers: I,
) where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
for producer in producers {
if producer.producer().is_some() {
identities
.entry(producer)
.and_modify(|state| state.last_generation = state.last_generation.max(generation))
.or_insert(DirtyUsageProducerIdentityState {
first_generation: generation,
last_generation: generation,
});
}
fn record_segment_invalidation_producer_identity(producer: crate::segment_invalidation::SegmentInvalidationProducerIdentity) {
if producer.producer().is_some() {
dirty_usage_producer_identities().insert(producer);
}
}
#[cfg(test)]
fn dirty_usage_producer_identities_for_tests() -> BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity> {
dirty_usage_producer_identities().keys().copied().collect()
dirty_usage_producer_identities().clone()
}
fn dirty_usage_top_level_entry(object: &str) -> Option<String> {
@@ -723,31 +649,6 @@ pub(super) fn dirty_usage_snapshot_status(snapshot: &DirtyUsageSnapshot) -> Dirt
}
}
pub(super) fn dirty_usage_producer_evidence(snapshot: &DirtyUsageSnapshot) -> DirtyUsageProducerEvidence {
let generation_window_bound = dirty_usage_snapshot_status(snapshot) == DirtyUsageSnapshotStatus::Current
&& snapshot.generation != 0
&& snapshot.generation != u64::MAX;
let identities = dirty_usage_producer_identities()
.iter()
.filter(|(_, state)| state.first_generation <= snapshot.generation)
.map(|(identity, _)| *identity)
.collect::<BTreeSet<_>>();
let producer_identity_coverage_complete =
generation_window_bound && crate::segment_invalidation::complete_segment_invalidation_producers(identities).is_ok();
DirtyUsageProducerEvidence {
producer_identity_coverage_complete,
// The current producer journal is still process-local. Keep the
// durable/restart gates closed until the mutation evidence is persisted
// and replayable across scanner restarts.
durable_producer_identity: false,
restart_gap_absent: false,
generation_window_bound,
generation_start: snapshot.generation,
generation_end: snapshot.generation,
}
}
#[cfg(test)]
pub(super) fn dirty_usage_bucket_count() -> usize {
dirty_usage_buckets().len()
-17
View File
@@ -172,8 +172,6 @@ impl ScannerIOCache for SetDisks {
bucket_failures,
pending_maintenance_work,
cache_cycle_floor,
cold_zero_walk_reuse_observed,
segment_invalidation_proof,
} = scan_plan;
let scan_plan_digest = scanner_bucket_work_digest(scan_plan_digest, scan_mode, requires_full_scan);
let bucket_work_digest = scanner_bucket_work_digest(bucket_coverage_digest, scan_mode, requires_full_scan);
@@ -221,9 +219,6 @@ impl ScannerIOCache for SetDisks {
},
current_bucket_incarnations.as_ref(),
);
let cold_zero_walk_reuse_candidate = scoped_scan.as_ref().is_some_and(|prepared| {
old_cache.info.next_cycle < want_cycle && !prepared.buckets.is_empty() && prepared.buckets.len() < all_buckets.len()
});
let mut scoped_cache = scoped_scan.map(|mut prepared| {
buckets = prepared.buckets;
prepared.cache.info.scan_coverage_digest = Some(bucket_coverage_digest);
@@ -231,8 +226,6 @@ impl ScannerIOCache for SetDisks {
});
if buckets.is_empty() {
let now = SystemTime::now();
let completed_segment_invalidation_proof =
scanner_completed_set_segment_invalidation_proof(&segment_invalidation_proof, cold_zero_walk_reuse_candidate);
let mut cache = match scoped_cache.take() {
Some(cache) => cache,
None => {
@@ -246,7 +239,6 @@ impl ScannerIOCache for SetDisks {
scan_plan_digest: Some(scan_plan_digest),
scan_coverage_digest: Some(bucket_coverage_digest),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
segment_invalidation_proof: completed_segment_invalidation_proof.clone(),
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
..Default::default()
},
@@ -262,7 +254,6 @@ impl ScannerIOCache for SetDisks {
cache.info.last_update = Some(now);
cache.info.snapshot_complete = true;
cache.info.scan_execution_digest = Some(execution_digest);
cache.info.segment_invalidation_proof = completed_segment_invalidation_proof;
cache.info.lkg_snapshot_complete = false;
cache.info.lkg_next_cycle = None;
cache.info.lkg_last_update = None;
@@ -544,7 +535,6 @@ impl ScannerIOCache for SetDisks {
lkg_last_update: old_cache.info.lkg_last_update,
lkg_leader_epoch: old_cache.info.lkg_leader_epoch,
lkg_scan_plan_digest: old_cache.info.lkg_scan_plan_digest,
segment_invalidation_proof: None,
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
..Default::default()
},
@@ -1465,23 +1455,17 @@ impl ScannerIOCache for SetDisks {
let completed_count = completed_bucket_count.load(Ordering::Relaxed);
if should_publish_completed_snapshot(completed_count, buckets.len(), budget.budget_elapsed(), ctx.is_cancelled()) {
let completed_segment_invalidation_proof =
scanner_completed_set_segment_invalidation_proof(&segment_invalidation_proof, cold_zero_walk_reuse_candidate);
let cache_snapshot = {
let mut cache = cache_mutex.lock().await;
cache.info.next_cycle = want_cycle;
cache.info.last_update.get_or_insert_with(SystemTime::now);
cache.info.snapshot_complete = true;
cache.info.scan_execution_digest = Some(execution_digest);
cache.info.segment_invalidation_proof = completed_segment_invalidation_proof;
cache.info.lkg_snapshot_complete = false;
cache.info.lkg_next_cycle = None;
cache.info.lkg_last_update = None;
cache.info.lkg_leader_epoch = None;
cache.info.lkg_scan_plan_digest = None;
if cold_zero_walk_reuse_candidate {
cold_zero_walk_reuse_observed.store(true, Ordering::Release);
}
cache.clone()
};
let _ = persist_and_publish_cache_snapshot(
@@ -1502,7 +1486,6 @@ impl ScannerIOCache for SetDisks {
incomplete_scope.info.tier_registry_generation = Some(tier_registry_generation);
incomplete_scope.info.source = Some(source);
incomplete_scope.info.snapshot_complete = false;
incomplete_scope.info.segment_invalidation_proof = None;
incomplete_scope.info.scan_plan_digest = Some(scan_plan_digest);
incomplete_scope.info.cache_key_format = DATA_USAGE_CACHE_KEY_FORMAT;
if let Err(e) = updates.send(incomplete_scope).await {
+1 -37
View File
@@ -122,7 +122,6 @@ where
let default_result = |scope: ScannerBucketScanScope| ScannerBucketScopeResolutionResult {
scope,
remote_dirty_usage_acknowledgements: Vec::new(),
distributed_segment_invalidation_evidence: None,
};
if resolution.requires_full_scan {
return default_result(ScannerBucketScanScope::default());
@@ -241,17 +240,12 @@ where
return remote_resolution;
}
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_baseline(
resolution.dirty_usage_snapshot,
distributed,
resolution.baseline_proof,
);
default_result(scoped_scan_scope_from_dirty_buckets(
resolution.requested_scope,
dirty_buckets,
(!distributed).then_some(resolution.dirty_usage_snapshot.scopes.as_ref()),
true,
segment_reuse_activation_preflight.scanner_segment_reuse_activated,
scanner_segment_reuse_activated(),
resolution.all_buckets,
resolution.baseline_proof,
))
@@ -414,9 +408,7 @@ where
)
.await;
let remote_dirty_usage_acknowledgements = scope_resolution.remote_dirty_usage_acknowledgements;
let distributed_segment_invalidation_evidence = scope_resolution.distributed_segment_invalidation_evidence;
let scan_scope = scope_resolution.scope;
let segment_invalidation_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot).segment_invalidation_proof();
#[cfg(test)]
if let Some(observer) = resolved_scope_observer {
let _ = observer.send(scan_scope.clone());
@@ -473,20 +465,12 @@ where
} else {
Vec::new()
};
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
dirty_usage_producer_evidence(&dirty_usage_snapshot),
distributed,
None,
false,
);
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
.with_publication_epoch(publication_epoch)
.with_activity_digest(activity_digest)
.with_observational_snapshot_published(observational_snapshot_published)
.with_remote_publication_lease_targets(remote_publication_lease_targets)
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
.with_publication_expectation(publication_expectation));
}
@@ -511,7 +495,6 @@ where
);
let bucket_failures = ScannerBucketFailureState::default();
let pending_maintenance_work = Arc::new(AtomicBool::new(false));
let cold_zero_walk_reuse_observed = Arc::new(AtomicBool::new(false));
record_set_scan_concurrency_limit(set_scan_limit);
debug!(
target: "rustfs::scanner::io",
@@ -605,8 +588,6 @@ where
bucket_failures: bucket_failures.clone(),
pending_maintenance_work: pending_maintenance_work.clone(),
cache_cycle_floor: cache_cycle_floor.clone(),
cold_zero_walk_reuse_observed: cold_zero_walk_reuse_observed.clone(),
segment_invalidation_proof: segment_invalidation_proof.clone(),
};
// Spawn task to run the scanner
let scanner_fut = tokio::spawn(async move {
@@ -710,21 +691,6 @@ where
scan_scope_matches && !partial_buckets.is_empty(),
scan_scope_matches && !namespace_not_found_buckets.is_empty(),
);
let cold_zero_walk_oracle = scanner_cycle_cold_zero_walk_oracle(
&scan_scope,
&all_buckets,
completed_all_sets,
scan_scope_matches,
bucket_scan_status,
cold_zero_walk_reuse_observed.load(Ordering::Acquire),
);
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources),
distributed,
distributed_segment_invalidation_evidence,
cold_zero_walk_oracle,
);
let pending_maintenance_work = pending_maintenance_work_for_cycle(&pending_maintenance_work, &results);
let observed_cycle_floor = cache_cycle_floor.load(Ordering::Acquire);
let required_cycle_floor = (observed_cycle_floor > want_cycle).then_some(observed_cycle_floor);
@@ -817,8 +783,6 @@ where
.with_observational_snapshot_published(observational_snapshot_published)
.with_remote_publication_lease_targets(remote_publication_lease_targets)
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
.with_distributed_segment_invalidation_evidence(distributed_segment_invalidation_evidence)
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
.with_failed_dirty_usage(!failed_buckets.is_empty())
.with_pending_maintenance_work(pending_maintenance_work)
.with_required_cycle_floor(required_cycle_floor)
@@ -13,7 +13,7 @@
// limitations under the License.
use super::*;
use crate::data_usage_define::{DataUsageSegmentInvalidationProof, UNKNOWN_TIER, UnknownTierStats, hash_path};
use crate::data_usage_define::{UNKNOWN_TIER, UnknownTierStats, hash_path};
use rustfs_data_usage::{ReplicationAllStats, ReplicationTargetUsage, TierAccountingProof};
const TEST_PLAN_DIGEST: DataUsageScanPlanDigest = DataUsageScanPlanDigest([7; 32]);
@@ -176,27 +176,6 @@ fn completed_data_usage_info_rejects_duplicate_bucket_inventory() {
assert!(completed_data_usage_info_for_test(&[set], &buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_carries_segment_invalidation_proof_to_set_state() {
let source = DataUsageCacheSource::new(0, 0);
let proof = DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 5,
generation_end: 8,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
};
let mut set = completed_root_cache("bucket", 2, 10, source);
set.info.segment_invalidation_proof = Some(proof.clone());
let (usage, _) =
completed_usage_for_scope(&[set], &HashSet::from([source]), &["bucket".to_string()], &[], true, false, false)
.expect("complete set should publish root usage");
assert_eq!(usage.usage_snapshot_set_states.len(), 1);
assert_eq!(usage.usage_snapshot_set_states[0].segment_invalidation_proof, Some(proof));
}
#[test]
fn completed_data_usage_info_rejects_extra_or_detached_bucket_data() {
let buckets = vec!["bucket".to_string()];
@@ -371,7 +350,6 @@ fn set_membership_add_remove_uses_generation_and_tombstone() {
scan_plan_digest: Some(TEST_PLAN_DIGEST.0),
complete: false,
tombstone: true,
segment_invalidation_proof: None,
};
let encoded = serde_json::to_vec(&state).expect("set state should serialize");
let decoded: DataUsageSnapshotSetState = serde_json::from_slice(&encoded).expect("set state should deserialize");
@@ -393,7 +371,6 @@ fn set_membership_add_remove_uses_generation_and_tombstone() {
scan_plan_digest: Some(TEST_PLAN_DIGEST.0),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
},
state,
],
+1 -424
View File
@@ -85,351 +85,6 @@ fn scanner_activity_preflight_defers_a_temporarily_offline_peer() {
}
}
#[test]
fn scanner_segment_reuse_activation_preflight_reports_release_gate_inputs() {
let preflight = scanner_segment_reuse_activation_preflight();
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert!(!scanner_segment_reuse_activated());
assert_eq!(preflight.proof_inputs, SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS);
assert_eq!(preflight.fail_closed_checks, SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS
);
}
#[test]
fn scanner_segment_reuse_activation_requires_every_preflight_proof() {
let complete_proof = ScannerSegmentReuseActivationProof {
production_activation: true,
producer_identity_coverage_complete: true,
durable_producer_identity: true,
restart_gap_absent: true,
generation_window_bound: true,
overflow_absent: true,
cold_zero_walk_oracle: true,
distributed_peer_invalidation: true,
};
let mut production_disabled = complete_proof;
production_disabled.production_activation = false;
let preflight = scanner_segment_reuse_activation_preflight_from_proof(production_disabled);
assert!(!preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
let preflight = scanner_segment_reuse_activation_preflight_from_proof(complete_proof);
assert!(preflight.production_activation);
assert!(preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
let mut missing_identity = complete_proof;
missing_identity.producer_identity_coverage_complete = false;
assert_segment_reuse_activation_blocked_by(missing_identity, "missing_producer_identity");
let mut non_durable_identity = complete_proof;
non_durable_identity.durable_producer_identity = false;
assert_segment_reuse_activation_blocked_by(non_durable_identity, "missing_producer_identity");
let mut restart_gap = complete_proof;
restart_gap.restart_gap_absent = false;
assert_segment_reuse_activation_blocked_by(restart_gap, "restart_gap");
let mut generation_gap = complete_proof;
generation_gap.generation_window_bound = false;
assert_segment_reuse_activation_blocked_by(generation_gap, "generation_gap");
let mut overflow = complete_proof;
overflow.overflow_absent = false;
assert_segment_reuse_activation_blocked_by(overflow, "overflow");
let mut missing_cold_oracle = complete_proof;
missing_cold_oracle.cold_zero_walk_oracle = false;
assert_segment_reuse_activation_blocked_by(missing_cold_oracle, "missing_cold_zero_walk_oracle");
let mut missing_distributed_invalidation = complete_proof;
missing_distributed_invalidation.distributed_peer_invalidation = false;
assert_segment_reuse_activation_blocked_by(missing_distributed_invalidation, "distributed_without_peer_invalidation");
}
#[test]
fn scanner_segment_reuse_activation_preflight_for_cycle_reports_cycle_inputs_without_activation() {
let dirty_usage_snapshot = DirtyUsageSnapshot {
buckets: Arc::new(DirtyUsageBuckets::from([("photos".to_string(), 7)])),
scopes: Arc::new(DirtyUsageBucketScopes::default()),
generation: 7,
covers_all_pending: true,
};
let distributed_evidence = DistributedSegmentInvalidationEvidence {
invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc,
distributed_ec_invalidation: true,
peer_count: 2,
dirty_peer_count: 1,
same_window_remote_proof: true,
all_peers_bound_to_generation_window: true,
};
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
complete_process_local_producer_evidence(),
true,
Some(distributed_evidence),
true,
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
vec!["missing_producer_identity", "restart_gap"]
);
}
#[test]
fn scanner_segment_reuse_activation_preflight_for_cycle_blocks_unbounded_inputs() {
let dirty_usage_snapshot = DirtyUsageSnapshot {
buckets: Arc::new(DirtyUsageBuckets::default()),
scopes: Arc::new(DirtyUsageBucketScopes::default()),
generation: u64::MAX,
covers_all_pending: false,
};
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
DirtyUsageProducerEvidence::default(),
true,
None,
false,
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS
);
}
#[test]
fn scanner_segment_reuse_activation_preflight_for_cycle_skips_distributed_blocker_for_local_scan() {
let dirty_usage_snapshot = DirtyUsageSnapshot {
buckets: Arc::new(DirtyUsageBuckets::from([("photos".to_string(), 7)])),
scopes: Arc::new(DirtyUsageBucketScopes::default()),
generation: 7,
covers_all_pending: true,
};
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
complete_process_local_producer_evidence(),
false,
None,
true,
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
vec!["missing_producer_identity", "restart_gap"]
);
}
#[test]
#[serial]
fn scanner_durable_segment_invalidation_evidence_requires_matching_complete_set_proofs() {
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket_from_producers("photos", SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION);
let dirty_usage_snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
let process_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot)
.segment_invalidation_proof()
.expect("complete process-local producer coverage should produce proof metadata");
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
let results = vec![
complete_set_cache_with_segment_proof(DataUsageCacheSource::new(0, 0), process_proof.clone()),
complete_set_cache_with_segment_proof(DataUsageCacheSource::new(0, 1), process_proof),
];
let durable_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources);
assert!(durable_evidence.producer_identity_coverage_complete);
assert!(durable_evidence.durable_producer_identity);
assert!(durable_evidence.restart_gap_absent);
let mut stale_epoch = results.clone();
stale_epoch[0]
.info
.segment_invalidation_proof
.as_mut()
.expect("proof fixture should exist")
.process_epoch = "stale-process".to_string();
let stale_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &stale_epoch, &expected_sources);
assert!(stale_evidence.producer_identity_coverage_complete);
assert!(!stale_evidence.durable_producer_identity);
assert!(!stale_evidence.restart_gap_absent);
record_dirty_usage_bucket("videos");
let changed_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources);
assert!(!changed_evidence.producer_identity_coverage_complete);
assert!(!changed_evidence.durable_producer_identity);
assert!(!changed_evidence.restart_gap_absent);
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn scanner_segment_reuse_activation_replays_cold_durable_baseline() {
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
clear_dirty_usage_buckets_for_tests();
for producer in SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION {
record_dirty_usage_object_from_producer("photos", "2026/object", producer);
}
let dirty_usage_snapshot =
snapshot_dirty_usage_buckets(&[bucket_info("photos"), bucket_info("archive")], dirty_usage_generation());
let mut segment_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot)
.segment_invalidation_proof()
.expect("complete process-local producer coverage should produce proof metadata");
segment_proof.cold_zero_walk_oracle = true;
let scan_plan_digest = DataUsageScanPlanDigest([6; 32]);
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
let baseline = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10)),
scanner_cycle: Some(7),
scanner_epoch: Some(11),
buckets_count: 2,
buckets_usage: HashMap::from([
("photos".to_string(), Default::default()),
("archive".to_string(), Default::default()),
]),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(true),
usage_snapshot_set_states: expected_sources
.iter()
.map(|source| DataUsageSnapshotSetState {
pool_index: u64::try_from(source.pool_index).expect("test pool index should fit"),
set_index: u64::try_from(source.set_index).expect("test set index should fit"),
scanner_cycle: Some(7),
scanner_epoch: Some(11),
scan_plan_digest: Some(scan_plan_digest.0),
complete: true,
tombstone: false,
segment_invalidation_proof: Some(segment_proof.clone()),
})
.collect(),
..Default::default()
};
let baseline = Bytes::from(serde_json::to_vec(&baseline).expect("baseline should encode"));
let preflight = scanner_segment_reuse_activation_preflight_for_baseline(
&dirty_usage_snapshot,
false,
ScannerCacheBaselineProof {
authoritative_data: Some(&baseline),
observed_candidate_data: None,
expected_sources: &expected_sources,
leader_epoch: 11,
want_cycle: 8,
scan_plan_digest,
},
);
assert!(preflight.production_activation);
assert!(preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
let mut missing_cold_baseline =
serde_json::from_slice::<DataUsageInfo>(&baseline).expect("baseline should decode for negative case");
missing_cold_baseline.usage_snapshot_set_states[0]
.segment_invalidation_proof
.as_mut()
.expect("proof should exist")
.cold_zero_walk_oracle = false;
let missing_cold_baseline = Bytes::from(serde_json::to_vec(&missing_cold_baseline).expect("negative baseline should encode"));
let preflight = scanner_segment_reuse_activation_preflight_for_baseline(
&dirty_usage_snapshot,
false,
ScannerCacheBaselineProof {
authoritative_data: Some(&missing_cold_baseline),
observed_candidate_data: None,
expected_sources: &expected_sources,
leader_epoch: 11,
want_cycle: 8,
scan_plan_digest,
},
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
vec!["missing_producer_identity", "restart_gap", "missing_cold_zero_walk_oracle"]
);
clear_dirty_usage_buckets_for_tests();
}
#[test]
fn scanner_cycle_result_returns_segment_reuse_activation_preflight() {
let proof = ScannerSegmentReuseActivationProof {
production_activation: true,
producer_identity_coverage_complete: true,
durable_producer_identity: true,
restart_gap_absent: true,
generation_window_bound: true,
overflow_absent: true,
cold_zero_walk_oracle: true,
distributed_peer_invalidation: true,
};
let preflight = scanner_segment_reuse_activation_preflight_from_proof(proof);
let result = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_segment_reuse_activation_preflight(preflight);
assert_eq!(result.segment_reuse_activation_preflight, preflight);
}
fn assert_segment_reuse_activation_blocked_by(proof: ScannerSegmentReuseActivationProof, blocker: &'static str) {
let preflight = scanner_segment_reuse_activation_preflight_from_proof(proof);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), vec![blocker]);
}
fn complete_process_local_producer_evidence() -> DirtyUsageProducerEvidence {
DirtyUsageProducerEvidence {
producer_identity_coverage_complete: true,
durable_producer_identity: false,
restart_gap_absent: false,
generation_window_bound: true,
generation_start: 7,
generation_end: 7,
}
}
fn complete_set_cache_with_segment_proof(
source: DataUsageCacheSource,
proof: crate::DataUsageSegmentInvalidationProof,
) -> DataUsageCache {
DataUsageCache {
info: DataUsageCacheInfo {
name: DATA_USAGE_ROOT.to_string(),
next_cycle: 7,
last_update: Some(SystemTime::UNIX_EPOCH),
leader_epoch: 11,
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(DataUsageScanPlanDigest([3; 32])),
segment_invalidation_proof: Some(proof),
..Default::default()
},
cache: HashMap::new(),
}
}
async fn setup_two_pool_scanner_store() -> (tempfile::TempDir, Arc<ECStore>) {
init_ecstore_config_for_scanner_tests();
let temp_dir = tempfile::tempdir().expect("multi-pool scanner test directory should be created");
@@ -1384,29 +1039,6 @@ fn dirty_usage_snapshot_detects_uncovered_generation() {
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn dirty_usage_producer_evidence_tracks_process_local_coverage_without_durable_restart_authority() {
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket_from_producers("photos", SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION);
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
let evidence = dirty_usage_producer_evidence(&snapshot);
assert!(evidence.generation_window_bound);
assert!(evidence.producer_identity_coverage_complete);
assert!(!evidence.durable_producer_identity);
assert!(!evidence.restart_gap_absent);
record_dirty_usage_bucket_from_producer("videos", SegmentInvalidationProducerIdentity::PutObject);
let stale_evidence = dirty_usage_producer_evidence(&snapshot);
assert!(!stale_evidence.generation_window_bound);
assert!(!stale_evidence.producer_identity_coverage_complete);
clear_dirty_usage_buckets_for_tests();
}
#[test]
fn generation_saturates_instead_of_wrapping() {
let generation = AtomicU64::new(u64::MAX - 1);
@@ -1714,13 +1346,6 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
let ctx = CancellationToken::new();
let empty_execution = DataUsageScanPlanDigest([5; 32]);
let segment_invalidation_proof = crate::DataUsageSegmentInvalidationProof {
process_epoch: scanner_activity_epoch().to_string(),
generation_start: 8,
generation_end: 8,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: false,
};
set.nsscanner_cache(
ctx.clone(),
ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default()),
@@ -1740,8 +1365,6 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
bucket_failures: ScannerBucketFailureState::default(),
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
cache_cycle_floor: Arc::new(AtomicU64::new(8)),
cold_zero_walk_reuse_observed: Arc::new(AtomicBool::new(false)),
segment_invalidation_proof: Some(segment_invalidation_proof.clone()),
},
tx,
8,
@@ -1751,7 +1374,6 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
.expect("empty set scope should replace its prior nonempty cache");
let empty = rx.try_recv().expect("empty set snapshot should be published");
assert_eq!(empty.info.scan_execution_digest, Some(empty_execution));
assert_eq!(empty.info.segment_invalidation_proof, Some(segment_invalidation_proof));
assert!(empty.info.snapshot_complete);
let root = empty.checked_flatten(DATA_USAGE_ROOT).expect("complete empty root");
assert_eq!((root.size, root.objects), (0, 0));
@@ -1779,7 +1401,6 @@ fn complete_usage_baseline(
scan_plan_digest: Some(scan_plan_digest.0),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
}],
..Default::default()
};
@@ -2160,18 +1781,6 @@ fn remote_dirty_usage_invalidates_local_prefix_hints_until_distributed_proof_exi
"peer dirty state is not a distributed segment invalidation proof"
);
assert_eq!(distributed.remote_dirty_usage_acknowledgements.len(), 1);
let evidence = distributed
.distributed_segment_invalidation_evidence
.expect("same-window peer snapshot and scoped ACK capability form distributed evidence");
assert_eq!(evidence.peer_count, 1);
assert_eq!(evidence.dirty_peer_count, 1);
assert_eq!(
evidence.invalidation_domain,
crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc
);
assert!(evidence.distributed_ec_invalidation);
assert!(evidence.same_window_remote_proof);
assert!(evidence.all_peers_bound_to_generation_window);
}
fn peer_dirty_usage_snapshot(
@@ -2218,7 +1827,7 @@ fn verified_remote_dirty_usage_buckets_merges_only_complete_current_snapshots()
ScannerPeerDirtyUsageExpectation {
instance_id: "instance-b".to_string(),
generation: 3,
pending: true,
pending: false,
},
),
]);
@@ -2265,36 +1874,10 @@ fn verified_remote_dirty_usage_buckets_merges_only_complete_current_snapshots()
},
},
],
peer_count: 2,
dirty_peer_count: 2,
})
);
}
#[test]
fn verified_remote_dirty_usage_rejects_peer_snapshot_that_contradicts_activity_pending_state() {
let expected_peers = HashMap::from([(
"node-a:9000".to_string(),
ScannerPeerDirtyUsageExpectation {
instance_id: "instance-a".to_string(),
generation: 7,
pending: false,
},
)]);
assert!(
verified_remote_dirty_usage(
&expected_peers,
vec![(
"node-a:9000".to_string(),
peer_dirty_usage_snapshot("instance-a", 7, true, &[("photos", 7)]),
)],
)
.is_none(),
"a clean activity window cannot authorize a dirty peer snapshot or scoped ACK"
);
}
#[test]
fn scanner_scoped_dirty_usage_ack_cost_threshold_is_single_protocol_batch() {
let acknowledgement = |entry_count: usize| crate::scanner::ScannerDirtyUsageAcknowledgement {
@@ -2368,7 +1951,6 @@ fn remote_dirty_usage_scope_resolution_falls_back_when_ack_batch_exceeds_thresho
result.remote_dirty_usage_acknowledgements.is_empty(),
"full-scan fallback must not send a scoped ACK that peers would reject or split"
);
assert!(result.distributed_segment_invalidation_evidence.is_none());
}
#[test]
@@ -2510,11 +2092,6 @@ async fn distributed_scoped_scan_falls_back_when_remote_scoped_ack_capability_is
assert_eq!(result.scope.selected_buckets.as_deref(), expected_buckets.as_ref());
assert_eq!(result.remote_dirty_usage_acknowledgements.len(), expected_ack_count);
assert_eq!(
result.distributed_segment_invalidation_evidence.is_some(),
capability,
"distributed evidence requires an authenticated scoped ACK capability probe"
);
}
}
@@ -84,14 +84,7 @@ async fn persist_baseline(store: &Arc<ECStore>, baseline: &DataUsageInfo) {
// Every invocation uses the production default scope. Once durable bucket
// incarnations are present, the expected walker set follows the resolved scope.
async fn run_entry(
store: &Arc<ECStore>,
cycle: u64,
selected: Option<&str>,
expect_walks: bool,
expect_activation: bool,
expect_prefix_scope: bool,
) -> DataUsageInfo {
async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, expect_walks: bool) -> DataUsageInfo {
let drives = drive_identities(store).await;
let inventory = store
.list_bucket_for_scanner(&BucketOptions::default())
@@ -145,19 +138,11 @@ async fn run_entry(
.expect("entry cycle should finish within the fixture deadline")
.expect("entry cycle should succeed");
assert_eq!(result.status, ScannerCycleStatus::Complete);
let activation_preflight = result.segment_reuse_activation_preflight;
let scope = observed.await.expect("production resolver should report its decision");
assert_eq!(
scope.selected_buckets.as_deref(),
selected.map(|name| HashSet::from([name.to_string()])).as_ref()
);
if let Some(selected) = selected {
assert_eq!(
scope.prefix_scope_for(selected).is_some(),
expect_prefix_scope,
"resolved prefix scope must match activation replay for cycle {cycle}"
);
}
let usage = receiver.recv().await.expect("one candidate should be delivered");
assert!(receiver.recv().await.is_none(), "there must be exactly one terminal candidate");
assert!(usage.usage_snapshot_complete);
@@ -189,22 +174,6 @@ async fn run_entry(
actual, expected_walks,
"each listed source/bucket must have exactly the expected real walks"
);
assert!(activation_preflight.production_activation);
assert_eq!(activation_preflight.scanner_segment_reuse_activated, expect_activation);
let activation_blockers = activation_preflight.fail_closed_blockers().collect::<Vec<_>>();
if expect_activation {
assert_eq!(activation_blockers, Vec::<&str>::new());
} else if selected.is_some() && expect_walks {
assert!(
!activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
"a complete scoped reuse cycle must carry the cold zero-walk oracle: cycle={cycle} selected={selected:?} blockers={activation_blockers:?}"
);
} else {
assert!(
activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
"unscoped or same-cycle cache reuse must not claim the cold zero-walk oracle: cycle={cycle} selected={selected:?} expect_walks={expect_walks} blockers={activation_blockers:?}"
);
}
assert_eq!(
read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
.await
@@ -220,78 +189,32 @@ async fn run_entry(
usage
}
fn record_segment_dirty_usage(bucket: &str) {
for producer in crate::segment_invalidation::SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION {
record_dirty_usage_object_from_producer(bucket, "hot-segment/object", producer);
}
}
// The scoped fallback fixture keeps two EC pools and several scan futures live
// at once. Run the async cases on a dedicated stack so Linux libtest defaults
// exercise the assertions instead of aborting before the oracle finishes.
fn run_scoped_entry_fallback_test<F, Fut>(thread_name: &'static str, test_fn: F)
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(thread_name.to_string())
.stack_size(32 * 1024 * 1024)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("scoped entry fallback runtime should build");
runtime.block_on(test_fn());
})
.expect("scoped entry fallback test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[test]
#[tokio::test]
#[serial]
fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks() {
run_scoped_entry_fallback_test(
"scanner-scoped-entry-planned-scope",
scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks_case,
);
}
async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks_case() {
async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks() {
let (_dir, store) = setup_two_pool_scanner_store().await;
clear_dirty_usage_buckets_for_tests();
let hot = format!("hot-{}", Uuid::new_v4().simple());
let cold = format!("cold-{}", Uuid::new_v4().simple());
create_bucket(&store, &hot).await;
create_bucket(&store, &cold).await;
record_segment_dirty_usage(&hot);
let baseline = run_entry(&store, 1, None, true, false, false).await;
record_dirty_usage_bucket(&hot);
let baseline = run_entry(&store, 1, None, true).await;
persist_baseline(&store, &baseline).await;
// Same-cycle Current remains a retry. The later cycle may skip the cold
// bucket only after the prior complete set cache has durable incarnations.
run_entry(&store, 1, Some(&hot), false, false, false).await;
let usage = run_entry(&store, 2, Some(&hot), true, true, false).await;
persist_baseline(&store, &usage).await;
let usage = run_entry(&store, 3, Some(&hot), true, true, true).await;
run_entry(&store, 1, Some(&hot), false).await;
let usage = run_entry(&store, 2, Some(&hot), true).await;
assert_eq!(usage.buckets_usage[&hot].objects_count, 1);
assert_eq!(usage.buckets_usage[&cold].objects_count, 1);
assert_eq!(usage.objects_total_count, 2);
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[tokio::test]
#[serial]
fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker() {
run_scoped_entry_fallback_test(
"scanner-scoped-entry-invalid-baseline",
scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_case,
);
}
async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_case() {
async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker() {
let (_dir, store) = setup_two_pool_scanner_store().await;
clear_dirty_usage_buckets_for_tests();
let hot = format!("hot-{}", Uuid::new_v4().simple());
@@ -300,7 +223,7 @@ async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_
create_bucket(&store, &cold).await;
record_dirty_usage_bucket(&hot);
// The first real scan is also the missing persisted-baseline case.
let baseline = run_entry(&store, 1, None, true, false, false).await;
let baseline = run_entry(&store, 1, None, true).await;
for (index, kind) in [
"malformed",
"unconverged",
@@ -333,43 +256,28 @@ async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_
crate::save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), bytes)
.await
.expect("negative baseline should persist");
let usage = run_entry(
&store,
u64::try_from(index).expect("fixture cycle index should fit") + 2,
None,
true,
false,
false,
)
.await;
let usage = run_entry(&store, u64::try_from(index).expect("fixture cycle index should fit") + 2, None, true).await;
assert_eq!(usage.objects_total_count, 2, "{kind}");
assert_eq!(usage.buckets_usage[&cold].objects_count, 1, "{kind}");
}
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[tokio::test]
#[serial]
fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
run_scoped_entry_fallback_test(
"scanner-scoped-entry-overflow-inventory",
scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case,
);
}
async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case() {
async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
let (_dir, store) = setup_two_pool_scanner_store().await;
clear_dirty_usage_buckets_for_tests();
let hot = format!("hot-{}", Uuid::new_v4().simple());
create_bucket(&store, &hot).await;
record_dirty_usage_bucket(&hot);
let baseline = run_entry(&store, 1, None, true, false, false).await;
let baseline = run_entry(&store, 1, None, true).await;
persist_baseline(&store, &baseline).await;
for index in 0..=crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES {
record_dirty_usage_bucket(&format!("overflow-{index}"));
}
assert!(dirty_usage_buckets_for_tests().len() > crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES);
let usage = run_entry(&store, 2, None, true, false, false).await;
let usage = run_entry(&store, 2, None, true).await;
assert_eq!(usage.objects_total_count, 1);
clear_dirty_usage_buckets_for_tests();
@@ -377,7 +285,7 @@ async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case() {
let new_bucket = format!("new-{}", Uuid::new_v4().simple());
create_bucket(&store, &new_bucket).await;
// Even a previously valid baseline cannot cover the changed inventory.
let usage = run_entry(&store, 3, None, true, false, false).await;
let usage = run_entry(&store, 3, None, true).await;
assert_eq!(usage.objects_total_count, 2);
assert_eq!(usage.buckets_usage[&new_bucket].objects_count, 1);
clear_dirty_usage_buckets_for_tests();
+2 -22
View File
@@ -57,9 +57,6 @@ pub enum SegmentInvalidationProducerIdentity {
DeleteObject,
DeleteMarker,
CompleteMultipartUpload,
AbortMultipartUpload,
ObjectMetadata,
BucketMetadata,
Replication,
TierTransition,
TierExpiration,
@@ -69,14 +66,11 @@ pub enum SegmentInvalidationProducerIdentity {
}
impl SegmentInvalidationProducerIdentity {
pub const REQUIRED_PRODUCTION: [Self; 11] = [
pub const REQUIRED_PRODUCTION: [Self; 8] = [
Self::PutObject,
Self::DeleteObject,
Self::DeleteMarker,
Self::CompleteMultipartUpload,
Self::AbortMultipartUpload,
Self::ObjectMetadata,
Self::BucketMetadata,
Self::Replication,
Self::TierTransition,
Self::TierExpiration,
@@ -88,9 +82,7 @@ impl SegmentInvalidationProducerIdentity {
Self::PutObject => Some(SegmentInvalidationProducer::Put),
Self::DeleteObject => Some(SegmentInvalidationProducer::Delete),
Self::DeleteMarker => Some(SegmentInvalidationProducer::DeleteMarker),
Self::CompleteMultipartUpload | Self::AbortMultipartUpload => Some(SegmentInvalidationProducer::Multipart),
Self::ObjectMetadata => Some(SegmentInvalidationProducer::Put),
Self::BucketMetadata => Some(SegmentInvalidationProducer::DirectoryObject),
Self::CompleteMultipartUpload => Some(SegmentInvalidationProducer::Multipart),
Self::Replication => Some(SegmentInvalidationProducer::Replication),
Self::TierTransition | Self::TierExpiration => Some(SegmentInvalidationProducer::Tier),
Self::DirectoryObject => Some(SegmentInvalidationProducer::DirectoryObject),
@@ -410,12 +402,8 @@ mod tests {
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
SegmentInvalidationProducerIdentity::ObjectMetadata,
SegmentInvalidationProducerIdentity::BucketMetadata,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::TierExpiration,
SegmentInvalidationProducerIdentity::DirectoryObject,
SegmentInvalidationProducerIdentity::Unknown,
]),
@@ -427,12 +415,8 @@ mod tests {
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
SegmentInvalidationProducerIdentity::ObjectMetadata,
SegmentInvalidationProducerIdentity::BucketMetadata,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::TierExpiration,
SegmentInvalidationProducerIdentity::DirectoryObject,
SegmentInvalidationProducerIdentity::TestFixture,
]),
@@ -443,7 +427,6 @@ mod tests {
SegmentInvalidationProducerIdentity::PutObject,
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::DirectoryObject,
@@ -456,9 +439,6 @@ mod tests {
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
SegmentInvalidationProducerIdentity::ObjectMetadata,
SegmentInvalidationProducerIdentity::BucketMetadata,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::DirectoryObject,
-49
View File
@@ -919,22 +919,6 @@ where
}
}
// The filename's item count is untrusted. Reject a payload that contains
// more items than advertised instead of returning success and allowing the
// caller to delete the entry with trailing events still in the file.
match deserializer.next() {
None => {}
Some(Ok(_)) => {
return Err(StoreError::Deserialization(format!(
"Batch for key {key} contains more than {} items",
key.item_count
)));
}
Some(Err(e)) => {
return Err(StoreError::Deserialization(format!("Failed to deserialize trailing batch item: {e}")));
}
}
if items.is_empty() && key.item_count > 0 {
return Err(StoreError::Deserialization("No items found".to_string()));
}
@@ -1397,39 +1381,6 @@ mod tests {
let _ = store.delete();
}
#[test]
fn get_multiple_errors_on_batch_with_trailing_items_instead_of_partial_success() {
let dir = temp_store_dir("trailing-batch-items");
let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
store.open().unwrap();
let items = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
let original_key = store.put_multiple(items).unwrap();
assert_eq!(original_key.item_count, 3);
// Keep the three-item payload but make its filename claim that it contains
// only two items, simulating a corrupt or otherwise untrusted queue key.
let original_path = store.file_path(&original_key);
let advertised_key = Key {
item_count: 2,
..original_key
};
let advertised_path = store.file_path(&advertised_key);
std::fs::rename(&original_path, &advertised_path).unwrap();
let err = store.get_multiple(&advertised_key).unwrap_err();
assert!(
matches!(err, StoreError::Deserialization(_)),
"expected Deserialization error, got {err:?}"
);
// Because get_multiple failed, the batch entry remains available for
// inspection or recovery instead of being silently discarded.
assert!(advertised_path.exists());
let _ = store.delete();
}
#[test]
fn concurrent_put_raw_respects_entry_limit() {
let dir = temp_store_dir("concurrent-limit");
-23
View File
@@ -1,23 +0,0 @@
# Documentation
Use the focused indexes rather than treating this directory as an unordered
collection:
- [Architecture knowledge base](architecture/README.md)
- [Testing references](testing/README.md)
## Operations
Operational runbooks live under [`operations/`](operations/). Replication
operators should start with:
| Runbook | Use it for |
|---|---|
| [Site replication operations](operations/site-replication-operations.md) | Health fields, pending operations, outage recovery, re-pair admission, IAM/SSE boundaries, and upgrades. |
| [Replication target check](operations/replication-check.md) | Validating an S3 destination and version fidelity before enabling replication. |
| [Replication object size limits](operations/replication-object-size-limits.md) | Multipart routing, large-object limits, and retry characteristics. |
| [Replication outbound transport](operations/replication-outbound-transport.md) | Integrity headers, generic target behavior, and transport knobs. |
Other runbooks remain grouped by filename in [`operations/`](operations/);
architecture pages link to the relevant runbook where a cross-boundary
procedure is required.
+1 -3
View File
@@ -60,8 +60,6 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
Operations runbooks are registered in the [documentation operations index](../README.md#operations), and testing references live in [../testing/README.md](../testing/README.md).
For replication operations, start with [site replication operations](../operations/site-replication-operations.md), [replication target check](../operations/replication-check.md), [replication object size limits](../operations/replication-object-size-limits.md), and [replication outbound transport](../operations/replication-outbound-transport.md).
Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).

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