mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 20:36:38 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 629e477da2 | |||
| 4fd3d7b643 | |||
| 79a8371479 | |||
| 69ae9ef671 | |||
| db709aee57 | |||
| 16c56fe0bd |
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=9f767b37ed8b1c82da62ea441462d75487785c8086e56f08fb6f6cd89c6e2e52
|
sha256-darwin=b4ae71aa894e5c7795ae3eb8116f1777a7601d0f5db3898be2e48faf3329bd9b
|
||||||
sha256-linux=fbdaf42b220958d4b1e8880e0f8b5a7992d38e21051bb60596dd4538424757d6
|
sha256-linux=433debd9d9defa832986269abdf0f1d131597b2d7a417ce930e17c1fd47d85ba
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
python3 ./scripts/check_test_wiring.py --self-test
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/s3-tests/test_report_compat.py
|
python3 ./scripts/s3-tests/test_report_compat.py
|
||||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||||
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||||
|
|||||||
@@ -14,10 +14,9 @@
|
|||||||
|
|
||||||
name: "Schedule Failure Issue"
|
name: "Schedule Failure Issue"
|
||||||
description: >-
|
description: >-
|
||||||
Open (or update) a tracking issue when a scheduled workflow run fails or
|
Open (or update) a tracking issue when a scheduled workflow run fails.
|
||||||
does not complete normally.
|
|
||||||
Dedupes by workflow name: if an open issue titled
|
Dedupes by workflow name: if an open issue titled
|
||||||
"[scheduled-failure] <workflow name>" already exists, the result is
|
"[scheduled-failure] <workflow name>" already exists, the failure is
|
||||||
appended as a comment; otherwise a new issue is created. This is the
|
appended as a comment; otherwise a new issue is created. This is the
|
||||||
single alerting mechanism for all scheduled pipelines (backlog#1149 ci-8).
|
single alerting mechanism for all scheduled pipelines (backlog#1149 ci-8).
|
||||||
|
|
||||||
@@ -39,30 +38,6 @@ inputs:
|
|||||||
Set to an empty string to skip labeling.
|
Set to an empty string to skip labeling.
|
||||||
required: false
|
required: false
|
||||||
default: "infrastructure"
|
default: "infrastructure"
|
||||||
source-run-id:
|
|
||||||
description: "Run ID to report. Defaults to the current workflow run."
|
|
||||||
required: false
|
|
||||||
default: ${{ github.run_id }}
|
|
||||||
source-run-attempt:
|
|
||||||
description: "Run attempt to report. Defaults to the current attempt."
|
|
||||||
required: false
|
|
||||||
default: ${{ github.run_attempt }}
|
|
||||||
source-event:
|
|
||||||
description: "Trigger event of the run being reported."
|
|
||||||
required: false
|
|
||||||
default: ${{ github.event_name }}
|
|
||||||
source-ref-name:
|
|
||||||
description: "Ref name of the run being reported."
|
|
||||||
required: false
|
|
||||||
default: ${{ github.ref_name }}
|
|
||||||
source-sha:
|
|
||||||
description: "Commit SHA of the run being reported."
|
|
||||||
required: false
|
|
||||||
default: ${{ github.sha }}
|
|
||||||
details-file:
|
|
||||||
description: "Optional Markdown file appended to the issue body."
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
@@ -73,22 +48,17 @@ runs:
|
|||||||
GH_TOKEN: ${{ inputs.github-token }}
|
GH_TOKEN: ${{ inputs.github-token }}
|
||||||
WORKFLOW_NAME: ${{ inputs.workflow-name }}
|
WORKFLOW_NAME: ${{ inputs.workflow-name }}
|
||||||
ISSUE_LABEL: ${{ inputs.label }}
|
ISSUE_LABEL: ${{ inputs.label }}
|
||||||
SOURCE_RUN_ID: ${{ inputs.source-run-id }}
|
|
||||||
SOURCE_RUN_ATTEMPT: ${{ inputs.source-run-attempt }}
|
|
||||||
SOURCE_EVENT: ${{ inputs.source-event }}
|
|
||||||
SOURCE_REF_NAME: ${{ inputs.source-ref-name }}
|
|
||||||
SOURCE_SHA: ${{ inputs.source-sha }}
|
|
||||||
DETAILS_FILE: ${{ inputs.details-file }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
title="[scheduled-failure] ${WORKFLOW_NAME}"
|
title="[scheduled-failure] ${WORKFLOW_NAME}"
|
||||||
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}"
|
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
|
||||||
# Inspect the reported run attempt. It can be the current in-workflow
|
# Failed job names for this run attempt. The alert job runs while the
|
||||||
# failure or a completed run observed by the external watchdog.
|
# run as a whole is still in progress, so inspect the jobs that have
|
||||||
|
# already completed with a non-success conclusion.
|
||||||
failed_jobs="$(gh api \
|
failed_jobs="$(gh api \
|
||||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/attempts/${SOURCE_RUN_ATTEMPT}/jobs" \
|
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" \
|
||||||
--paginate \
|
--paginate \
|
||||||
--jq '.jobs[]
|
--jq '.jobs[]
|
||||||
| select(.conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "cancelled")
|
| select(.conclusion == "failure" or .conclusion == "timed_out" or .conclusion == "cancelled")
|
||||||
@@ -97,26 +67,15 @@ runs:
|
|||||||
failed_jobs="- (failed job not recorded yet — see the run page)"
|
failed_jobs="- (failed job not recorded yet — see the run page)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
details=""
|
|
||||||
if [ -n "${DETAILS_FILE}" ]; then
|
|
||||||
if [ -f "${DETAILS_FILE}" ]; then
|
|
||||||
details="$(cat "${DETAILS_FILE}")"
|
|
||||||
else
|
|
||||||
details="Details file was not available: \`${DETAILS_FILE}\`"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
body="$(cat <<EOF
|
body="$(cat <<EOF
|
||||||
Run of **${WORKFLOW_NAME}** did not complete successfully.
|
Scheduled run of **${WORKFLOW_NAME}** failed.
|
||||||
|
|
||||||
- Run: ${run_url} (attempt ${SOURCE_RUN_ATTEMPT})
|
- Run: ${run_url} (attempt ${GITHUB_RUN_ATTEMPT})
|
||||||
- Event: \`${SOURCE_EVENT}\`
|
- Event: \`${GITHUB_EVENT_NAME}\`
|
||||||
- Ref: \`${SOURCE_REF_NAME}\` @ \`${SOURCE_SHA}\`
|
- Ref: \`${GITHUB_REF_NAME}\` @ \`${GITHUB_SHA}\`
|
||||||
|
|
||||||
Non-success jobs:
|
Failed jobs:
|
||||||
${failed_jobs}
|
${failed_jobs}
|
||||||
|
|
||||||
${details}
|
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
[
|
|
||||||
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
|
|
||||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
|
||||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
|
||||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
|
||||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
|
||||||
{ "workflow": ".github/workflows/minio-interop.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
|
||||||
{ "workflow": ".github/workflows/runner-hygiene.yml", "max_age_hours": 792 }
|
|
||||||
]
|
|
||||||
@@ -46,7 +46,7 @@ on:
|
|||||||
# advisory could sit unnoticed for seven days. The check list is unchanged —
|
# advisory could sit unnoticed for seven days. The check list is unchanged —
|
||||||
# splitting it into a light daily advisories-only run and a weekly full run
|
# splitting it into a light daily advisories-only run and a weekly full run
|
||||||
# would create runs where sources/bans/licenses go unverified.
|
# would create runs where sources/bans/licenses go unverified.
|
||||||
- cron: '23 3 * * *' # Daily 03:23 UTC
|
- cron: '0 3 * * *' # Daily 03:00 UTC (staggered after the midnight ci/build crons)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ on:
|
|||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
- "flake.lock"
|
- "flake.lock"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
|
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
build_docker:
|
build_docker:
|
||||||
@@ -1032,23 +1032,3 @@ jobs:
|
|||||||
|
|
||||||
echo "🎉 Released $TAG successfully!"
|
echo "🎉 Released $TAG successfully!"
|
||||||
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
||||||
|
|
||||||
alert-on-failure:
|
|
||||||
name: Alert on scheduled failure
|
|
||||||
needs: [build-check, prepare-platform-matrix, build-rustfs, build-summary]
|
|
||||||
if: >-
|
|
||||||
always() && github.event_name == 'schedule' &&
|
|
||||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update failure-tracking issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -94,9 +94,6 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Swatinem/rust-cache hashes every RUST* variable. Keep this aligned with
|
|
||||||
# ci.yml or the writer and readers use disjoint cache keys.
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||||
@@ -104,7 +101,7 @@ jobs:
|
|||||||
warm-ci-dev:
|
warm-ci-dev:
|
||||||
name: Warm ci-dev
|
name: Warm ci-dev
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 120
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -194,7 +191,7 @@ jobs:
|
|||||||
warm-ci-feat-rio:
|
warm-ci-feat-rio:
|
||||||
name: Warm ci-feat-rio
|
name: Warm ci-feat-rio
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 120
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -222,7 +219,7 @@ jobs:
|
|||||||
warm-ci-feat-proto:
|
warm-ci-feat-proto:
|
||||||
name: Warm ci-feat-proto
|
name: Warm ci-feat-proto
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 120
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -126,10 +126,7 @@ jobs:
|
|||||||
run: ./scripts/check_embedded_secrets.sh
|
run: ./scripts/check_embedded_secrets.sh
|
||||||
|
|
||||||
- name: Check test wiring
|
- name: Check test wiring
|
||||||
run: |
|
run: python3 ./scripts/check_test_wiring.py
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ on:
|
|||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
|
- cron: "0 0 * * 0" # Weekly on Sunday at midnight UTC
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -161,10 +161,7 @@ jobs:
|
|||||||
run: ./scripts/check_embedded_secrets.sh
|
run: ./scripts/check_embedded_secrets.sh
|
||||||
|
|
||||||
- name: Check test wiring
|
- name: Check test wiring
|
||||||
run: |
|
run: python3 ./scripts/check_test_wiring.py
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
@@ -1035,37 +1032,3 @@ jobs:
|
|||||||
path: artifacts/s3tests-single/**
|
path: artifacts/s3tests-single/**
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
alert-on-failure:
|
|
||||||
name: Alert on scheduled failure
|
|
||||||
needs:
|
|
||||||
- typos
|
|
||||||
- quick-checks
|
|
||||||
- test-and-lint
|
|
||||||
- test-ilm-integration-serial
|
|
||||||
- test-and-lint-rio-v2
|
|
||||||
- test-and-lint-protocols
|
|
||||||
- build-rustfs-debug-binary
|
|
||||||
- build-rustfs-debug-binary-rio-v2
|
|
||||||
- uring-integration
|
|
||||||
- e2e-tests
|
|
||||||
- e2e-full
|
|
||||||
- e2e-tests-rio-v2
|
|
||||||
- s3-implemented-tests
|
|
||||||
- s3-lifecycle-behavior-tests
|
|
||||||
if: >-
|
|
||||||
always() && github.event_name == 'schedule' &&
|
|
||||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update failure-tracking issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ on:
|
|||||||
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
||||||
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
||||||
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
|
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
|
||||||
- cron: "43 7 * * 0"
|
- cron: "0 7 * * 0"
|
||||||
|
|
||||||
# Only alert-on-failure needs more than read access; it declares its own
|
# Only alert-on-failure needs more than read access; it declares its own
|
||||||
# job-level `issues: write`.
|
# job-level `issues: write`.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
|
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
|
||||||
# stale (01:30) and performance-ab (06:00).
|
# stale (01:30) and performance-ab (06:00).
|
||||||
- cron: "29 4 * * *"
|
- cron: "0 4 * * *"
|
||||||
|
|
||||||
# Only alert-on-failure needs more than read access; it declares its own
|
# Only alert-on-failure needs more than read access; it declares its own
|
||||||
# job-level `issues: write`.
|
# job-level `issues: write`.
|
||||||
@@ -196,9 +196,6 @@ jobs:
|
|||||||
cache-save-if: 'false'
|
cache-save-if: 'false'
|
||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Verify protocol socket oracle
|
|
||||||
run: ss -tn state CLOSE-WAIT >/dev/null
|
|
||||||
|
|
||||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||||
- name: Verify protocol e2e membership
|
- name: Verify protocol e2e membership
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -90,14 +90,10 @@ on:
|
|||||||
description: "Optional pytest -m expression"
|
description: "Optional pytest -m expression"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
testexpr:
|
|
||||||
description: "Optional pytest -k expression"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
schedule:
|
schedule:
|
||||||
# Weekly full sweep (Sunday 02:00 UTC): full suite, run against BOTH the
|
# Weekly full sweep (Sunday 02:00 UTC): full suite, run against BOTH the
|
||||||
# single-node and the 4-node distributed topologies (matrix below).
|
# single-node and the 4-node distributed topologies (matrix below).
|
||||||
- cron: "19 2 * * 0"
|
- cron: "0 2 * * 0"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# main user
|
# main user
|
||||||
@@ -120,7 +116,6 @@ env:
|
|||||||
XDIST: ${{ github.event.inputs.xdist || '4' }}
|
XDIST: ${{ github.event.inputs.xdist || '4' }}
|
||||||
MAXFAIL: ${{ github.event.inputs.maxfail || '0' }}
|
MAXFAIL: ${{ github.event.inputs.maxfail || '0' }}
|
||||||
MARKEXPR: ${{ github.event.inputs.markexpr || '' }}
|
MARKEXPR: ${{ github.event.inputs.markexpr || '' }}
|
||||||
TESTEXPR: ${{ github.event.inputs.testexpr || '' }}
|
|
||||||
S3_SHARD_COUNT: ${{ github.event_name == 'schedule' && '4' || github.event.inputs.shard-count || '1' }}
|
S3_SHARD_COUNT: ${{ github.event_name == 'schedule' && '4' || github.event.inputs.shard-count || '1' }}
|
||||||
TEST_TIMEOUT: "300"
|
TEST_TIMEOUT: "300"
|
||||||
|
|
||||||
@@ -274,20 +269,14 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat > haproxy.cfg <<'EOF'
|
cat > haproxy.cfg <<'EOF'
|
||||||
global
|
|
||||||
log stdout format raw local0 info
|
|
||||||
|
|
||||||
defaults
|
defaults
|
||||||
mode http
|
mode http
|
||||||
log global
|
|
||||||
log-format '%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %tsc %HM %HP'
|
|
||||||
timeout connect 5s
|
timeout connect 5s
|
||||||
timeout client 30s
|
timeout client 30s
|
||||||
timeout server 30s
|
timeout server 30s
|
||||||
|
|
||||||
frontend fe_s3
|
frontend fe_s3
|
||||||
bind *:9000
|
bind *:9000
|
||||||
option http-buffer-request
|
|
||||||
default_backend be_s3
|
default_backend be_s3
|
||||||
|
|
||||||
backend be_s3
|
backend be_s3
|
||||||
@@ -325,7 +314,6 @@ jobs:
|
|||||||
XDIST="${XDIST}" \
|
XDIST="${XDIST}" \
|
||||||
MAXFAIL="${MAXFAIL}" \
|
MAXFAIL="${MAXFAIL}" \
|
||||||
MARKEXPR="${MARKEXPR}" \
|
MARKEXPR="${MARKEXPR}" \
|
||||||
TESTEXPR="${TESTEXPR}" \
|
|
||||||
./scripts/s3-tests/run.sh
|
./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Publish compatibility report
|
- name: Publish compatibility report
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ on:
|
|||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- ".github/workflows/fuzz.yml"
|
- ".github/workflows/fuzz.yml"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "17 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
profile:
|
profile:
|
||||||
|
|||||||
@@ -121,21 +121,3 @@ jobs:
|
|||||||
cargo nextest run --run-ignored ignored-only --no-tests=fail \
|
cargo nextest run --run-ignored ignored-only --no-tests=fail \
|
||||||
-p "$INTEROP_PACKAGE" --features "$INTEROP_FEATURES" \
|
-p "$INTEROP_PACKAGE" --features "$INTEROP_FEATURES" \
|
||||||
-E "$INTEROP_FILTER"
|
-E "$INTEROP_FILTER"
|
||||||
|
|
||||||
alert-on-failure:
|
|
||||||
name: Alert on scheduled failure
|
|
||||||
needs: [minio-interop]
|
|
||||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update failure-tracking issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
||||||
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
|
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
|
||||||
|
|
||||||
|
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||||
|
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||||
|
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
||||||
|
# reading this file, which has already misled at least one audit — hence this
|
||||||
|
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||||
|
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||||
|
#
|
||||||
name: mint
|
name: mint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -63,13 +70,13 @@ on:
|
|||||||
- core
|
- core
|
||||||
- full
|
- full
|
||||||
mint-image:
|
mint-image:
|
||||||
description: "Mint image reference (empty = pinned default)"
|
description: "Mint image reference"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: "minio/mint:edge"
|
||||||
schedule:
|
schedule:
|
||||||
# Weekly, after the Sunday s3-tests full sweep (starts 02:00 UTC, up to
|
# Weekly, after the Sunday s3-tests full sweep (starts 02:00 UTC, up to
|
||||||
# 3h) has finished, so the two never contend for the same runner pool.
|
# 3h) has finished, so the two never contend for the same runner pool.
|
||||||
- cron: "41 6 * * 0"
|
- cron: "0 6 * * 0"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
S3_ACCESS_KEY: rustfsadmin-ci
|
S3_ACCESS_KEY: rustfsadmin-ci
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ name: Nightly GNU Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "7 0 * * *"
|
- cron: "0 0 * * *"
|
||||||
timezone: "Asia/Shanghai"
|
timezone: "Asia/Shanghai"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -194,23 +194,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run HA leader failover live checks (three-node Raft cluster in Docker)
|
- name: Run HA leader failover live checks (three-node Raft cluster in Docker)
|
||||||
run: bash scripts/test/vault_ha_kms_live.sh
|
run: bash scripts/test/vault_ha_kms_live.sh
|
||||||
|
|
||||||
alert-on-failure:
|
|
||||||
name: Alert on scheduled failure
|
|
||||||
needs: [build, kms-vault-lane, kms-vault-ha-failover]
|
|
||||||
if: >-
|
|
||||||
always() && github.event_name == 'schedule' &&
|
|
||||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update failure-tracking issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -22,15 +22,22 @@
|
|||||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
||||||
# blocked (rustfs/backlog#935 correction 1).
|
# blocked (rustfs/backlog#935 correction 1).
|
||||||
|
|
||||||
|
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||||
|
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||||
|
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
||||||
|
# reading this file, which has already misled at least one audit — hence this
|
||||||
|
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
||||||
|
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
||||||
|
#
|
||||||
name: Performance A/B
|
name: Performance A/B
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
|
- cron: "0 6 * * *" # 06:00 UTC nightly, against main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
duration:
|
duration:
|
||||||
description: "warp duration per round"
|
description: "warp duration per round (short by default to fit the double-build budget)"
|
||||||
required: false
|
required: false
|
||||||
default: "12s"
|
default: "12s"
|
||||||
type: string
|
type: string
|
||||||
@@ -39,8 +46,12 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
push:
|
||||||
|
# Every main commit pre-builds and caches its release binary (perf-3) so the
|
||||||
|
# nightly A/B restores a ready baseline instead of paying the double build.
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -48,19 +59,83 @@ env:
|
|||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
warp-ab:
|
# perf-3: on every push to main, build the release binary once and cache it
|
||||||
name: Warp A/B budget gate
|
# keyed by commit SHA (rustfs-baseline-<sha>). The warp-ab measurements
|
||||||
|
# restore this instead of paying the ~32min-per-side source
|
||||||
|
# build. That double build is what pushed the expanded 24-cell nightly past its
|
||||||
|
# ceiling — 2026-07-11..07-14 all cancelled on the 120min timeout. Incremental
|
||||||
|
# builds off the shared cargo cache keep each push cheap, and building on the
|
||||||
|
# same sm-standard-2 runner the A/B measures on guarantees the cached binary is
|
||||||
|
# ABI-identical. Do NOT source this from build.yml's per-merge artifact: those
|
||||||
|
# are cancelled ~7/8 of the time and are not a reliable baseline.
|
||||||
|
build-baseline-cache:
|
||||||
|
name: Build + cache baseline binary
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: sm-standard-2
|
runs-on: sm-standard-2
|
||||||
# A normal nightly restores the last successful binary and builds only the
|
# Latest-wins: consumers only ever restore the binary for the *current*
|
||||||
# candidate; daily access keeps that cache warm. A cache miss may build both
|
# origin/main tip, so when pushes land faster than the ~65min build, a
|
||||||
# and needs room for the A/B run plus artifact and cache publication.
|
# superseded build's output is dead weight — cancel it instead of stacking
|
||||||
timeout-minutes: 180
|
# hour-long jobs on the shared runner pool. A skipped intermediate SHA at
|
||||||
|
# most costs one same-commit self-heal in the A/B job.
|
||||||
|
concurrency:
|
||||||
|
group: perf-baseline-build-main
|
||||||
|
cancel-in-progress: true
|
||||||
|
# #4806 put thin LTO + codegen-units=1 on [profile.release], pushing a
|
||||||
|
# single release build past 60min on this runner — every cache build on
|
||||||
|
# 2026-07-15 died on the old 60min ceiling ("exceeded the maximum execution
|
||||||
|
# time of 1h0m0s") and the cache never populated. The measured binary must
|
||||||
|
# keep the production profile, so the budget absorbs the build instead.
|
||||||
|
timeout-minutes: 100
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0 # baseline may be an earlier successful scheduled head
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
- name: Build release rustfs
|
||||||
|
run: cargo build --release --bin rustfs
|
||||||
|
|
||||||
|
- name: Stage binary for cache
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p baseline-bin
|
||||||
|
cp target/release/rustfs baseline-bin/rustfs
|
||||||
|
|
||||||
|
- name: Cache baseline binary by SHA
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||||
|
with:
|
||||||
|
path: baseline-bin/rustfs
|
||||||
|
key: rustfs-baseline-${{ github.sha }}
|
||||||
|
|
||||||
|
warp-ab:
|
||||||
|
name: Warp A/B budget gate
|
||||||
|
# Always run on schedule / manual dispatch. Never on push — that event only
|
||||||
|
# feeds build-baseline-cache above.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: sm-standard-2
|
||||||
|
# With perf-3's cached baseline binary the common (cache-hit) nightly is
|
||||||
|
# measurement-only and finishes well under 50min. This ceiling stays
|
||||||
|
# generous only to absorb the same-commit cache-miss self-heal (~65min
|
||||||
|
# single build with the post-#4806 LTO profile + measurement). A timeout
|
||||||
|
# surfaces via the alert-on-failure job (it fires on cancelled/timed-out,
|
||||||
|
# not just failure). perf-6 recalibrates the budget once the noise study
|
||||||
|
# lands.
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0 # baseline is built from origin/main
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -88,55 +163,24 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# A failed regression run must keep comparing against the last known-good
|
# perf-3: resolve the commits so the cache can be keyed by SHA. The
|
||||||
# scheduled head. Otherwise the next nightly would absorb the regression
|
# baseline is origin/main; the candidate is the checked-out ref. On the
|
||||||
# into its baseline and turn green without a fix.
|
# nightly (checkout == main) they are the same commit, so one cached binary
|
||||||
- name: Find last successful scheduled baseline
|
# serves both phases and the run does zero source builds.
|
||||||
id: scheduled_baseline
|
|
||||||
if: github.event_name == 'schedule'
|
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
|
||||||
with:
|
|
||||||
result-encoding: string
|
|
||||||
script: |
|
|
||||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
workflow_id: "performance-ab.yml",
|
|
||||||
event: "schedule",
|
|
||||||
status: "success",
|
|
||||||
per_page: 1,
|
|
||||||
});
|
|
||||||
return data.workflow_runs[0]?.head_sha ?? "";
|
|
||||||
|
|
||||||
# Manual runs compare a selected ref with current main. Scheduled runs
|
|
||||||
# compare current main with the last successful scheduled head. With no
|
|
||||||
# history, the first run measures the candidate against itself and seeds
|
|
||||||
# that head only if the complete rig succeeds.
|
|
||||||
- name: Resolve baseline / candidate commits
|
- name: Resolve baseline / candidate commits
|
||||||
id: commits
|
id: commits
|
||||||
env:
|
|
||||||
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
candidate_sha="$(git rev-parse HEAD)"
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
|
||||||
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
|
|
||||||
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
|
|
||||||
echo "::error::scheduled baseline $baseline_sha is not an ancestor of candidate $candidate_sha" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
baseline_sha="$(git rev-parse origin/main)"
|
baseline_sha="$(git rev-parse origin/main)"
|
||||||
fi
|
candidate_sha="$(git rev-parse HEAD)"
|
||||||
git cat-file -e "${baseline_sha}^{commit}"
|
|
||||||
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
|
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
|
||||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
||||||
echo "baseline commit: $baseline_sha"
|
echo "baseline commit: $baseline_sha"
|
||||||
echo "candidate commit: $candidate_sha"
|
echo "candidate commit: $candidate_sha"
|
||||||
|
|
||||||
# Exact-key restore of the candidate binary saved by its successful
|
# Exact-key restore of the baseline binary built by build-baseline-cache
|
||||||
# scheduled run. A miss leaves cache-hit unset and falls back to a source
|
# when origin/main last landed. A miss (binary evicted or not built yet)
|
||||||
# build of that known-good head.
|
# leaves cache-hit unset and the rig falls back to a source build.
|
||||||
- name: Restore cached baseline binary
|
- name: Restore cached baseline binary
|
||||||
id: baseline_cache
|
id: baseline_cache
|
||||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||||
@@ -226,11 +270,11 @@ jobs:
|
|||||||
elif [[ "$selfheal_built" == "true" ]]; then
|
elif [[ "$selfheal_built" == "true" ]]; then
|
||||||
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
||||||
else
|
else
|
||||||
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
|
base_src="isolated origin/main source build (saved as rustfs-baseline-$baseline_sha)"
|
||||||
fi
|
fi
|
||||||
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
||||||
# No commits landed since the last successful baseline, so reuse
|
# Nightly on main: the candidate is the same commit as the baseline,
|
||||||
# the one binary for both phases and measure only rig drift.
|
# so reuse the one binary for both phases and skip all builds.
|
||||||
args+=(--candidate-bin "$base_bin")
|
args+=(--candidate-bin "$base_bin")
|
||||||
cand_src="same binary as baseline (same commit)"
|
cand_src="same binary as baseline (same commit)"
|
||||||
elif [[ "$candidate_built" == "true" ]]; then
|
elif [[ "$candidate_built" == "true" ]]; then
|
||||||
@@ -318,23 +362,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Stage successful candidate baseline
|
|
||||||
if: >-
|
|
||||||
steps.ab.outputs.status == '0' &&
|
|
||||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cp candidate-bin/rustfs baseline-bin/rustfs
|
|
||||||
|
|
||||||
- name: Cache successful candidate baseline
|
|
||||||
if: >-
|
|
||||||
steps.ab.outputs.status == '0' &&
|
|
||||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
|
||||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
|
||||||
with:
|
|
||||||
path: baseline-bin/rustfs
|
|
||||||
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
|
|
||||||
|
|
||||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
# Scheduled failure alerting is handled by the alert-on-failure job below
|
||||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ name: Runner Hygiene
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "37 6 1 * *" # Monthly, 1st at 06:37 UTC
|
- cron: "0 6 1 * *" # Monthly, 1st at 06:00 UTC (after the daily audit cron)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -1,57 +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.
|
|
||||||
|
|
||||||
name: Scheduled Validation Freshness
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "47 23 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: scheduled-validation-freshness
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-freshness:
|
|
||||||
name: Check scheduled validation freshness
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Check latest scheduled runs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
python3 scripts/check_scheduled_validation_freshness.py \
|
|
||||||
--report "${RUNNER_TEMP}/scheduled-validation-freshness.md"
|
|
||||||
status=$?
|
|
||||||
cat "${RUNNER_TEMP}/scheduled-validation-freshness.md" >> "${GITHUB_STEP_SUMMARY}"
|
|
||||||
exit "${status}"
|
|
||||||
- name: Open or update freshness issue
|
|
||||||
if: failure()
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
details-file: ${{ runner.temp }}/scheduled-validation-freshness.md
|
|
||||||
@@ -1,63 +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.
|
|
||||||
|
|
||||||
name: Scheduled Validation Watchdog
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- "Security Audit"
|
|
||||||
- "Build and Release"
|
|
||||||
- "Continuous Integration"
|
|
||||||
- "coverage"
|
|
||||||
- "e2e-nightly"
|
|
||||||
- "e2e-s3tests"
|
|
||||||
- "Fuzz"
|
|
||||||
- "mint"
|
|
||||||
- "minio-interop"
|
|
||||||
- "Nightly GNU Build"
|
|
||||||
- "Performance A/B"
|
|
||||||
- "Runner Hygiene"
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
alert-on-incomplete-run:
|
|
||||||
name: Alert on incomplete scheduled run
|
|
||||||
if: >-
|
|
||||||
github.event.workflow_run.event == 'schedule' &&
|
|
||||||
github.event.workflow_run.conclusion != 'success' &&
|
|
||||||
github.event.workflow_run.conclusion != 'failure'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update incomplete-run issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
workflow-name: ${{ github.event.workflow_run.name }}
|
|
||||||
source-run-id: ${{ github.event.workflow_run.id }}
|
|
||||||
source-run-attempt: ${{ github.event.workflow_run.run_attempt }}
|
|
||||||
source-event: ${{ github.event.workflow_run.event }}
|
|
||||||
source-ref-name: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
source-sha: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
Generated
-1
@@ -12688,7 +12688,6 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"sha1_smol",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -901,10 +901,6 @@ pub struct Metrics {
|
|||||||
scanner_cycle_max_duration_millis: AtomicU64,
|
scanner_cycle_max_duration_millis: AtomicU64,
|
||||||
scanner_cycle_max_objects: AtomicU64,
|
scanner_cycle_max_objects: AtomicU64,
|
||||||
scanner_cycle_max_directories: AtomicU64,
|
scanner_cycle_max_directories: AtomicU64,
|
||||||
scanner_cycle_timeout_total: AtomicU64,
|
|
||||||
scanner_cycle_recovery_required_total: AtomicU64,
|
|
||||||
scanner_cycle_last_progress_age_seconds: AtomicU64,
|
|
||||||
scanner_leader_lease_without_progress: AtomicBool,
|
|
||||||
scanner_bitrot_cycle_enabled: AtomicBool,
|
scanner_bitrot_cycle_enabled: AtomicBool,
|
||||||
scanner_bitrot_cycle_millis: AtomicU64,
|
scanner_bitrot_cycle_millis: AtomicU64,
|
||||||
scanner_checkpoint: Mutex<Option<ScannerCheckpointReport>>,
|
scanner_checkpoint: Mutex<Option<ScannerCheckpointReport>>,
|
||||||
@@ -1374,14 +1370,6 @@ pub struct ScannerMetricsReport {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cycle_max_directories: u64,
|
pub cycle_max_directories: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cycle_timeout_total: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cycle_recovery_required_total: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cycle_last_progress_age: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub leader_lease_without_progress: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub bitrot_cycle_enabled: bool,
|
pub bitrot_cycle_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bitrot_cycle_seconds: f64,
|
pub bitrot_cycle_seconds: f64,
|
||||||
@@ -1442,9 +1430,6 @@ const OTEL_SCANNER_BUCKETS_SCANNED: &str = "rustfs_scanner_buckets_scanned_total
|
|||||||
const OTEL_SCANNER_CYCLES: &str = "rustfs_scanner_cycles_total";
|
const OTEL_SCANNER_CYCLES: &str = "rustfs_scanner_cycles_total";
|
||||||
const OTEL_SCANNER_CYCLE_DURATION_SECONDS: &str = "rustfs_scanner_cycle_duration_seconds";
|
const OTEL_SCANNER_CYCLE_DURATION_SECONDS: &str = "rustfs_scanner_cycle_duration_seconds";
|
||||||
const OTEL_SCANNER_BUCKET_DRIVE_DURATION_SECONDS: &str = "rustfs_scanner_bucket_drive_duration_seconds";
|
const OTEL_SCANNER_BUCKET_DRIVE_DURATION_SECONDS: &str = "rustfs_scanner_bucket_drive_duration_seconds";
|
||||||
const OTEL_SCANNER_CYCLE_TIMEOUT_TOTAL: &str = "rustfs_scanner_cycle_timeout_total";
|
|
||||||
const OTEL_SCANNER_CYCLE_LAST_PROGRESS_AGE: &str = "rustfs_scanner_cycle_last_progress_age";
|
|
||||||
const OTEL_SCANNER_LEADER_LEASE_WITHOUT_PROGRESS: &str = "rustfs_scanner_leader_lease_without_progress";
|
|
||||||
|
|
||||||
fn scan_cycle_result_label(result: u8) -> &'static str {
|
fn scan_cycle_result_label(result: u8) -> &'static str {
|
||||||
match result {
|
match result {
|
||||||
@@ -1928,10 +1913,6 @@ impl Metrics {
|
|||||||
scanner_cycle_max_duration_millis: AtomicU64::new(0),
|
scanner_cycle_max_duration_millis: AtomicU64::new(0),
|
||||||
scanner_cycle_max_objects: AtomicU64::new(0),
|
scanner_cycle_max_objects: AtomicU64::new(0),
|
||||||
scanner_cycle_max_directories: AtomicU64::new(0),
|
scanner_cycle_max_directories: AtomicU64::new(0),
|
||||||
scanner_cycle_timeout_total: AtomicU64::new(0),
|
|
||||||
scanner_cycle_recovery_required_total: AtomicU64::new(0),
|
|
||||||
scanner_cycle_last_progress_age_seconds: AtomicU64::new(0),
|
|
||||||
scanner_leader_lease_without_progress: AtomicBool::new(false),
|
|
||||||
scanner_bitrot_cycle_enabled: AtomicBool::new(false),
|
scanner_bitrot_cycle_enabled: AtomicBool::new(false),
|
||||||
scanner_bitrot_cycle_millis: AtomicU64::new(0),
|
scanner_bitrot_cycle_millis: AtomicU64::new(0),
|
||||||
scanner_checkpoint: Mutex::new(None),
|
scanner_checkpoint: Mutex::new(None),
|
||||||
@@ -2431,29 +2412,12 @@ impl Metrics {
|
|||||||
.store(cycle_max_objects.unwrap_or_default(), Ordering::Relaxed);
|
.store(cycle_max_objects.unwrap_or_default(), Ordering::Relaxed);
|
||||||
self.scanner_cycle_max_directories
|
self.scanner_cycle_max_directories
|
||||||
.store(cycle_max_directories.unwrap_or_default(), Ordering::Relaxed);
|
.store(cycle_max_directories.unwrap_or_default(), Ordering::Relaxed);
|
||||||
self.scanner_leader_lease_without_progress.store(false, Ordering::Relaxed);
|
|
||||||
self.scanner_cycle_last_progress_age_seconds.store(0, Ordering::Relaxed);
|
|
||||||
metrics::gauge!(OTEL_SCANNER_LEADER_LEASE_WITHOUT_PROGRESS).set(0.0);
|
|
||||||
metrics::gauge!(OTEL_SCANNER_CYCLE_LAST_PROGRESS_AGE).set(0.0);
|
|
||||||
self.scanner_bitrot_cycle_enabled
|
self.scanner_bitrot_cycle_enabled
|
||||||
.store(bitrot_cycle.is_some(), Ordering::Relaxed);
|
.store(bitrot_cycle.is_some(), Ordering::Relaxed);
|
||||||
self.scanner_bitrot_cycle_millis
|
self.scanner_bitrot_cycle_millis
|
||||||
.store(bitrot_cycle.map(duration_millis_saturated).unwrap_or_default(), Ordering::Relaxed);
|
.store(bitrot_cycle.map(duration_millis_saturated).unwrap_or_default(), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_scanner_cycle_timeout(&self, recovery_required: bool, progress_age: Duration) {
|
|
||||||
self.scanner_cycle_timeout_total.fetch_add(1, Ordering::Relaxed);
|
|
||||||
if recovery_required {
|
|
||||||
self.scanner_cycle_recovery_required_total.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
self.scanner_cycle_last_progress_age_seconds
|
|
||||||
.store(progress_age.as_secs(), Ordering::Relaxed);
|
|
||||||
self.scanner_leader_lease_without_progress.store(true, Ordering::Relaxed);
|
|
||||||
metrics::counter!(OTEL_SCANNER_CYCLE_TIMEOUT_TOTAL).increment(1);
|
|
||||||
metrics::gauge!(OTEL_SCANNER_CYCLE_LAST_PROGRESS_AGE).set(progress_age.as_secs_f64());
|
|
||||||
metrics::gauge!(OTEL_SCANNER_LEADER_LEASE_WITHOUT_PROGRESS).set(1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_scanner_set_scan_state(&self, concurrency_limit: Option<usize>, queued: Option<usize>, active: Option<usize>) {
|
pub fn record_scanner_set_scan_state(&self, concurrency_limit: Option<usize>, queued: Option<usize>, active: Option<usize>) {
|
||||||
if let Some(concurrency_limit) = concurrency_limit {
|
if let Some(concurrency_limit) = concurrency_limit {
|
||||||
self.scanner_set_scan_concurrency_limit
|
self.scanner_set_scan_concurrency_limit
|
||||||
@@ -3301,10 +3265,6 @@ impl Metrics {
|
|||||||
m.cycle_max_duration_seconds = self.scanner_cycle_max_duration_millis.load(Ordering::Relaxed) as f64 / 1000.0;
|
m.cycle_max_duration_seconds = self.scanner_cycle_max_duration_millis.load(Ordering::Relaxed) as f64 / 1000.0;
|
||||||
m.cycle_max_objects = self.scanner_cycle_max_objects.load(Ordering::Relaxed);
|
m.cycle_max_objects = self.scanner_cycle_max_objects.load(Ordering::Relaxed);
|
||||||
m.cycle_max_directories = self.scanner_cycle_max_directories.load(Ordering::Relaxed);
|
m.cycle_max_directories = self.scanner_cycle_max_directories.load(Ordering::Relaxed);
|
||||||
m.cycle_timeout_total = self.scanner_cycle_timeout_total.load(Ordering::Relaxed);
|
|
||||||
m.cycle_recovery_required_total = self.scanner_cycle_recovery_required_total.load(Ordering::Relaxed);
|
|
||||||
m.cycle_last_progress_age = self.scanner_cycle_last_progress_age_seconds.load(Ordering::Relaxed);
|
|
||||||
m.leader_lease_without_progress = self.scanner_leader_lease_without_progress.load(Ordering::Relaxed);
|
|
||||||
m.bitrot_cycle_enabled = self.scanner_bitrot_cycle_enabled.load(Ordering::Relaxed);
|
m.bitrot_cycle_enabled = self.scanner_bitrot_cycle_enabled.load(Ordering::Relaxed);
|
||||||
m.bitrot_cycle_seconds = self.scanner_bitrot_cycle_millis.load(Ordering::Relaxed) as f64 / 1000.0;
|
m.bitrot_cycle_seconds = self.scanner_bitrot_cycle_millis.load(Ordering::Relaxed) as f64 / 1000.0;
|
||||||
m.scan_checkpoint = match self.scanner_checkpoint.lock() {
|
m.scan_checkpoint = match self.scanner_checkpoint.lock() {
|
||||||
@@ -4966,20 +4926,4 @@ mod tests {
|
|||||||
assert!(!report.bitrot_cycle_enabled);
|
assert!(!report.bitrot_cycle_enabled);
|
||||||
assert_eq!(report.bitrot_cycle_seconds, 0.0);
|
assert_eq!(report.bitrot_cycle_seconds, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn scanner_cycle_timeout_metrics_reset_for_a_new_cycle() {
|
|
||||||
let metrics = Metrics::new();
|
|
||||||
metrics.record_scanner_cycle_timeout(true, Duration::from_secs(17));
|
|
||||||
let timed_out = metrics.report().await;
|
|
||||||
assert_eq!(timed_out.cycle_timeout_total, 1);
|
|
||||||
assert_eq!(timed_out.cycle_last_progress_age, 17);
|
|
||||||
assert!(timed_out.leader_lease_without_progress);
|
|
||||||
|
|
||||||
metrics.record_scanner_cycle_config(Duration::from_secs(60), None, Some(Duration::from_secs(1)), None, None);
|
|
||||||
let current = metrics.report().await;
|
|
||||||
assert_eq!(current.cycle_timeout_total, 1);
|
|
||||||
assert_eq!(current.cycle_last_progress_age, 0);
|
|
||||||
assert!(!current.leader_lease_without_progress);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,6 @@ Current guidance:
|
|||||||
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
|
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
|
||||||
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
|
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
|
||||||
|
|
||||||
Scanner cycle budget controls:
|
|
||||||
|
|
||||||
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
|
|
||||||
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
|
|
||||||
- A timed-out cycle cancels cooperative scanner work, then fences its leader epoch before releasing the lease. An uncooperative I/O operation is dropped after the bounded shutdown window; its cursor is not claimed to be durable and the scanner reports `recovery-required` when the worker cannot stop cooperatively, the cycle state was not confirmed durable, or epoch fencing cannot be persisted.
|
|
||||||
|
|
||||||
## Mmap read environment aliases
|
## Mmap read environment aliases
|
||||||
|
|
||||||
- `RUSTFS_OBJECT_MMAP_READ_ENABLE` (canonical)
|
- `RUSTFS_OBJECT_MMAP_READ_ENABLE` (canonical)
|
||||||
|
|||||||
@@ -143,12 +143,9 @@ pub const ENV_SCANNER_MAX_WAIT_SECS: &str = "RUSTFS_SCANNER_MAX_WAIT_SECS";
|
|||||||
/// Default scanner speed preset.
|
/// Default scanner speed preset.
|
||||||
pub const DEFAULT_SCANNER_SPEED: &str = "default";
|
pub const DEFAULT_SCANNER_SPEED: &str = "default";
|
||||||
|
|
||||||
/// Default scanner cycle runtime budget when no override is configured.
|
/// Default scanner cycle runtime budget.
|
||||||
///
|
/// `0` keeps the existing unbounded per-cycle behavior.
|
||||||
/// An explicit `0` remains the compatibility escape hatch for an unbounded
|
pub const DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS: u64 = 0;
|
||||||
/// cycle. Keeping the unset default finite prevents a stalled scanner I/O
|
|
||||||
/// operation from holding the leader lease forever.
|
|
||||||
pub const DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS: u64 = 30 * 60;
|
|
||||||
|
|
||||||
/// Default scanner per-cycle object budget.
|
/// Default scanner per-cycle object budget.
|
||||||
/// `0` keeps the existing unbounded per-cycle behavior.
|
/// `0` keeps the existing unbounded per-cycle behavior.
|
||||||
|
|||||||
@@ -585,12 +585,9 @@ impl VersionsHistogram {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replication statistics for a single target.
|
/// Replication statistics for a single target
|
||||||
///
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
/// Renamed from `ReplicationStats`; serde field names are preserved
|
pub struct ReplicationStats {
|
||||||
/// byte-identically to maintain wire compatibility with existing snapshots.
|
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct ReplicationTargetUsage {
|
|
||||||
pub pending_size: u64,
|
pub pending_size: u64,
|
||||||
pub replicated_size: u64,
|
pub replicated_size: u64,
|
||||||
pub failed_size: u64,
|
pub failed_size: u64,
|
||||||
@@ -603,7 +600,7 @@ pub struct ReplicationTargetUsage {
|
|||||||
pub replicated_count: u64,
|
pub replicated_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplicationTargetUsage {
|
impl ReplicationStats {
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
let Self {
|
let Self {
|
||||||
pending_size,
|
pending_size,
|
||||||
@@ -639,7 +636,7 @@ impl ReplicationTargetUsage {
|
|||||||
/// Replication statistics for all targets
|
/// Replication statistics for all targets
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
pub struct ReplicationAllStats {
|
pub struct ReplicationAllStats {
|
||||||
pub targets: HashMap<String, ReplicationTargetUsage>,
|
pub targets: HashMap<String, ReplicationStats>,
|
||||||
pub replica_size: u64,
|
pub replica_size: u64,
|
||||||
pub replica_count: u64,
|
pub replica_count: u64,
|
||||||
}
|
}
|
||||||
@@ -652,7 +649,7 @@ impl ReplicationAllStats {
|
|||||||
targets,
|
targets,
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
*replica_size == 0 && *replica_count == 0 && targets.values().all(ReplicationTargetUsage::is_empty)
|
*replica_size == 0 && *replica_count == 0 && targets.values().all(ReplicationStats::is_empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[deprecated(note = "use is_empty instead")]
|
#[deprecated(note = "use is_empty instead")]
|
||||||
@@ -2469,7 +2466,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_stats_empty_checks_every_field() {
|
fn replication_stats_empty_checks_every_field() {
|
||||||
type SetField = fn(&mut ReplicationTargetUsage);
|
type SetField = fn(&mut ReplicationStats);
|
||||||
|
|
||||||
let cases: [(&str, SetField); 10] = [
|
let cases: [(&str, SetField); 10] = [
|
||||||
("pending_size", |stats| stats.pending_size = 1),
|
("pending_size", |stats| stats.pending_size = 1),
|
||||||
@@ -2484,9 +2481,9 @@ mod tests {
|
|||||||
("replicated_count", |stats| stats.replicated_count = 1),
|
("replicated_count", |stats| stats.replicated_count = 1),
|
||||||
];
|
];
|
||||||
|
|
||||||
assert!(ReplicationTargetUsage::default().is_empty());
|
assert!(ReplicationStats::default().is_empty());
|
||||||
for (field, set_nonzero) in cases {
|
for (field, set_nonzero) in cases {
|
||||||
let mut stats = ReplicationTargetUsage::default();
|
let mut stats = ReplicationStats::default();
|
||||||
set_nonzero(&mut stats);
|
set_nonzero(&mut stats);
|
||||||
assert!(!stats.is_empty(), "{field} must make replication stats non-empty");
|
assert!(!stats.is_empty(), "{field} must make replication stats non-empty");
|
||||||
}
|
}
|
||||||
@@ -2517,17 +2514,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let empty_targets = ReplicationAllStats {
|
let empty_targets = ReplicationAllStats {
|
||||||
targets: HashMap::from([("arn:test:empty".to_string(), ReplicationTargetUsage::default())]),
|
targets: HashMap::from([("arn:test:empty".to_string(), ReplicationStats::default())]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(empty_targets.is_empty(), "all-empty targets must keep aggregate stats empty");
|
assert!(empty_targets.is_empty(), "all-empty targets must keep aggregate stats empty");
|
||||||
|
|
||||||
let stats = ReplicationAllStats {
|
let stats = ReplicationAllStats {
|
||||||
targets: HashMap::from([
|
targets: HashMap::from([
|
||||||
("arn:test:empty".to_string(), ReplicationTargetUsage::default()),
|
("arn:test:empty".to_string(), ReplicationStats::default()),
|
||||||
(
|
(
|
||||||
"arn:test:non-empty".to_string(),
|
"arn:test:non-empty".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
pending_count: 1,
|
pending_count: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -2568,7 +2565,7 @@ mod tests {
|
|||||||
replication_stats: Some(ReplicationAllStats {
|
replication_stats: Some(ReplicationAllStats {
|
||||||
targets: HashMap::from([(
|
targets: HashMap::from([(
|
||||||
"arn:test:pending".to_string(),
|
"arn:test:pending".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
pending_count: 1,
|
pending_count: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -2717,7 +2714,7 @@ mod tests {
|
|||||||
targets: HashMap::from([
|
targets: HashMap::from([
|
||||||
(
|
(
|
||||||
"arn:self-only".to_string(),
|
"arn:self-only".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
pending_size: 7,
|
pending_size: 7,
|
||||||
pending_count: 1,
|
pending_count: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -2725,7 +2722,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"arn:shared".to_string(),
|
"arn:shared".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
failed_size: 3,
|
failed_size: 3,
|
||||||
failed_count: 1,
|
failed_count: 1,
|
||||||
missed_threshold_size: 2,
|
missed_threshold_size: 2,
|
||||||
@@ -2744,7 +2741,7 @@ mod tests {
|
|||||||
targets: HashMap::from([
|
targets: HashMap::from([
|
||||||
(
|
(
|
||||||
"arn:shared".to_string(),
|
"arn:shared".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
failed_size: 5,
|
failed_size: 5,
|
||||||
failed_count: 2,
|
failed_count: 2,
|
||||||
after_threshold_size: 4,
|
after_threshold_size: 4,
|
||||||
@@ -2754,7 +2751,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"arn:other-only".to_string(),
|
"arn:other-only".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
replicated_size: 11,
|
replicated_size: 11,
|
||||||
replicated_count: 3,
|
replicated_count: 3,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -2996,9 +2993,7 @@ mod tests {
|
|||||||
fn replication_target_deserialization_preserves_large_historical_maps() {
|
fn replication_target_deserialization_preserves_large_historical_maps() {
|
||||||
let mut stats = ReplicationAllStats::default();
|
let mut stats = ReplicationAllStats::default();
|
||||||
for index in 0..=1024 {
|
for index in 0..=1024 {
|
||||||
stats
|
stats.targets.insert(format!("target-{index}"), ReplicationStats::default());
|
||||||
.targets
|
|
||||||
.insert(format!("target-{index}"), ReplicationTargetUsage::default());
|
|
||||||
}
|
}
|
||||||
let encoded = rmp_serde::to_vec_named(&stats).expect("large replication target fixture should encode");
|
let encoded = rmp_serde::to_vec_named(&stats).expect("large replication target fixture should encode");
|
||||||
let decoded = rmp_serde::from_slice::<ReplicationAllStats>(&encoded)
|
let decoded = rmp_serde::from_slice::<ReplicationAllStats>(&encoded)
|
||||||
@@ -3007,47 +3002,6 @@ mod tests {
|
|||||||
assert_eq!(decoded.targets.len(), stats.targets.len());
|
assert_eq!(decoded.targets.len(), stats.targets.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Round-trip test: encoding a [`ReplicationTargetUsage`] and decoding it back
|
|
||||||
/// must produce the exact same value. This guards against accidental serde
|
|
||||||
/// field-name drift during the `ReplicationStats` -> `ReplicationTargetUsage`
|
|
||||||
/// rename. Wire-level field names are the serialized Rust field identifiers,
|
|
||||||
/// which must remain byte-identical.
|
|
||||||
#[test]
|
|
||||||
fn replication_target_usage_rmp_round_trip() {
|
|
||||||
let original = ReplicationTargetUsage {
|
|
||||||
pending_size: 100,
|
|
||||||
replicated_size: 2_000,
|
|
||||||
failed_size: 50,
|
|
||||||
failed_count: 3,
|
|
||||||
pending_count: 7,
|
|
||||||
missed_threshold_size: 11,
|
|
||||||
after_threshold_size: 22,
|
|
||||||
missed_threshold_count: 1,
|
|
||||||
after_threshold_count: 2,
|
|
||||||
replicated_count: 99,
|
|
||||||
};
|
|
||||||
|
|
||||||
let buf = rmp_serde::to_vec_named(&original).expect("encode ReplicationTargetUsage to msgpack");
|
|
||||||
let decoded: ReplicationTargetUsage = rmp_serde::from_slice(&buf).expect("decode ReplicationTargetUsage from msgpack");
|
|
||||||
assert_eq!(original, decoded, "round-trip through rmp must preserve every field");
|
|
||||||
|
|
||||||
// Also verify that encoding as an unnamed sequence and then decoding
|
|
||||||
// with named fields produces the correct mapping (this catches reordering).
|
|
||||||
let named_buf = rmp_serde::to_vec_named(&original).expect("re-encode for field-name pinning");
|
|
||||||
// Spot-check that known field names appear in the named encoding.
|
|
||||||
let named_str = String::from_utf8_lossy(&named_buf);
|
|
||||||
assert!(named_str.contains("pending_size"), "field 'pending_size' must survive the rename");
|
|
||||||
assert!(named_str.contains("replicated_size"), "field 'replicated_size' must survive the rename");
|
|
||||||
assert!(
|
|
||||||
named_str.contains("missed_threshold_size"),
|
|
||||||
"field 'missed_threshold_size' must survive the rename"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
named_str.contains("after_threshold_count"),
|
|
||||||
"field 'after_threshold_count' must survive the rename"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checked_merge_rejects_noncanonical_histograms_without_mutation() {
|
fn checked_merge_rejects_noncanonical_histograms_without_mutation() {
|
||||||
let mut entry = DataUsageEntry {
|
let mut entry = DataUsageEntry {
|
||||||
|
|||||||
@@ -380,25 +380,11 @@ mod tests {
|
|||||||
cluster.start_node(1).await?;
|
cluster.start_node(1).await?;
|
||||||
|
|
||||||
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
|
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
|
||||||
let mut recovered = serde_json::Value::Null;
|
|
||||||
for _ in 0..60 {
|
|
||||||
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||||
assert!(
|
assert!(
|
||||||
!status_body.contains("MissingContentLength"),
|
!status_body.contains("MissingContentLength"),
|
||||||
"background heal status should not fail without an explicit Content-Length: {status_body}"
|
"background heal status should not fail without an explicit Content-Length: {status_body}"
|
||||||
);
|
);
|
||||||
recovered = serde_json::from_str(&status_body)
|
|
||||||
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
|
|
||||||
if recovered["clusterStatusComplete"] == serde_json::Value::Bool(true) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sleep(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
recovered["clusterStatusComplete"],
|
|
||||||
serde_json::Value::Bool(true),
|
|
||||||
"cluster heal status should recover before root heal starts: {recovered}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
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/?forceStart=true", cluster.nodes[0].url);
|
let heal_url = format!("{}/rustfs/admin/v3/heal/?forceStart=true", cluster.nodes[0].url);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async fn test_bucket_default_sse_s3_put_object() -> Result<(), Box<dyn std::erro
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -159,7 +159,7 @@ async fn test_bucket_default_sse_kms_put_object() -> Result<(), Box<dyn std::err
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -278,7 +278,7 @@ async fn test_bucket_default_sse_kms_multipart_crc32() -> Result<(), Box<dyn std
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -475,7 +475,7 @@ async fn test_explicit_encryption_overrides_bucket_default() -> Result<(), Box<d
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -570,7 +570,7 @@ async fn test_sse_kms_without_key_id_populates_default() -> Result<(), Box<dyn s
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -189,121 +189,34 @@ pub async fn wait_for_kms_ready(
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
wait_for_kms_ready_with_timeout(base_url, access_key, secret_key, Duration::from_secs(5)).await
|
let total_deadline = Duration::from_secs(5);
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_kms_ready_with_timeout(
|
|
||||||
base_url: &str,
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
total_deadline: Duration,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let start = tokio::time::Instant::now();
|
let start = tokio::time::Instant::now();
|
||||||
let deadline = start + total_deadline;
|
|
||||||
let mut backoff = Duration::from_millis(200);
|
let mut backoff = Duration::from_millis(200);
|
||||||
let max_backoff = Duration::from_secs(1);
|
let max_backoff = Duration::from_secs(1);
|
||||||
|
let mut first_attempt = true;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match tokio::time::timeout_at(deadline, get_kms_status(base_url, access_key, secret_key)).await {
|
if !first_attempt {
|
||||||
Ok(Ok(status)) => {
|
if start.elapsed() >= total_deadline {
|
||||||
let backend_status = serde_json::from_str::<serde_json::Value>(&status)
|
return Err("KMS failed to become ready within 5 seconds".into());
|
||||||
.ok()
|
}
|
||||||
.and_then(|value| value.get("backend_status")?.as_str().map(str::to_owned));
|
sleep(backoff).await;
|
||||||
if backend_status.as_deref() == Some("healthy") {
|
backoff = (backoff * 2).min(max_backoff);
|
||||||
|
}
|
||||||
|
first_attempt = false;
|
||||||
|
|
||||||
|
match get_kms_status(base_url, access_key, secret_key).await {
|
||||||
|
Ok(status) => {
|
||||||
info!("KMS is ready (status: {})", status);
|
info!("KMS is ready (status: {})", status);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
warn!(
|
Err(e) => {
|
||||||
backend_status = backend_status.as_deref().unwrap_or("missing"),
|
if start.elapsed() >= total_deadline {
|
||||||
elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
|
return Err(format!("KMS did not become ready within 5 s: last error: {e}").into());
|
||||||
"KMS not ready yet, retrying…"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
warn!(error = %e, elapsed_ms = start.elapsed().as_millis() as u64, "KMS not ready yet, retrying…");
|
||||||
let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
|
||||||
warn!(error = %e, elapsed_ms, "KMS not ready yet, retrying…");
|
|
||||||
}
|
|
||||||
Err(_) => return Err(format!("KMS failed to become ready within {} ms", total_deadline.as_millis()).into()),
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = tokio::time::Instant::now();
|
|
||||||
if now >= deadline {
|
|
||||||
return Err(format!("KMS failed to become ready within {} ms", total_deadline.as_millis()).into());
|
|
||||||
}
|
|
||||||
sleep((now + backoff).min(deadline) - now).await;
|
|
||||||
backoff = (backoff * 2).min(max_backoff);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod readiness_tests {
|
|
||||||
use super::{wait_for_kms_ready, wait_for_kms_ready_with_timeout};
|
|
||||||
use std::sync::{
|
|
||||||
Arc,
|
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn kms_readiness_retries_http_success_until_backend_is_healthy() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind readiness test server");
|
|
||||||
let address = listener.local_addr().expect("read readiness test server address");
|
|
||||||
let requests = Arc::new(AtomicUsize::new(0));
|
|
||||||
let server_requests = Arc::clone(&requests);
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
for backend_status in ["error", "healthy"] {
|
|
||||||
let (mut socket, _) = listener.accept().await.expect("accept readiness request");
|
|
||||||
let mut request = Vec::new();
|
|
||||||
let mut chunk = [0_u8; 1024];
|
|
||||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
||||||
let read = socket.read(&mut chunk).await.expect("read readiness request");
|
|
||||||
if read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
request.extend_from_slice(&chunk[..read]);
|
|
||||||
}
|
|
||||||
server_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let body = format!(r#"{{"backend_status":"{backend_status}"}}"#);
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
socket.write_all(response.as_bytes()).await.expect("write readiness response");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wait_for_kms_ready(&format!("http://{address}"), "access-key", "secret-key")
|
|
||||||
.await
|
|
||||||
.expect("KMS should become ready after the healthy response");
|
|
||||||
|
|
||||||
let observed_requests = requests.load(Ordering::SeqCst);
|
|
||||||
server.abort();
|
|
||||||
assert_eq!(observed_requests, 2, "an HTTP 200 unhealthy status must be retried");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn kms_readiness_deadline_covers_a_stalled_status_request() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind readiness test server");
|
|
||||||
let address = listener.local_addr().expect("read readiness test server address");
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut socket, _) = listener.accept().await.expect("accept readiness request");
|
|
||||||
let mut request = [0_u8; 1024];
|
|
||||||
let _ = socket.read(&mut request).await.expect("read readiness request");
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
Duration::from_secs(1),
|
|
||||||
wait_for_kms_ready_with_timeout(&format!("http://{address}"), "access-key", "secret-key", Duration::from_millis(50)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("readiness helper must enforce its own deadline");
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
assert!(result.is_err(), "a stalled status request must not outlive the readiness deadline");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async fn test_metadata_replace_self_copy_of_sse_object_stays_decryptable() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("failed to start RustFS with local KMS");
|
.expect("failed to start RustFS with local KMS");
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let client = kms_env.base_env.create_s3_client();
|
let client = kms_env.base_env.create_s3_client();
|
||||||
// Deliberately an UNVERSIONED bucket: that is the branch where the store layer can service
|
// Deliberately an UNVERSIONED bucket: that is the branch where the store layer can service
|
||||||
@@ -160,7 +160,7 @@ async fn test_metadata_replace_self_copy_dropping_sse_rewrites_plaintext() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("failed to start RustFS with local KMS");
|
.expect("failed to start RustFS with local KMS");
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let client = kms_env.base_env.create_s3_client();
|
let client = kms_env.base_env.create_s3_client();
|
||||||
// Unversioned, and deliberately WITHOUT a bucket default-encryption rule, so the copy below
|
// Unversioned, and deliberately WITHOUT a bucket default-encryption rule, so the copy below
|
||||||
@@ -256,7 +256,7 @@ async fn test_metadata_replace_self_copy_under_bucket_default_sse_stays_decrypta
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("failed to start RustFS with local KMS");
|
.expect("failed to start RustFS with local KMS");
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let client = kms_env.base_env.create_s3_client();
|
let client = kms_env.base_env.create_s3_client();
|
||||||
let bucket = "copy-object-self-copy-bucket-default-sse-test";
|
let bucket = "copy-object-self-copy-bucket-default-sse-test";
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async fn test_self_copy_of_historical_sse_s3_version_is_readable() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("failed to start RustFS with local KMS");
|
.expect("failed to start RustFS with local KMS");
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let client = kms_env.base_env.create_s3_client();
|
let client = kms_env.base_env.create_s3_client();
|
||||||
let bucket = "copy-object-version-restore-sse-test";
|
let bucket = "copy-object-version-restore-sse-test";
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async fn test_head_reports_managed_metadata_for_sse_s3() -> Result<(), Box<dyn s
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -147,7 +147,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -250,7 +250,7 @@ async fn test_multipart_upload_writes_encrypted_data() -> Result<(), Box<dyn std
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use super::common::{
|
|||||||
test_sse_kms_encryption, test_sse_s3_encryption,
|
test_sse_kms_encryption, test_sse_s3_encryption,
|
||||||
};
|
};
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
|
use tokio::time::{Duration, sleep};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// Comprehensive test: Full KMS workflow with all encryption types
|
/// Comprehensive test: Full KMS workflow with all encryption types
|
||||||
@@ -34,7 +35,7 @@ async fn test_comprehensive_kms_full_workflow() -> Result<(), Box<dyn std::error
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -102,7 +103,7 @@ async fn test_comprehensive_stress_test() -> Result<(), Box<dyn std::error::Erro
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -136,7 +137,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -207,7 +208,7 @@ async fn test_comprehensive_concurrent_operations() -> Result<(), Box<dyn std::e
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -252,7 +253,7 @@ async fn test_comprehensive_performance_benchmark() -> Result<(), Box<dyn std::e
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -117,7 +117,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -209,7 +209,7 @@ async fn test_kms_multipart_boundary_conditions() -> Result<(), Box<dyn std::err
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -284,7 +284,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -371,7 +371,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = Arc::new(kms_env.base_env.create_s3_client());
|
let s3_client = Arc::new(kms_env.base_env.create_s3_client());
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -478,7 +478,7 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -127,7 +127,7 @@ async fn test_kms_corrupted_key_files() -> Result<(), Box<dyn std::error::Error
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -218,7 +218,7 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -401,7 +401,7 @@ async fn test_kms_resource_constraints() -> Result<(), Box<dyn std::error::Error
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + S
|
|||||||
.expect("Failed to start RustFS with Local KMS");
|
.expect("Failed to start RustFS with Local KMS");
|
||||||
|
|
||||||
// Wait a moment for RustFS to fully start up and initialize KMS
|
// Wait a moment for RustFS to fully start up and initialize KMS
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ async fn test_local_kms_key_isolation() {
|
|||||||
.expect("Failed to start RustFS with Local KMS");
|
.expect("Failed to start RustFS with Local KMS");
|
||||||
|
|
||||||
// Wait a moment for RustFS to fully start up and initialize KMS
|
// Wait a moment for RustFS to fully start up and initialize KMS
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ async fn test_local_kms_large_file() {
|
|||||||
.expect("Failed to start RustFS with Local KMS");
|
.expect("Failed to start RustFS with Local KMS");
|
||||||
|
|
||||||
// Wait a moment for RustFS to fully start up and initialize KMS
|
// Wait a moment for RustFS to fully start up and initialize KMS
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ async fn test_local_kms_multipart_upload() {
|
|||||||
.expect("Failed to start RustFS with Local KMS");
|
.expect("Failed to start RustFS with Local KMS");
|
||||||
|
|
||||||
// Wait for KMS initialization
|
// Wait for KMS initialization
|
||||||
kms_env.wait_for_kms_ready().await.expect("KMS ready");
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
info!("RustFS started with KMS auto-configuration, default_key_id: {}", default_key_id);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
//! multipart upload behaviour.
|
//! multipart upload behaviour.
|
||||||
|
|
||||||
use crate::common::{TEST_BUCKET, init_logging};
|
use crate::common::{TEST_BUCKET, init_logging};
|
||||||
|
use tokio::time::{Duration, sleep};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
@@ -44,8 +45,8 @@ impl VaultKmsTestContext {
|
|||||||
|
|
||||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||||
|
|
||||||
// Wait for KMS to finish initialising.
|
// Allow Vault to finish initialising token auth and transit engine.
|
||||||
super::common::wait_for_kms_ready(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
Ok(Self { env })
|
Ok(Self { env })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ mod kms_edge_cases_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod kms_fault_recovery_test;
|
mod kms_fault_recovery_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_runner;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod bucket_default_encryption_test;
|
mod bucket_default_encryption_test;
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async fn test_step1_basic_single_file_encryption() -> Result<(), Box<dyn std::er
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -89,7 +89,7 @@ async fn test_step2_basic_multipart_upload_without_encryption() -> Result<(), Bo
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -187,7 +187,7 @@ async fn test_step3_multipart_upload_with_sse_s3() -> Result<(), Box<dyn std::er
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -310,7 +310,7 @@ async fn test_step4_large_multipart_upload_with_encryption() -> Result<(), Box<d
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
@@ -435,7 +435,7 @@ async fn test_step5_all_encryption_types_multipart() -> Result<(), Box<dyn std::
|
|||||||
|
|
||||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
kms_env.wait_for_kms_ready().await?;
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let s3_client = kms_env.base_env.create_s3_client();
|
let s3_client = kms_env.base_env.create_s3_client();
|
||||||
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
// 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
|
||||||
|
//
|
||||||
|
#![allow(dead_code)]
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Unified KMS test suite runner
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface for running KMS tests with categorization,
|
||||||
|
//! filtering, and comprehensive reporting capabilities.
|
||||||
|
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::time::{Duration, sleep};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
/// Test category for organization and filtering
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum TestCategory {
|
||||||
|
CoreFunctionality,
|
||||||
|
MultipartEncryption,
|
||||||
|
EdgeCases,
|
||||||
|
FaultRecovery,
|
||||||
|
Comprehensive,
|
||||||
|
Performance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestCategory {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TestCategory::CoreFunctionality => "core-functionality",
|
||||||
|
TestCategory::MultipartEncryption => "multipart-encryption",
|
||||||
|
TestCategory::EdgeCases => "edge-cases",
|
||||||
|
TestCategory::FaultRecovery => "fault-recovery",
|
||||||
|
TestCategory::Comprehensive => "comprehensive",
|
||||||
|
TestCategory::Performance => "performance",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test definition with metadata
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TestDefinition {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub category: TestCategory,
|
||||||
|
pub estimated_duration: Duration,
|
||||||
|
pub is_critical: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestDefinition {
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
description: impl Into<String>,
|
||||||
|
category: TestCategory,
|
||||||
|
estimated_duration: Duration,
|
||||||
|
is_critical: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
description: description.into(),
|
||||||
|
category,
|
||||||
|
estimated_duration,
|
||||||
|
is_critical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test execution result
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TestResult {
|
||||||
|
pub test_name: String,
|
||||||
|
pub category: TestCategory,
|
||||||
|
pub success: bool,
|
||||||
|
pub duration: Duration,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestResult {
|
||||||
|
pub fn success(test_name: String, category: TestCategory, duration: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
test_name,
|
||||||
|
category,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
error_message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failure(test_name: String, category: TestCategory, duration: Duration, error: String) -> Self {
|
||||||
|
Self {
|
||||||
|
test_name,
|
||||||
|
category,
|
||||||
|
success: false,
|
||||||
|
duration,
|
||||||
|
error_message: Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comprehensive test suite configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TestSuiteConfig {
|
||||||
|
pub categories: Vec<TestCategory>,
|
||||||
|
pub include_critical_only: bool,
|
||||||
|
pub max_duration: Option<Duration>,
|
||||||
|
pub parallel_execution: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TestSuiteConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
categories: vec![
|
||||||
|
TestCategory::CoreFunctionality,
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
TestCategory::FaultRecovery,
|
||||||
|
TestCategory::Comprehensive,
|
||||||
|
],
|
||||||
|
include_critical_only: false,
|
||||||
|
max_duration: None,
|
||||||
|
parallel_execution: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified KMS test suite runner
|
||||||
|
pub struct KMSTestSuite {
|
||||||
|
tests: Vec<TestDefinition>,
|
||||||
|
config: TestSuiteConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KMSTestSuite {
|
||||||
|
/// Create a new test suite with default configuration
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let tests = vec![
|
||||||
|
// Core Functionality Tests
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_local_kms_end_to_end",
|
||||||
|
"End-to-end KMS test with all encryption types",
|
||||||
|
TestCategory::CoreFunctionality,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_local_kms_key_isolation",
|
||||||
|
"Test KMS key isolation and security",
|
||||||
|
TestCategory::CoreFunctionality,
|
||||||
|
Duration::from_secs(45),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
// Multipart Encryption Tests
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_local_kms_multipart_upload",
|
||||||
|
"Test large file multipart upload with encryption",
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
Duration::from_secs(120),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_step1_basic_single_file_encryption",
|
||||||
|
"Basic single file encryption test",
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_step2_basic_multipart_upload_without_encryption",
|
||||||
|
"Basic multipart upload without encryption",
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
Duration::from_secs(45),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_step3_multipart_upload_with_sse_s3",
|
||||||
|
"Multipart upload with SSE-S3 encryption",
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_step4_large_multipart_upload_with_encryption",
|
||||||
|
"Large file multipart upload with encryption",
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
Duration::from_secs(90),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_step5_all_encryption_types_multipart",
|
||||||
|
"All encryption types multipart test",
|
||||||
|
TestCategory::MultipartEncryption,
|
||||||
|
Duration::from_secs(120),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
// Edge Cases Tests
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_zero_byte_file_encryption",
|
||||||
|
"Test encryption of zero-byte files",
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
Duration::from_secs(20),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_single_byte_file_encryption",
|
||||||
|
"Test encryption of single-byte files",
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
Duration::from_secs(20),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_multipart_boundary_conditions",
|
||||||
|
"Test multipart upload boundary conditions",
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
Duration::from_secs(45),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_invalid_key_scenarios",
|
||||||
|
"Test invalid key scenarios",
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_concurrent_encryption",
|
||||||
|
"Test concurrent encryption operations",
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_key_validation_security",
|
||||||
|
"Test key validation security",
|
||||||
|
TestCategory::EdgeCases,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// Fault Recovery Tests
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_key_directory_unavailable",
|
||||||
|
"Test KMS when key directory is unavailable",
|
||||||
|
TestCategory::FaultRecovery,
|
||||||
|
Duration::from_secs(45),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_corrupted_key_files",
|
||||||
|
"Test KMS with corrupted key files",
|
||||||
|
TestCategory::FaultRecovery,
|
||||||
|
Duration::from_secs(30),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_multipart_upload_interruption",
|
||||||
|
"Test multipart upload interruption recovery",
|
||||||
|
TestCategory::FaultRecovery,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_kms_resource_constraints",
|
||||||
|
"Test KMS under resource constraints",
|
||||||
|
TestCategory::FaultRecovery,
|
||||||
|
Duration::from_secs(90),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// Comprehensive Tests
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_comprehensive_kms_full_workflow",
|
||||||
|
"Full KMS workflow comprehensive test",
|
||||||
|
TestCategory::Comprehensive,
|
||||||
|
Duration::from_secs(300),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_comprehensive_stress_test",
|
||||||
|
"KMS stress test with large datasets",
|
||||||
|
TestCategory::Comprehensive,
|
||||||
|
Duration::from_secs(400),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_comprehensive_key_isolation",
|
||||||
|
"Comprehensive key isolation test",
|
||||||
|
TestCategory::Comprehensive,
|
||||||
|
Duration::from_secs(180),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_comprehensive_concurrent_operations",
|
||||||
|
"Comprehensive concurrent operations test",
|
||||||
|
TestCategory::Comprehensive,
|
||||||
|
Duration::from_secs(240),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TestDefinition::new(
|
||||||
|
"test_comprehensive_performance_benchmark",
|
||||||
|
"KMS performance benchmark test",
|
||||||
|
TestCategory::Comprehensive,
|
||||||
|
Duration::from_secs(360),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tests,
|
||||||
|
config: TestSuiteConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the test suite
|
||||||
|
pub fn with_config(mut self, config: TestSuiteConfig) -> Self {
|
||||||
|
self.config = config;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter tests based on category
|
||||||
|
pub fn filter_by_category(&self, category: &TestCategory) -> Vec<&TestDefinition> {
|
||||||
|
self.tests.iter().filter(|test| &test.category == category).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter tests based on criticality
|
||||||
|
pub fn filter_critical_tests(&self) -> Vec<&TestDefinition> {
|
||||||
|
self.tests.iter().filter(|test| test.is_critical).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get test summary by category
|
||||||
|
pub fn get_category_summary(&self) -> std::collections::HashMap<TestCategory, Vec<&TestDefinition>> {
|
||||||
|
let mut summary = std::collections::HashMap::new();
|
||||||
|
for test in &self.tests {
|
||||||
|
summary.entry(test.category.clone()).or_insert_with(Vec::new).push(test);
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the complete test suite
|
||||||
|
pub async fn run_test_suite(&self) -> Vec<TestResult> {
|
||||||
|
init_logging();
|
||||||
|
info!("🚀 Starting unified KMS test suite");
|
||||||
|
|
||||||
|
let start_time = Instant::now();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Filter tests based on configuration
|
||||||
|
let tests_to_run: Vec<&TestDefinition> = self
|
||||||
|
.tests
|
||||||
|
.iter()
|
||||||
|
.filter(|test| self.config.categories.contains(&test.category))
|
||||||
|
.filter(|test| !self.config.include_critical_only || test.is_critical)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!("📊 Test plan: {} test(s) scheduled", tests_to_run.len());
|
||||||
|
for (i, test) in tests_to_run.iter().enumerate() {
|
||||||
|
info!(" {}. {} ({})", i + 1, test.name, test.category.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tests
|
||||||
|
for (i, test_def) in tests_to_run.iter().enumerate() {
|
||||||
|
info!("🧪 Running test {}/{}: {}", i + 1, tests_to_run.len(), test_def.name);
|
||||||
|
info!(" 📝 Description: {}", test_def.description);
|
||||||
|
info!(" 🏷️ Category: {}", test_def.category.as_str());
|
||||||
|
info!(" ⏱️ Estimated duration: {:?}", test_def.estimated_duration);
|
||||||
|
|
||||||
|
let test_start = Instant::now();
|
||||||
|
let result = self.run_single_test(test_def).await;
|
||||||
|
let test_duration = test_start.elapsed();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("✅ Test passed: {} ({:.2}s)", test_def.name, test_duration.as_secs_f64());
|
||||||
|
results.push(TestResult::success(test_def.name.clone(), test_def.category.clone(), test_duration));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("❌ Test failed: {} ({:.2}s): {}", test_def.name, test_duration.as_secs_f64(), e);
|
||||||
|
results.push(TestResult::failure(
|
||||||
|
test_def.name.clone(),
|
||||||
|
test_def.category.clone(),
|
||||||
|
test_duration,
|
||||||
|
e.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between tests to avoid resource conflicts
|
||||||
|
if i < tests_to_run.len() - 1 {
|
||||||
|
debug!("⏸️ Waiting two seconds before the next test...");
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_duration = start_time.elapsed();
|
||||||
|
self.print_test_summary(&results, total_duration);
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a single test by dispatching to the appropriate test function
|
||||||
|
async fn run_single_test(&self, test_def: &TestDefinition) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// This is a placeholder for test dispatch logic
|
||||||
|
// In a real implementation, this would dispatch to actual test functions
|
||||||
|
warn!("⚠️ Test '{}' is not implemented in the unified runner; skipping", test_def.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print comprehensive test summary
|
||||||
|
fn print_test_summary(&self, results: &[TestResult], total_duration: Duration) {
|
||||||
|
info!("📊 KMS test suite summary");
|
||||||
|
info!("⏱️ Total duration: {:.2} seconds", total_duration.as_secs_f64());
|
||||||
|
info!("📈 Total tests: {}", results.len());
|
||||||
|
|
||||||
|
let passed = results.iter().filter(|r| r.success).count();
|
||||||
|
let failed = results.iter().filter(|r| !r.success).count();
|
||||||
|
|
||||||
|
info!("✅ Passed: {}", passed);
|
||||||
|
info!("❌ Failed: {}", failed);
|
||||||
|
info!("📊 Success rate: {:.1}%", (passed as f64 / results.len() as f64) * 100.0);
|
||||||
|
|
||||||
|
// Summary by category
|
||||||
|
let mut category_summary: std::collections::HashMap<TestCategory, (usize, usize)> = std::collections::HashMap::new();
|
||||||
|
for result in results {
|
||||||
|
let (total, passed_count) = category_summary.entry(result.category.clone()).or_insert((0, 0));
|
||||||
|
*total += 1;
|
||||||
|
if result.success {
|
||||||
|
*passed_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("📊 Category summary:");
|
||||||
|
for (category, (total, passed_count)) in category_summary {
|
||||||
|
info!(
|
||||||
|
" 🏷️ {}: {}/{} ({:.1}%)",
|
||||||
|
category.as_str(),
|
||||||
|
passed_count,
|
||||||
|
total,
|
||||||
|
(passed_count as f64 / total as f64) * 100.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List failed tests
|
||||||
|
if failed > 0 {
|
||||||
|
warn!("❌ Failing tests:");
|
||||||
|
for result in results.iter().filter(|r| !r.success) {
|
||||||
|
warn!(" - {}: {}", result.test_name, result.error_message.as_deref().unwrap_or("Unknown error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick test suite for critical tests only
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_kms_critical_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let config = TestSuiteConfig {
|
||||||
|
categories: vec![TestCategory::CoreFunctionality, TestCategory::MultipartEncryption],
|
||||||
|
include_critical_only: true,
|
||||||
|
max_duration: Some(Duration::from_secs(600)), // 10 minutes max
|
||||||
|
parallel_execution: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let suite = KMSTestSuite::new().with_config(config);
|
||||||
|
let results = suite.run_test_suite().await;
|
||||||
|
|
||||||
|
let failed_count = results.iter().filter(|r| !r.success).count();
|
||||||
|
if failed_count > 0 {
|
||||||
|
return Err(format!("Critical test suite failed: {failed_count} tests failed").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("✅ All critical tests passed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full comprehensive test suite
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_kms_full_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let suite = KMSTestSuite::new();
|
||||||
|
let results = suite.run_test_suite().await;
|
||||||
|
|
||||||
|
let total_tests = results.len();
|
||||||
|
let failed_count = results.iter().filter(|r| !r.success).count();
|
||||||
|
let success_rate = ((total_tests - failed_count) as f64 / total_tests as f64) * 100.0;
|
||||||
|
|
||||||
|
info!("📊 Full suite success rate: {:.1}%", success_rate);
|
||||||
|
|
||||||
|
// Allow up to 10% failure rate for non-critical tests
|
||||||
|
if success_rate < 90.0 {
|
||||||
|
return Err(format!("Test suite success rate too low: {success_rate:.1}%").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("✅ Full test suite succeeded");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_lo
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use rustfs_config::{ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, ENV_NOTIFY_ENABLE};
|
|
||||||
use rustfs_signer::pre_sign_v4;
|
use rustfs_signer::pre_sign_v4;
|
||||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
@@ -977,8 +976,7 @@ async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn
|
|||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
|
env.start_rustfs_server(vec![]).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let bucket = "object-lambda-e2e-disabled-target";
|
let bucket = "object-lambda-e2e-disabled-target";
|
||||||
let key = "input.txt";
|
let key = "input.txt";
|
||||||
@@ -994,24 +992,17 @@ async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let queue_dir = format!("{}/disabled-target-queue", env.temp_dir);
|
configure_webhook_target_with_key_values(
|
||||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
&env,
|
||||||
let config_url = format!("{}/rustfs/admin/v3/set-config-kv", env.url);
|
"transformer",
|
||||||
let directive = format!(
|
vec![
|
||||||
"notify_webhook:transformer enable=off endpoint=\"http://127.0.0.1:9/transform\" auth_token=\"secret-token\" queue_dir=\"{queue_dir}\""
|
("endpoint", "http://127.0.0.1:9/transform".to_string()),
|
||||||
);
|
("auth_token", "secret-token".to_string()),
|
||||||
let disable_response = signed_request(
|
("enable", "off".to_string()),
|
||||||
http::Method::PUT,
|
],
|
||||||
&config_url,
|
|
||||||
&env.access_key,
|
|
||||||
&env.secret_key,
|
|
||||||
Some(directive.into_bytes()),
|
|
||||||
Some("text/plain"),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let disable_status = disable_response.status();
|
wait_for_target_visibility(&env, "transformer").await?;
|
||||||
let disable_body = disable_response.text().await?;
|
|
||||||
assert_eq!(disable_status, StatusCode::OK, "failed to disable target: {disable_body}");
|
|
||||||
|
|
||||||
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
|
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
|
||||||
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
|
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
|
||||||
@@ -1030,8 +1021,7 @@ async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Resul
|
|||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
|
env.start_rustfs_server(vec![]).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let bucket = "object-lambda-e2e-invalid-endpoint";
|
let bucket = "object-lambda-e2e-invalid-endpoint";
|
||||||
|
|
||||||
@@ -1074,8 +1064,7 @@ async fn test_configure_object_lambda_notify_webhook_rejects_response_header_tim
|
|||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
env.start_rustfs_server_with_env(vec![], &[(ENV_NOTIFY_ENABLE, "true")])
|
env.start_rustfs_server(vec![]).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let response = send_configure_webhook_target_request(
|
let response = send_configure_webhook_target_request(
|
||||||
&env,
|
&env,
|
||||||
@@ -1184,8 +1173,6 @@ async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box
|
|||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||||
cluster.set_env(ENV_NOTIFY_ENABLE, "true");
|
|
||||||
cluster.set_env(ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS, "1");
|
|
||||||
cluster.start().await?;
|
cluster.start().await?;
|
||||||
|
|
||||||
let bucket = "listen-notification-cluster";
|
let bucket = "listen-notification-cluster";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||||
use aws_sdk_s3::error::SdkError;
|
use aws_sdk_s3::error::SdkError;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::CompletedMultipartUpload;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -42,18 +43,32 @@ async fn list_parts_reports_missing_upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn multipart_listing_reports_missing_upload(
|
async fn complete_reports_missing_upload(
|
||||||
client: &aws_sdk_s3::Client,
|
client: &aws_sdk_s3::Client,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
key: &str,
|
key: &str,
|
||||||
upload_id: &str,
|
upload_id: &str,
|
||||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let result = client.list_multipart_uploads().bucket(bucket).prefix(key).send().await?;
|
let result = client
|
||||||
|
.complete_multipart_upload()
|
||||||
Ok(!result
|
.bucket(bucket)
|
||||||
.uploads()
|
.key(key)
|
||||||
.iter()
|
.upload_id(upload_id)
|
||||||
.any(|upload| upload.key() == Some(key) && upload.upload_id() == Some(upload_id)))
|
.multipart_upload(CompletedMultipartUpload::builder().build())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(false),
|
||||||
|
Err(SdkError::ServiceError(err)) => {
|
||||||
|
let code = err.err().meta().code().unwrap_or("");
|
||||||
|
if code == "NoSuchUpload" {
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Err(format!("unexpected complete_multipart_upload service error: code={code}, err={err:?}").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => Err(format!("unexpected complete_multipart_upload error: {err:?}").into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_cleanup_on_all_nodes(
|
async fn wait_for_cleanup_on_all_nodes(
|
||||||
@@ -66,8 +81,8 @@ async fn wait_for_cleanup_on_all_nodes(
|
|||||||
let mut all_cleaned = true;
|
let mut all_cleaned = true;
|
||||||
for (idx, client) in clients.iter().enumerate() {
|
for (idx, client) in clients.iter().enumerate() {
|
||||||
let list_parts_missing = list_parts_reports_missing_upload(client, bucket, key, upload_id).await?;
|
let list_parts_missing = list_parts_reports_missing_upload(client, bucket, key, upload_id).await?;
|
||||||
let listing_missing = multipart_listing_reports_missing_upload(client, bucket, key, upload_id).await?;
|
let complete_missing = complete_reports_missing_upload(client, bucket, key, upload_id).await?;
|
||||||
if !(list_parts_missing && listing_missing) {
|
if !(list_parts_missing && complete_missing) {
|
||||||
info!("stale multipart still visible on node {} at attempt {}", idx, attempt + 1);
|
info!("stale multipart still visible on node {} at attempt {}", idx, attempt + 1);
|
||||||
all_cleaned = false;
|
all_cleaned = false;
|
||||||
break;
|
break;
|
||||||
@@ -131,10 +146,6 @@ async fn test_stale_multipart_cleanup_removes_incomplete_upload_across_cluster()
|
|||||||
1,
|
1,
|
||||||
"multipart upload should be visible before background cleanup"
|
"multipart upload should be visible before background cleanup"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
!multipart_listing_reports_missing_upload(&clients[2], CLEANUP_BUCKET, &key, &upload_id).await?,
|
|
||||||
"multipart upload listing should contain the upload before background cleanup"
|
|
||||||
);
|
|
||||||
|
|
||||||
wait_for_cleanup_on_all_nodes(&clients, CLEANUP_BUCKET, &key, &upload_id).await?;
|
wait_for_cleanup_on_all_nodes(&clients, CLEANUP_BUCKET, &key, &upload_id).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,8 @@ use futures::lock::Mutex;
|
|||||||
use metrics::counter;
|
use metrics::counter;
|
||||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||||
use rustfs_io_metrics::internode_metrics::{
|
use rustfs_io_metrics::internode_metrics::{
|
||||||
INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE, INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_DECODE,
|
INTERNODE_STAGE_READ_VERSION_REQUEST_ENCODE, INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE,
|
||||||
INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, INTERNODE_STAGE_READ_VERSION_REQUEST_ENCODE,
|
INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP,
|
||||||
INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE, INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP,
|
|
||||||
};
|
};
|
||||||
use rustfs_protos::ChannelClass;
|
use rustfs_protos::ChannelClass;
|
||||||
use rustfs_protos::evict_failed_connection;
|
use rustfs_protos::evict_failed_connection;
|
||||||
@@ -99,7 +98,6 @@ const NS_SCANNER_CAPABILITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
|||||||
const REMOTE_DISK_READ_RETRY_BASE_BACKOFF: Duration = Duration::from_millis(50);
|
const REMOTE_DISK_READ_RETRY_BASE_BACKOFF: Duration = Duration::from_millis(50);
|
||||||
const ENV_RUSTFS_METADATA_BATCH_READ: &str = "RUSTFS_METADATA_BATCH_READ";
|
const ENV_RUSTFS_METADATA_BATCH_READ: &str = "RUSTFS_METADATA_BATCH_READ";
|
||||||
const LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC: &str = "RUSTFS_BATCH_METADATA_RPC";
|
const LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC: &str = "RUSTFS_BATCH_METADATA_RPC";
|
||||||
const ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE: &str = "RUSTFS_GET_METADATA_READ_VERSION_COALESCE";
|
|
||||||
const BATCH_METADATA_RPC_OFF: &str = "off";
|
const BATCH_METADATA_RPC_OFF: &str = "off";
|
||||||
const BATCH_METADATA_RPC_AUTO: &str = "auto";
|
const BATCH_METADATA_RPC_AUTO: &str = "auto";
|
||||||
const BATCH_METADATA_RPC_ON: &str = "on";
|
const BATCH_METADATA_RPC_ON: &str = "on";
|
||||||
@@ -204,8 +202,7 @@ fn parse_batch_metadata_rpc_mode(raw: &str) -> BatchMetadataRpcMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn batch_metadata_rpc_mode_from_env() -> BatchMetadataRpcMode {
|
fn batch_metadata_rpc_mode_from_env() -> BatchMetadataRpcMode {
|
||||||
rustfs_utils::get_env_opt_str(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE)
|
rustfs_utils::get_env_opt_str(ENV_RUSTFS_METADATA_BATCH_READ)
|
||||||
.or_else(|| rustfs_utils::get_env_opt_str(ENV_RUSTFS_METADATA_BATCH_READ))
|
|
||||||
.or_else(|| rustfs_utils::get_env_opt_str(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC))
|
.or_else(|| rustfs_utils::get_env_opt_str(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC))
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(parse_batch_metadata_rpc_mode)
|
.map(parse_batch_metadata_rpc_mode)
|
||||||
@@ -1829,12 +1826,6 @@ fn record_read_version_stage(stage: &'static str, started_at: Option<Instant>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_batch_read_version_stage(stage: &'static str, started_at: Option<Instant>) {
|
|
||||||
if let Some(started_at) = started_at {
|
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_stage(stage, started_at.elapsed());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregate encoded size (bytes) of a `ReadMultiple` response, preferring the msgpack payloads
|
/// Aggregate encoded size (bytes) of a `ReadMultiple` response, preferring the msgpack payloads
|
||||||
/// and falling back to the JSON compatibility strings. Used to size the RPC for the payload
|
/// and falling back to the JSON compatibility strings. Used to size the RPC for the payload
|
||||||
/// histogram / large-payload alerting (grpc-optimization P0 instrumentation).
|
/// histogram / large-payload alerting (grpc-optimization P0 instrumentation).
|
||||||
@@ -1945,27 +1936,6 @@ fn decode_batch_read_version_response_items(
|
|||||||
Ok(batch_read_version_resps)
|
Ok(batch_read_version_resps)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn batch_read_version_request_payload_len(req: &BatchReadVersionReq, req_json: &str, req_bin: &[u8]) -> usize {
|
|
||||||
req.items
|
|
||||||
.iter()
|
|
||||||
.fold(req_json.len().saturating_add(req_bin.len()), |total, item| {
|
|
||||||
total
|
|
||||||
.saturating_add(item.org_volume.len())
|
|
||||||
.saturating_add(item.volume.len())
|
|
||||||
.saturating_add(item.path.len())
|
|
||||||
.saturating_add(item.version_id.len())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_read_version_response_payload_len(response: &BatchReadVersionResponse) -> usize {
|
|
||||||
response
|
|
||||||
.batch_read_version_resps
|
|
||||||
.iter()
|
|
||||||
.map(String::len)
|
|
||||||
.sum::<usize>()
|
|
||||||
.saturating_add(response.batch_read_version_resps_bin.iter().map(Bytes::len).sum::<usize>())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
|
fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
|
||||||
file_info.validate_for_metadata_read().map_err(Into::into)
|
file_info.validate_for_metadata_read().map_err(Into::into)
|
||||||
}
|
}
|
||||||
@@ -2867,19 +2837,14 @@ impl DiskAPI for RemoteDisk {
|
|||||||
state = "started",
|
state = "started",
|
||||||
"Remote disk RPC started"
|
"Remote disk RPC started"
|
||||||
);
|
);
|
||||||
let batch_read_version_attribution_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
|
||||||
let encode_started = read_version_stage_timer(batch_read_version_attribution_enabled);
|
|
||||||
let batch_read_version_req = compat_json(&req)?;
|
let batch_read_version_req = compat_json(&req)?;
|
||||||
let batch_read_version_req_bin = encode_msgpack(&req)?;
|
let batch_read_version_req_bin = encode_msgpack(&req)?;
|
||||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE, encode_started);
|
|
||||||
let request_payload_bytes = batch_read_version_attribution_enabled
|
|
||||||
.then(|| batch_read_version_request_payload_len(&req, &batch_read_version_req, &batch_read_version_req_bin));
|
|
||||||
let batch_result = self
|
let batch_result = self
|
||||||
.execute_with_timeout_for_op(
|
.execute_with_timeout_for_op(
|
||||||
"batch_read_version",
|
"batch_read_version",
|
||||||
move || async move {
|
move || async move {
|
||||||
let disk = self.disk_ref().await;
|
let disk = self.disk_ref().await;
|
||||||
let disk_len = disk.len();
|
|
||||||
let mut client = self
|
let mut client = self
|
||||||
.get_bulk_client()
|
.get_bulk_client()
|
||||||
.await
|
.await
|
||||||
@@ -2890,20 +2855,9 @@ impl DiskAPI for RemoteDisk {
|
|||||||
batch_read_version_req_bin: batch_read_version_req_bin.into(),
|
batch_read_version_req_bin: batch_read_version_req_bin.into(),
|
||||||
});
|
});
|
||||||
|
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_request();
|
|
||||||
if let Some(request_payload_bytes) = request_payload_bytes {
|
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_sent_bytes(
|
|
||||||
request_payload_bytes.saturating_add(disk_len),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let rpc_started = read_version_stage_timer(batch_read_version_attribution_enabled);
|
|
||||||
let response = match client.batch_read_version(request).await {
|
let response = match client.batch_read_version(request).await {
|
||||||
Ok(response) => {
|
Ok(response) => response.into_inner(),
|
||||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
|
|
||||||
response.into_inner()
|
|
||||||
}
|
|
||||||
Err(status) if status.code() == Code::Unimplemented => {
|
Err(status) if status.code() == Code::Unimplemented => {
|
||||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
|
|
||||||
if mode.should_fallback_on_unimplemented() {
|
if mode.should_fallback_on_unimplemented() {
|
||||||
record_batch_read_version_gate_decision(mode, BATCH_READ_VERSION_GATE_FALLBACK_UNIMPLEMENTED);
|
record_batch_read_version_gate_decision(mode, BATCH_READ_VERSION_GATE_FALLBACK_UNIMPLEMENTED);
|
||||||
warn!(
|
warn!(
|
||||||
@@ -2920,7 +2874,6 @@ impl DiskAPI for RemoteDisk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
record_batch_read_version_gate_decision(mode, BATCH_READ_VERSION_GATE_UNSUPPORTED_NO_FALLBACK);
|
record_batch_read_version_gate_decision(mode, BATCH_READ_VERSION_GATE_UNSUPPORTED_NO_FALLBACK);
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_error();
|
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_REMOTE_DISK_RPC,
|
event = EVENT_REMOTE_DISK_RPC,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -2933,33 +2886,14 @@ impl DiskAPI for RemoteDisk {
|
|||||||
);
|
);
|
||||||
return Err(Error::from(status));
|
return Err(Error::from(status));
|
||||||
}
|
}
|
||||||
Err(status) => {
|
Err(status) => return Err(Error::from(status)),
|
||||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
|
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_error();
|
|
||||||
return Err(Error::from(status));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if !response.success {
|
if !response.success {
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_error();
|
|
||||||
return Err(response.error.unwrap_or_default().into());
|
return Err(response.error.unwrap_or_default().into());
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_recv_bytes(
|
decode_batch_read_version_response_items(response, &self.endpoint).map(Some)
|
||||||
batch_read_version_response_payload_len(&response),
|
|
||||||
);
|
|
||||||
let decode_started = read_version_stage_timer(batch_read_version_attribution_enabled);
|
|
||||||
match decode_batch_read_version_response_items(response, &self.endpoint) {
|
|
||||||
Ok(batch_read_version_resps) => {
|
|
||||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_DECODE, decode_started);
|
|
||||||
Ok(Some(batch_read_version_resps))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_DECODE, decode_started);
|
|
||||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_error();
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
get_max_timeout_duration(),
|
get_max_timeout_duration(),
|
||||||
)
|
)
|
||||||
@@ -4687,7 +4621,6 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
"file version not found".to_string()
|
"file version not found".to_string()
|
||||||
},
|
},
|
||||||
error_code: if success { 0 } else { DiskError::FileVersionNotFound.to_u32() },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4807,7 +4740,6 @@ mod tests {
|
|||||||
fn batch_metadata_rpc_mode_uses_documented_env_before_legacy_alias() {
|
fn batch_metadata_rpc_mode_uses_documented_env_before_legacy_alias() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
[
|
[
|
||||||
(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE, None::<&str>),
|
|
||||||
(ENV_RUSTFS_METADATA_BATCH_READ, Some("auto")),
|
(ENV_RUSTFS_METADATA_BATCH_READ, Some("auto")),
|
||||||
(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC, Some("on")),
|
(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC, Some("on")),
|
||||||
],
|
],
|
||||||
@@ -4817,25 +4749,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_metadata_rpc_mode_uses_get_coalescer_env_before_batch_env() {
|
|
||||||
temp_env::with_vars(
|
|
||||||
[
|
|
||||||
(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE, Some("on")),
|
|
||||||
(ENV_RUSTFS_METADATA_BATCH_READ, Some("off")),
|
|
||||||
(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC, Some("off")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
assert_eq!(batch_metadata_rpc_mode_from_env(), BatchMetadataRpcMode::On);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_metadata_rpc_mode_falls_back_to_legacy_env_alias() {
|
fn batch_metadata_rpc_mode_falls_back_to_legacy_env_alias() {
|
||||||
temp_env::with_vars(
|
temp_env::with_vars(
|
||||||
[
|
[
|
||||||
(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE, None::<&str>),
|
|
||||||
(ENV_RUSTFS_METADATA_BATCH_READ, None::<&str>),
|
(ENV_RUSTFS_METADATA_BATCH_READ, None::<&str>),
|
||||||
(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC, Some("on")),
|
(LEGACY_ENV_RUSTFS_BATCH_METADATA_RPC, Some("on")),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,10 +14,9 @@
|
|||||||
|
|
||||||
use rustfs_io_metrics::internode_metrics::{
|
use rustfs_io_metrics::internode_metrics::{
|
||||||
INTERNODE_MSGPACK_CODEC_JSON, INTERNODE_MSGPACK_CODEC_MSGPACK, INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
INTERNODE_MSGPACK_CODEC_JSON, INTERNODE_MSGPACK_CODEC_MSGPACK, INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
||||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE, INTERNODE_OPERATION_GRPC_READ_VERSION,
|
||||||
INTERNODE_OPERATION_GRPC_READ_VERSION, INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_OPERATION_PUT_FILE_STREAM,
|
INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_OPERATION_PUT_FILE_STREAM, INTERNODE_OPERATION_READ_FILE_STREAM,
|
||||||
INTERNODE_OPERATION_READ_FILE_STREAM, INTERNODE_TRANSPORT_BACKEND_GRPC, INTERNODE_TRANSPORT_BACKEND_TCP_HTTP,
|
INTERNODE_TRANSPORT_BACKEND_GRPC, INTERNODE_TRANSPORT_BACKEND_TCP_HTTP, global_internode_metrics,
|
||||||
global_internode_metrics,
|
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -94,59 +93,6 @@ pub(crate) fn record_remote_disk_grpc_read_version_request() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_request() {
|
|
||||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
global_internode_metrics().record_outgoing_request_for_operation_and_backend(
|
|
||||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
|
|
||||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_stage(stage: &'static str, duration: Duration) {
|
|
||||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
global_internode_metrics().record_stage_duration_for_operation_and_backend(
|
|
||||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
|
|
||||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
|
||||||
stage,
|
|
||||||
duration,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_error() {
|
|
||||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
global_internode_metrics()
|
|
||||||
.record_error_for_operation_and_backend(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_sent_bytes(bytes: usize) {
|
|
||||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
global_internode_metrics().record_sent_bytes_for_operation_and_backend(
|
|
||||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
|
|
||||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
|
||||||
bytes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_recv_bytes(bytes: usize) {
|
|
||||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
global_internode_metrics().record_recv_bytes_for_operation_and_backend(
|
|
||||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
|
|
||||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
|
||||||
bytes,
|
|
||||||
);
|
|
||||||
record_grpc_payload_size(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_remote_disk_grpc_read_version_error() {
|
pub(crate) fn record_remote_disk_grpc_read_version_error() {
|
||||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -248,13 +248,10 @@ impl Config {
|
|||||||
let shard_size = shard_size as usize;
|
let shard_size = shard_size as usize;
|
||||||
// Keep the historical two-data-shard object budget while preventing
|
// Keep the historical two-data-shard object budget while preventing
|
||||||
// wider EC layouts from multiplying the maximum inline object size.
|
// wider EC layouts from multiplying the maximum inline object size.
|
||||||
// Use div_ceil to match the shard_file_size calculation (which also uses
|
|
||||||
// div_ceil), avoiding a 1-byte rounding discrepancy that prevents inline
|
|
||||||
// for objects right at the threshold.
|
|
||||||
let inline_block = if self.initialized && self.inline_block_explicit {
|
let inline_block = if self.initialized && self.inline_block_explicit {
|
||||||
self.inline_block
|
self.inline_block
|
||||||
} else {
|
} else {
|
||||||
DEFAULT_INLINE_OBJECT_BUDGET.div_ceil(data_shards).min(DEFAULT_INLINE_BLOCK)
|
(DEFAULT_INLINE_OBJECT_BUDGET / data_shards).min(DEFAULT_INLINE_BLOCK)
|
||||||
};
|
};
|
||||||
|
|
||||||
if versioned {
|
if versioned {
|
||||||
|
|||||||
+382
-1745
File diff suppressed because it is too large
Load Diff
@@ -988,11 +988,14 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sets {
|
#[async_trait::async_trait]
|
||||||
pub(crate) async fn heal_format_with_fence<F>(&self, dry_run: bool, fence_lost: F) -> Result<(HealResultItem, Option<Error>)>
|
impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||||
where
|
type Error = Error;
|
||||||
F: Fn() -> bool + Send + Sync,
|
type HealResultItem = HealResultItem;
|
||||||
{
|
type HealOptions = HealOpts;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
|
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||||
let (disks, init_errs) = init_storage_disks_with_errors(
|
let (disks, init_errs) = init_storage_disks_with_errors(
|
||||||
&self.endpoints.endpoints,
|
&self.endpoints.endpoints,
|
||||||
&DiskOption {
|
&DiskOption {
|
||||||
@@ -1065,9 +1068,6 @@ impl Sets {
|
|||||||
// Save new formats `format.json` on unformatted disks.
|
// Save new formats `format.json` on unformatted disks.
|
||||||
for (index, (fm, disk)) in tmp_new_formats.iter_mut().zip(disks.iter()).enumerate() {
|
for (index, (fm, disk)) in tmp_new_formats.iter_mut().zip(disks.iter()).enumerate() {
|
||||||
if fm.is_some() && disk.is_some() {
|
if fm.is_some() && disk.is_some() {
|
||||||
if fence_lost() {
|
|
||||||
return Ok((res, Some(StorageError::SlowDown)));
|
|
||||||
}
|
|
||||||
if let Err(err) = save_format_file(disk, fm).await {
|
if let Err(err) = save_format_file(disk, fm).await {
|
||||||
if let Some(disk) = disk.as_ref() {
|
if let Some(disk) = disk.as_ref() {
|
||||||
let _ = disk.close().await;
|
let _ = disk.close().await;
|
||||||
@@ -1101,18 +1101,6 @@ impl Sets {
|
|||||||
}
|
}
|
||||||
Ok((res, None))
|
Ok((res, None))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
|
||||||
type Error = Error;
|
|
||||||
type HealResultItem = HealResultItem;
|
|
||||||
type HealOptions = HealOpts;
|
|
||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
|
||||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
|
||||||
self.heal_format_with_fence(dry_run, || false).await
|
|
||||||
}
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self))]
|
||||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||||
let mut result = HealResultItem {
|
let mut result = HealResultItem {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use crate::storage_api_contracts::{
|
|||||||
namespace::NamespaceLocking as _,
|
namespace::NamespaceLocking as _,
|
||||||
object::{HTTPPreconditions, ObjectOperations as _},
|
object::{HTTPPreconditions, ObjectOperations as _},
|
||||||
};
|
};
|
||||||
use crate::store::{ECStore, ObjectLockDiagGuard, SourceCleanupMutationFence};
|
use crate::store::ECStore;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rustfs_filemeta::{FileInfo, FileInfoVersions, ObjectPartInfo};
|
use rustfs_filemeta::{FileInfo, FileInfoVersions, ObjectPartInfo};
|
||||||
use rustfs_rio::{EtagResolvable, HashReader, HashReaderDetector, Index, TryGetIndex};
|
use rustfs_rio::{EtagResolvable, HashReader, HashReaderDetector, Index, TryGetIndex};
|
||||||
@@ -856,6 +856,7 @@ fn is_equivalent_data_movement_object(source: &ObjectInfo, target: &ObjectInfo)
|
|||||||
fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||||
is_unversioned_data_movement_object(source)
|
is_unversioned_data_movement_object(source)
|
||||||
&& is_unversioned_data_movement_object(target)
|
&& is_unversioned_data_movement_object(target)
|
||||||
|
&& !target.delete_marker
|
||||||
&& source
|
&& source
|
||||||
.mod_time
|
.mod_time
|
||||||
.zip(target.mod_time)
|
.zip(target.mod_time)
|
||||||
@@ -1027,7 +1028,6 @@ pub(crate) enum SourceCleanupError {
|
|||||||
pub(crate) struct SourceCleanupBucketFence<'a> {
|
pub(crate) struct SourceCleanupBucketFence<'a> {
|
||||||
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
|
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
|
||||||
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||||
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_source_cleanup_versions_match(
|
fn ensure_source_cleanup_versions_match(
|
||||||
@@ -1065,9 +1065,7 @@ pub(crate) async fn ensure_source_cleanup_versions_unchanged(
|
|||||||
struct SourceCleanupDeleteBarrierState {
|
struct SourceCleanupDeleteBarrierState {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
object: String,
|
object: String,
|
||||||
fence_pending: tokio::sync::Notify,
|
|
||||||
arrived: tokio::sync::Notify,
|
arrived: tokio::sync::Notify,
|
||||||
is_paused: AtomicBool,
|
|
||||||
release: tokio::sync::Notify,
|
release: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,7 +1079,7 @@ pub(crate) struct SourceCleanupDeleteBarrier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static SOURCE_CLEANUP_DELETE_BARRIERS: std::sync::OnceLock<std::sync::Mutex<Vec<Arc<SourceCleanupDeleteBarrierState>>>> =
|
static SOURCE_CLEANUP_DELETE_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<SourceCleanupDeleteBarrierState>>>> =
|
||||||
std::sync::OnceLock::new();
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1094,22 +1092,15 @@ impl SourceCleanupDeleteBarrier {
|
|||||||
let state = Arc::new(SourceCleanupDeleteBarrierState {
|
let state = Arc::new(SourceCleanupDeleteBarrierState {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
object: object.to_string(),
|
object: object.to_string(),
|
||||||
fence_pending: tokio::sync::Notify::new(),
|
|
||||||
arrived: tokio::sync::Notify::new(),
|
arrived: tokio::sync::Notify::new(),
|
||||||
is_paused: AtomicBool::new(false),
|
|
||||||
release: tokio::sync::Notify::new(),
|
release: tokio::sync::Notify::new(),
|
||||||
});
|
});
|
||||||
let mut barriers = SOURCE_CLEANUP_DELETE_BARRIERS
|
let mut slot = SOURCE_CLEANUP_DELETE_BARRIER
|
||||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
.lock()
|
.lock()
|
||||||
.expect("source cleanup delete barrier mutex should not poison");
|
.expect("source cleanup delete barrier mutex should not poison");
|
||||||
assert!(
|
assert!(slot.is_none(), "source cleanup delete barrier must be unique");
|
||||||
!barriers
|
*slot = Some(Arc::clone(&state));
|
||||||
.iter()
|
|
||||||
.any(|barrier| barrier.bucket == bucket && barrier.object == object),
|
|
||||||
"source cleanup delete barrier must be unique per object"
|
|
||||||
);
|
|
||||||
barriers.push(Arc::clone(&state));
|
|
||||||
Self { state }
|
Self { state }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,58 +1110,35 @@ impl SourceCleanupDeleteBarrier {
|
|||||||
.expect("source cleanup should reach the pre-delete barrier");
|
.expect("source cleanup should reach the pre-delete barrier");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_until_fence_pending(&self) {
|
|
||||||
tokio::time::timeout(StdDuration::from_secs(30), self.state.fence_pending.notified())
|
|
||||||
.await
|
|
||||||
.expect("source cleanup should attempt the fixed mutation fence");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_paused(&self) -> bool {
|
|
||||||
self.state.is_paused.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn release(&self) {
|
pub(crate) fn release(&self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn notify_source_cleanup_mutation_fence_pending(bucket: &str, object: &str) {
|
|
||||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIERS
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
||||||
.lock()
|
|
||||||
.expect("source cleanup delete barrier mutex should not poison")
|
|
||||||
.iter()
|
|
||||||
.find(|barrier| barrier.bucket == bucket && barrier.object == object)
|
|
||||||
.cloned();
|
|
||||||
if let Some(barrier) = barrier {
|
|
||||||
barrier.fence_pending.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl Drop for SourceCleanupDeleteBarrier {
|
impl Drop for SourceCleanupDeleteBarrier {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
let mut barriers = SOURCE_CLEANUP_DELETE_BARRIERS
|
let mut slot = SOURCE_CLEANUP_DELETE_BARRIER
|
||||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
.lock()
|
.lock()
|
||||||
.expect("source cleanup delete barrier mutex should not poison");
|
.expect("source cleanup delete barrier mutex should not poison");
|
||||||
barriers.retain(|state| !Arc::ptr_eq(state, &self.state));
|
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
|
async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
|
||||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIERS
|
let barrier = SOURCE_CLEANUP_DELETE_BARRIER
|
||||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
.lock()
|
.lock()
|
||||||
.expect("source cleanup delete barrier mutex should not poison")
|
.expect("source cleanup delete barrier mutex should not poison")
|
||||||
.iter()
|
.as_ref()
|
||||||
.find(|barrier| barrier.bucket == bucket && barrier.object == object)
|
.filter(|barrier| barrier.bucket == bucket && barrier.object == object)
|
||||||
.cloned();
|
.cloned();
|
||||||
if let Some(barrier) = barrier {
|
if let Some(barrier) = barrier {
|
||||||
barrier.is_paused.store(true, Ordering::Release);
|
|
||||||
barrier.arrived.notify_one();
|
barrier.arrived.notify_one();
|
||||||
barrier.release.notified().await;
|
barrier.release.notified().await;
|
||||||
}
|
}
|
||||||
@@ -1186,20 +1154,11 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
|||||||
op_label: &str,
|
op_label: &str,
|
||||||
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
|
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
|
||||||
let cleanup_key = encode_dir_object(object);
|
let cleanup_key = encode_dir_object(object);
|
||||||
let source_guard = if bucket_fence
|
|
||||||
.object_mutation_fence
|
|
||||||
.is_some_and(SourceCleanupMutationFence::source_lock_covered)
|
|
||||||
{
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let ns_lock = set.new_ns_lock(bucket, cleanup_key.as_str()).await?;
|
let ns_lock = set.new_ns_lock(bucket, cleanup_key.as_str()).await?;
|
||||||
Some(
|
let _guard = ns_lock
|
||||||
ns_lock
|
|
||||||
.get_write_lock(get_lock_acquire_timeout())
|
.get_write_lock(get_lock_acquire_timeout())
|
||||||
.await
|
.await
|
||||||
.map_err(Error::from)?,
|
.map_err(Error::from)?;
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if bucket_fence
|
if bucket_fence
|
||||||
.lifecycle_guard
|
.lifecycle_guard
|
||||||
@@ -1209,14 +1168,6 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
|||||||
"{op_label}: bucket incarnation fence was lost before source cleanup"
|
"{op_label}: bucket incarnation fence was lost before source cleanup"
|
||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
if bucket_fence
|
|
||||||
.object_mutation_fence
|
|
||||||
.is_some_and(SourceCleanupMutationFence::is_lock_lost)
|
|
||||||
{
|
|
||||||
return Err(SourceCleanupError::Storage(Error::other(format!(
|
|
||||||
"{op_label}: object mutation fence was lost before source cleanup"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_source_cleanup_versions_unchanged(set.clone(), bucket, object, expected, allowed_missing, op_label).await?;
|
ensure_source_cleanup_versions_unchanged(set.clone(), bucket, object, expected, allowed_missing, op_label).await?;
|
||||||
|
|
||||||
@@ -1231,12 +1182,7 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
|||||||
expected_bucket_incarnation_id: bucket_fence.expected_incarnation_id,
|
expected_bucket_incarnation_id: bucket_fence.expected_incarnation_id,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
if let Some(source_guard) = source_guard.as_ref() {
|
opts.add_namespace_lock_guard(&_guard);
|
||||||
opts.add_namespace_lock_guard(source_guard);
|
|
||||||
}
|
|
||||||
if let Some(object_mutation_fence) = bucket_fence.object_mutation_fence {
|
|
||||||
object_mutation_fence.add_namespace_lock_fence(&mut opts);
|
|
||||||
}
|
|
||||||
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
|
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
|
||||||
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
|
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
|
||||||
}
|
}
|
||||||
@@ -1384,37 +1330,6 @@ fn data_movement_part_upload_failure_stage(err: &Error) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn migrate_decommission_object(
|
|
||||||
store: Arc<ECStore>,
|
|
||||||
pool_idx: usize,
|
|
||||||
bucket: String,
|
|
||||||
rd: GetObjectReader,
|
|
||||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
||||||
op_label: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let source = rd.object_info.clone();
|
|
||||||
let _mutation_fence = store
|
|
||||||
.acquire_decommission_object_mutation_fence(&bucket, &source.name)
|
|
||||||
.await?;
|
|
||||||
let current = find_data_movement_target_info(store.as_ref(), pool_idx, &bucket, &source)
|
|
||||||
.await?
|
|
||||||
.ok_or(Error::FileNotFound)?;
|
|
||||||
if !is_equivalent_data_movement_object_identity(&source, ¤t, true, false) {
|
|
||||||
return Err(Error::FileNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate_object_inner(
|
|
||||||
store,
|
|
||||||
pool_idx,
|
|
||||||
bucket,
|
|
||||||
rd,
|
|
||||||
source_bucket_incarnation_id,
|
|
||||||
op_label,
|
|
||||||
Some(&_mutation_fence),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn migrate_object(
|
pub(crate) async fn migrate_object(
|
||||||
store: Arc<ECStore>,
|
store: Arc<ECStore>,
|
||||||
pool_idx: usize,
|
pool_idx: usize,
|
||||||
@@ -1422,18 +1337,6 @@ pub(crate) async fn migrate_object(
|
|||||||
rd: GetObjectReader,
|
rd: GetObjectReader,
|
||||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||||
op_label: &str,
|
op_label: &str,
|
||||||
) -> Result<()> {
|
|
||||||
migrate_object_inner(store, pool_idx, bucket, rd, source_bucket_incarnation_id, op_label, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn migrate_object_inner(
|
|
||||||
store: Arc<ECStore>,
|
|
||||||
pool_idx: usize,
|
|
||||||
bucket: String,
|
|
||||||
rd: GetObjectReader,
|
|
||||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
|
||||||
op_label: &str,
|
|
||||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let object_info = rd.object_info.clone();
|
let object_info = rd.object_info.clone();
|
||||||
let has_part_checksums = object_info
|
let has_part_checksums = object_info
|
||||||
@@ -1447,7 +1350,7 @@ async fn migrate_object_inner(
|
|||||||
let mut new_multipart_opts = data_movement_new_multipart_opts(&object_info, pool_idx);
|
let mut new_multipart_opts = data_movement_new_multipart_opts(&object_info, pool_idx);
|
||||||
new_multipart_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
new_multipart_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
||||||
let (res, target_pool_idx, expected_bucket_incarnation_id) = match store
|
let (res, target_pool_idx, expected_bucket_incarnation_id) = match store
|
||||||
.handle_new_multipart_upload_with_pool_idx(&bucket, &object_info.name, &new_multipart_opts, mutation_fence)
|
.handle_new_multipart_upload_with_pool_idx(&bucket, &object_info.name, &new_multipart_opts)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(res) => res,
|
Ok(res) => res,
|
||||||
@@ -1545,7 +1448,7 @@ async fn migrate_object_inner(
|
|||||||
if let Err(err) = store
|
if let Err(err) = store
|
||||||
.clone()
|
.clone()
|
||||||
.complete_multipart_upload_for_data_movement(
|
.complete_multipart_upload_for_data_movement(
|
||||||
(target_pool_idx, mutation_fence),
|
target_pool_idx,
|
||||||
&bucket,
|
&bucket,
|
||||||
&object_info.name,
|
&object_info.name,
|
||||||
&res.upload_id,
|
&res.upload_id,
|
||||||
@@ -1706,7 +1609,7 @@ async fn migrate_object_inner(
|
|||||||
let mut put_opts = data_movement_put_object_opts(&object_info, pool_idx);
|
let mut put_opts = data_movement_put_object_opts(&object_info, pool_idx);
|
||||||
put_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
put_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
||||||
let (target_pool_idx, put_result) = store
|
let (target_pool_idx, put_result) = store
|
||||||
.put_object_for_data_movement(&bucket, &object_info.name, &mut data, &put_opts, mutation_fence)
|
.put_object_for_data_movement(&bucket, &object_info.name, &mut data, &put_opts)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| data_movement_stage_error(op_label, "prepare_put_object", &bucket, &object_info.name, err))?;
|
.map_err(|err| data_movement_stage_error(op_label, "prepare_put_object", &bucket, &object_info.name, err))?;
|
||||||
if let Err(err) = put_result {
|
if let Err(err) = put_result {
|
||||||
@@ -3638,10 +3541,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_precondition_conflict_accepts_only_newer_null_delete_marker() {
|
fn test_precondition_conflict_rejects_newer_delete_marker() {
|
||||||
for version_id in [None, Some(Uuid::nil())] {
|
|
||||||
let source = ObjectInfo {
|
let source = ObjectInfo {
|
||||||
version_id,
|
|
||||||
size: 128,
|
size: 128,
|
||||||
etag: Some("etag-source".to_string()),
|
etag: Some("etag-source".to_string()),
|
||||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
@@ -3654,31 +3555,11 @@ mod tests {
|
|||||||
..source.clone()
|
..source.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(
|
let should_resume =
|
||||||
resolve_data_movement_overwrite_resume_result(
|
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||||
&Error::PreconditionFailed,
|
.expect("delete marker conflict should be evaluated");
|
||||||
Ok(Some(target.clone())),
|
|
||||||
&source,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.expect("newer null delete marker should be evaluated")
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut same_time = target.clone();
|
assert!(!should_resume);
|
||||||
same_time.mod_time = source.mod_time;
|
|
||||||
assert!(
|
|
||||||
!resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(same_time)), &source, 0, 1,)
|
|
||||||
.expect("same-generation null delete marker should be rejected")
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut versioned = target;
|
|
||||||
versioned.version_id = Some(Uuid::new_v4());
|
|
||||||
assert!(
|
|
||||||
!resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(versioned)), &source, 0, 1,)
|
|
||||||
.expect("a UUID delete marker must not erase a null source version")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -858,7 +858,6 @@ const EVENT_DISK_LOCAL_DIRECT_IO_FALLBACK: &str = "disk_local_direct_io_fallback
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
const EVENT_DISK_LOCAL_URING_LATCH_OFF: &str = "disk_local_uring_latch_off";
|
const EVENT_DISK_LOCAL_URING_LATCH_OFF: &str = "disk_local_uring_latch_off";
|
||||||
const EVENT_DISK_LOCAL_DELETE_FAILED: &str = "disk_local_delete_failed";
|
const EVENT_DISK_LOCAL_DELETE_FAILED: &str = "disk_local_delete_failed";
|
||||||
const EVENT_DISK_LOCAL_DELETE_ROLLBACK_FAILED: &str = "disk_local_delete_rollback_failed";
|
|
||||||
const EVENT_DISK_LOCAL_CHECK_PARTS: &str = "disk_local_check_parts";
|
const EVENT_DISK_LOCAL_CHECK_PARTS: &str = "disk_local_check_parts";
|
||||||
const EVENT_DISK_LOCAL_ACCESS_FAILED: &str = "disk_local_access_failed";
|
const EVENT_DISK_LOCAL_ACCESS_FAILED: &str = "disk_local_access_failed";
|
||||||
const EVENT_DISK_LOCAL_VOLUME_SETUP_FAILED: &str = "disk_local_volume_setup_failed";
|
const EVENT_DISK_LOCAL_VOLUME_SETUP_FAILED: &str = "disk_local_volume_setup_failed";
|
||||||
@@ -6107,43 +6106,6 @@ impl LocalDisk {
|
|||||||
Ok((bytes, modtime))
|
Ok((bytes, modtime))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_missing_delete_marker(
|
|
||||||
&self,
|
|
||||||
volume: &str,
|
|
||||||
path: &str,
|
|
||||||
fi: FileInfo,
|
|
||||||
object_dir: &Path,
|
|
||||||
xl_path: &Path,
|
|
||||||
rollback_dir: Option<Uuid>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if let Some(rollback_dir) = rollback_dir {
|
|
||||||
let rollback_path = object_dir.join(rollback_dir.to_string());
|
|
||||||
fs::create_dir_all(&rollback_path).await.map_err(to_file_error)?;
|
|
||||||
fs::write(rollback_path.join(DELETE_MARKER_ROLLBACK_FILE), [])
|
|
||||||
.await
|
|
||||||
.map_err(to_file_error)?;
|
|
||||||
}
|
|
||||||
if let Err(err) = self.write_metadata("", volume, path, fi).await {
|
|
||||||
if let Some(rollback_dir) = rollback_dir
|
|
||||||
&& let Err(restore_err) = restore_delete_rollback(object_dir, xl_path, rollback_dir, &self.publication_root).await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
event = EVENT_DISK_LOCAL_DELETE_ROLLBACK_FAILED,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
|
||||||
result = "failed",
|
|
||||||
volume,
|
|
||||||
path,
|
|
||||||
rollback_dir = %rollback_dir,
|
|
||||||
error = ?restore_err,
|
|
||||||
"Disk local delete rollback failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_versions_internal(&self, volume: &str, path: &str, fis: &[FileInfo], opts: &DeleteOptions) -> Result<()> {
|
async fn delete_versions_internal(&self, volume: &str, path: &str, fis: &[FileInfo], opts: &DeleteOptions) -> Result<()> {
|
||||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||||
let xlpath = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
let xlpath = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
||||||
@@ -6161,20 +6123,7 @@ impl LocalDisk {
|
|||||||
return restore_metadata_backup(object_dir, &xlpath, rollback_dir, &self.publication_root).await;
|
return restore_metadata_backup(object_dir, &xlpath, rollback_dir, &self.publication_root).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (data, _) = match self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await {
|
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await?;
|
||||||
Ok(data) => data,
|
|
||||||
Err(DiskError::FileNotFound) => {
|
|
||||||
// `deleted` alone can be an explicit marker purge; only
|
|
||||||
// `mark_deleted` may create metadata that was not present.
|
|
||||||
let Some(delete_marker) = fis.iter().find(|fi| fi.deleted && fi.mark_deleted).cloned() else {
|
|
||||||
return Err(DiskError::FileNotFound);
|
|
||||||
};
|
|
||||||
return self
|
|
||||||
.write_missing_delete_marker(volume, path, delete_marker, object_dir, &xlpath, opts.old_data_dir)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
return Err(DiskError::FileNotFound);
|
return Err(DiskError::FileNotFound);
|
||||||
@@ -10473,9 +10422,29 @@ impl DiskAPI for LocalDisk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fi.deleted && force_del_marker {
|
if fi.deleted && force_del_marker {
|
||||||
return self
|
if let Some(rollback_dir) = rollback_dir {
|
||||||
.write_missing_delete_marker(volume, path, fi, file_path.as_path(), &xl_path, rollback_dir)
|
let rollback_path = file_path.join(rollback_dir.to_string());
|
||||||
.await;
|
fs::create_dir_all(&rollback_path).await.map_err(to_file_error)?;
|
||||||
|
fs::write(rollback_path.join(DELETE_MARKER_ROLLBACK_FILE), [])
|
||||||
|
.await
|
||||||
|
.map_err(to_file_error)?;
|
||||||
|
}
|
||||||
|
if let Err(err) = self.write_metadata("", volume, path, fi).await {
|
||||||
|
if let Some(rollback_dir) = rollback_dir
|
||||||
|
&& let Err(restore_err) =
|
||||||
|
restore_delete_rollback(file_path.as_path(), &xl_path, rollback_dir, &self.publication_root).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
volume,
|
||||||
|
path,
|
||||||
|
rollback_dir = %rollback_dir,
|
||||||
|
error = ?restore_err,
|
||||||
|
"failed to restore metadata after delete marker commit error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
return if fi.version_id.is_some() {
|
return if fi.version_id.is_some() {
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ pub const PART_TRANSACTION_ROLLBACK: &str = "rollback";
|
|||||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||||
const LOG_SUBSYSTEM_DISK: &str = "disk";
|
const LOG_SUBSYSTEM_DISK: &str = "disk";
|
||||||
const EVENT_DISK_PART_ERR_UNCLASSIFIED: &str = "disk_part_err_unclassified";
|
const EVENT_DISK_PART_ERR_UNCLASSIFIED: &str = "disk_part_err_unclassified";
|
||||||
const ENV_BATCH_READ_VERSION_SERVER_PARALLELISM: &str = "RUSTFS_BATCH_READ_VERSION_SERVER_PARALLELISM";
|
|
||||||
const BATCH_READ_VERSION_SERVER_PARALLELISM: usize = 4;
|
|
||||||
|
|
||||||
pub fn part_transaction_path(part_path: &str) -> String {
|
pub fn part_transaction_path(part_path: &str) -> String {
|
||||||
match part_path.rsplit_once('/') {
|
match part_path.rsplit_once('/') {
|
||||||
@@ -64,7 +62,6 @@ use bytes::Bytes;
|
|||||||
use endpoint::Endpoint;
|
use endpoint::Endpoint;
|
||||||
use error::DiskError;
|
use error::DiskError;
|
||||||
use error::{Error, Result};
|
use error::{Error, Result};
|
||||||
use futures::stream::{self, StreamExt};
|
|
||||||
use local::LocalDisk;
|
use local::LocalDisk;
|
||||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||||
use rustfs_madmin::info_commands::DiskMetrics;
|
use rustfs_madmin::info_commands::DiskMetrics;
|
||||||
@@ -420,14 +417,6 @@ impl DiskAPI for Disk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
|
||||||
async fn batch_read_version(&self, req: BatchReadVersionReq) -> Result<Vec<BatchReadVersionResp>> {
|
|
||||||
match self {
|
|
||||||
Disk::Local(local_disk) => local_disk.batch_read_version(req).await,
|
|
||||||
Disk::Remote(remote_disk) => remote_disk.batch_read_version(req).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
async fn read_xl(&self, volume: &str, path: &str, read_data: bool) -> Result<RawFileInfo> {
|
async fn read_xl(&self, volume: &str, path: &str, read_data: bool) -> Result<RawFileInfo> {
|
||||||
match self {
|
match self {
|
||||||
@@ -1039,47 +1028,36 @@ where
|
|||||||
D: DiskAPI + ?Sized,
|
D: DiskAPI + ?Sized,
|
||||||
{
|
{
|
||||||
validate_batch_read_version_item_count(req.items.len())?;
|
validate_batch_read_version_item_count(req.items.len())?;
|
||||||
let parallelism = batch_read_version_server_parallelism();
|
|
||||||
|
|
||||||
let mut responses = stream::iter(req.items.into_iter().enumerate())
|
let mut responses = Vec::with_capacity(req.items.len());
|
||||||
.map(|(index, item)| async move {
|
for (index, item) in req.items.iter().enumerate() {
|
||||||
match disk
|
let response = match disk
|
||||||
.read_version(&item.org_volume, &item.volume, &item.path, &item.version_id, &req.opts)
|
.read_version(&item.org_volume, &item.volume, &item.path, &item.version_id, &req.opts)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(file_info) => BatchReadVersionResp {
|
Ok(file_info) => BatchReadVersionResp {
|
||||||
index,
|
index,
|
||||||
path: item.path,
|
path: item.path.clone(),
|
||||||
version_id: item.version_id,
|
version_id: item.version_id.clone(),
|
||||||
success: true,
|
success: true,
|
||||||
file_info,
|
file_info,
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
error_code: 0,
|
|
||||||
},
|
},
|
||||||
Err(err) => BatchReadVersionResp {
|
Err(err) => BatchReadVersionResp {
|
||||||
index,
|
index,
|
||||||
path: item.path,
|
path: item.path.clone(),
|
||||||
version_id: item.version_id,
|
version_id: item.version_id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
file_info: FileInfo::default(),
|
file_info: FileInfo::default(),
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
error_code: err.to_u32(),
|
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
responses.push(response);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.buffer_unordered(parallelism)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.await;
|
|
||||||
responses.sort_unstable_by_key(|response| response.index);
|
|
||||||
|
|
||||||
Ok(responses)
|
Ok(responses)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn batch_read_version_server_parallelism() -> usize {
|
|
||||||
rustfs_utils::get_env_usize(ENV_BATCH_READ_VERSION_SERVER_PARALLELISM, BATCH_READ_VERSION_SERVER_PARALLELISM)
|
|
||||||
.clamp(1, BATCH_READ_VERSION_MAX_ITEMS)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct CheckPartsResp {
|
pub struct CheckPartsResp {
|
||||||
pub results: Vec<usize>,
|
pub results: Vec<usize>,
|
||||||
@@ -1344,8 +1322,6 @@ pub struct BatchReadVersionResp {
|
|||||||
pub success: bool,
|
pub success: bool,
|
||||||
pub file_info: FileInfo,
|
pub file_info: FileInfo,
|
||||||
pub error: String,
|
pub error: String,
|
||||||
#[serde(default)]
|
|
||||||
pub error_code: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_batch_read_version_item_count(item_count: usize) -> Result<()> {
|
pub fn validate_batch_read_version_item_count(item_count: usize) -> Result<()> {
|
||||||
@@ -1441,26 +1417,6 @@ mod tests {
|
|||||||
assert!(!partial_valid_location.valid());
|
assert!(!partial_valid_location.valid());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_read_version_server_parallelism_defaults_to_conservative_four() {
|
|
||||||
temp_env::with_var(ENV_BATCH_READ_VERSION_SERVER_PARALLELISM, None::<&str>, || {
|
|
||||||
assert_eq!(batch_read_version_server_parallelism(), 4);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_read_version_server_parallelism_honors_env_with_bounds() {
|
|
||||||
temp_env::with_var(ENV_BATCH_READ_VERSION_SERVER_PARALLELISM, Some("8"), || {
|
|
||||||
assert_eq!(batch_read_version_server_parallelism(), 8);
|
|
||||||
});
|
|
||||||
temp_env::with_var(ENV_BATCH_READ_VERSION_SERVER_PARALLELISM, Some("0"), || {
|
|
||||||
assert_eq!(batch_read_version_server_parallelism(), 1);
|
|
||||||
});
|
|
||||||
temp_env::with_var(ENV_BATCH_READ_VERSION_SERVER_PARALLELISM, Some("9999"), || {
|
|
||||||
assert_eq!(batch_read_version_server_parallelism(), BATCH_READ_VERSION_MAX_ITEMS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test FileInfoVersions find_version_index
|
/// Test FileInfoVersions find_version_index
|
||||||
#[test]
|
#[test]
|
||||||
fn test_file_info_versions_find_version_index() {
|
fn test_file_info_versions_find_version_index() {
|
||||||
|
|||||||
@@ -81,14 +81,6 @@ pub fn shutdown_background_monitors() {
|
|||||||
cluster::rpc::shutdown_background_monitors();
|
cluster::rpc::shutdown_background_monitors();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish that the process is ready to serve user-object GET traffic.
|
|
||||||
///
|
|
||||||
/// Experimental metadata coalescing is allowed to run only after this point so
|
|
||||||
/// startup and internal metadata reads keep the original per-disk path.
|
|
||||||
pub fn mark_get_metadata_read_version_coalescing_service_ready() {
|
|
||||||
runtime::global::mark_get_metadata_read_version_coalescing_service_ready();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod rio_tests {
|
mod rio_tests {
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::storage_api_contracts::{
|
|||||||
pub struct NamespaceLockFence {
|
pub struct NamespaceLockFence {
|
||||||
signals: Arc<Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
signals: Arc<Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
forced_lost: Arc<Vec<Arc<std::sync::atomic::AtomicBool>>>,
|
forced_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Debug for NamespaceLockFence {
|
impl Debug for NamespaceLockFence {
|
||||||
@@ -40,17 +40,13 @@ impl NamespaceLockFence {
|
|||||||
Self {
|
Self {
|
||||||
signals: Arc::default(),
|
signals: Arc::default(),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
forced_lost: Arc::new(vec![Arc::new(std::sync::atomic::AtomicBool::new(false))]),
|
forced_lost: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if self
|
if self.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
.forced_lost
|
|
||||||
.iter()
|
|
||||||
.any(|lost| lost.load(std::sync::atomic::Ordering::Acquire))
|
|
||||||
{
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
self.signals.iter().any(|signal| signal.is_lost())
|
self.signals.iter().any(|signal| signal.is_lost())
|
||||||
@@ -61,26 +57,27 @@ impl NamespaceLockFence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extend(&mut self, other: &Self) {
|
fn extend(&mut self, other: &Self) {
|
||||||
if !Arc::ptr_eq(&self.signals, &other.signals) {
|
if Arc::ptr_eq(&self.signals, &other.signals) {
|
||||||
Arc::make_mut(&mut self.signals).extend(other.signals.iter().cloned());
|
return;
|
||||||
}
|
}
|
||||||
|
Arc::make_mut(&mut self.signals).extend(other.signals.iter().cloned());
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if !Arc::ptr_eq(&self.forced_lost, &other.forced_lost) {
|
if other.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
Arc::make_mut(&mut self.forced_lost).extend(other.forced_lost.iter().cloned());
|
self.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn lost_for_test() -> Self {
|
pub(crate) fn lost_for_test() -> Self {
|
||||||
let fence = Self::new();
|
let fence = Self::new();
|
||||||
fence.forced_lost[0].store(true, std::sync::atomic::Ordering::Release);
|
fence.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||||
fence
|
fence
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn loss_handle_for_test() -> (Self, Arc<std::sync::atomic::AtomicBool>) {
|
pub(crate) fn loss_handle_for_test() -> (Self, Arc<std::sync::atomic::AtomicBool>) {
|
||||||
let fence = Self::new();
|
let fence = Self::new();
|
||||||
(fence.clone(), Arc::clone(&fence.forced_lost[0]))
|
(fence.clone(), Arc::clone(&fence.forced_lost))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,13 +411,6 @@ impl ObjectOptions {
|
|||||||
self.namespace_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
self.namespace_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn add_namespace_lock_fence_for_test(&mut self, fence: &NamespaceLockFence) {
|
|
||||||
self.namespace_lock_fence
|
|
||||||
.get_or_insert_with(NamespaceLockFence::new)
|
|
||||||
.extend(fence);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn ensure_lifecycle_delete_all_journal(&mut self) {
|
pub(crate) fn ensure_lifecycle_delete_all_journal(&mut self) {
|
||||||
self.lifecycle_delete_all_journal
|
self.lifecycle_delete_all_journal
|
||||||
.get_or_insert_with(|| Arc::new(parking_lot::Mutex::new(LifecycleDeleteAllJournalState::default())));
|
.get_or_insert_with(|| Arc::new(parking_lot::Mutex::new(LifecycleDeleteAllJournalState::default())));
|
||||||
|
|||||||
@@ -25,10 +25,7 @@ use lazy_static::lazy_static;
|
|||||||
use rustfs_lock::client::LockClient;
|
use rustfs_lock::client::LockClient;
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{
|
sync::{Arc, OnceLock},
|
||||||
Arc, OnceLock,
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
},
|
|
||||||
time::SystemTime,
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
use tokio::sync::{OnceCell, RwLock};
|
use tokio::sync::{OnceCell, RwLock};
|
||||||
@@ -40,16 +37,6 @@ pub const DISK_MIN_INODES: u64 = 1000;
|
|||||||
pub const DISK_FILL_FRACTION: f64 = 0.99;
|
pub const DISK_FILL_FRACTION: f64 = 0.99;
|
||||||
pub const DISK_RESERVE_FRACTION: f64 = 0.15;
|
pub const DISK_RESERVE_FRACTION: f64 = 0.15;
|
||||||
|
|
||||||
static GET_METADATA_READ_VERSION_COALESCING_SERVICE_READY: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
pub(crate) fn mark_get_metadata_read_version_coalescing_service_ready() {
|
|
||||||
GET_METADATA_READ_VERSION_COALESCING_SERVICE_READY.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get_metadata_read_version_coalescing_service_ready() -> bool {
|
|
||||||
GET_METADATA_READ_VERSION_COALESCING_SERVICE_READY.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global singletons for backward compatibility with MinIO port.
|
// Global singletons for backward compatibility with MinIO port.
|
||||||
// These should be migrated to AppContext over time.
|
// These should be migrated to AppContext over time.
|
||||||
// See issue #730 for migration plan.
|
// See issue #730 for migration plan.
|
||||||
|
|||||||
@@ -160,10 +160,6 @@ pub struct InstanceContext {
|
|||||||
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
||||||
/// Replaces the process-global cancel-token static.
|
/// Replaces the process-global cancel-token static.
|
||||||
background_cancel_token: OnceLock<CancellationToken>,
|
background_cancel_token: OnceLock<CancellationToken>,
|
||||||
/// Serializes decommission data-movement operations with cancellation and
|
|
||||||
/// a subsequent restart. Readers are held across one object side effect;
|
|
||||||
/// the transition path takes the writer after cancelling the routine.
|
|
||||||
decommission_operation_gate: Arc<RwLock<()>>,
|
|
||||||
/// Resolves object-encryption material at the application boundary.
|
/// Resolves object-encryption material at the application boundary.
|
||||||
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
@@ -204,7 +200,6 @@ impl InstanceContext {
|
|||||||
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
||||||
bucket_metadata_sys: std::sync::Mutex::new(None),
|
bucket_metadata_sys: std::sync::Mutex::new(None),
|
||||||
background_cancel_token: OnceLock::new(),
|
background_cancel_token: OnceLock::new(),
|
||||||
decommission_operation_gate: Arc::new(RwLock::new(())),
|
|
||||||
object_encryption_resolver: OnceLock::new(),
|
object_encryption_resolver: OnceLock::new(),
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
@@ -223,10 +218,6 @@ impl InstanceContext {
|
|||||||
self.lock_manager.clone()
|
self.lock_manager.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn decommission_operation_gate(&self) -> Arc<RwLock<()>> {
|
|
||||||
Arc::clone(&self.decommission_operation_gate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Install the application-owned object-encryption resolver once.
|
/// Install the application-owned object-encryption resolver once.
|
||||||
pub fn set_object_encryption_resolver(
|
pub fn set_object_encryption_resolver(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -256,10 +256,6 @@ fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsRepo
|
|||||||
cycle_max_duration_seconds: metrics.cycle_max_duration_seconds,
|
cycle_max_duration_seconds: metrics.cycle_max_duration_seconds,
|
||||||
cycle_max_objects: metrics.cycle_max_objects,
|
cycle_max_objects: metrics.cycle_max_objects,
|
||||||
cycle_max_directories: metrics.cycle_max_directories,
|
cycle_max_directories: metrics.cycle_max_directories,
|
||||||
cycle_timeout_total: metrics.cycle_timeout_total,
|
|
||||||
cycle_recovery_required_total: metrics.cycle_recovery_required_total,
|
|
||||||
cycle_last_progress_age: metrics.cycle_last_progress_age,
|
|
||||||
leader_lease_without_progress: metrics.leader_lease_without_progress,
|
|
||||||
bitrot_cycle_enabled: metrics.bitrot_cycle_enabled,
|
bitrot_cycle_enabled: metrics.bitrot_cycle_enabled,
|
||||||
bitrot_cycle_seconds: metrics.bitrot_cycle_seconds,
|
bitrot_cycle_seconds: metrics.bitrot_cycle_seconds,
|
||||||
scan_checkpoint: metrics.scan_checkpoint.map(|checkpoint| MadminScannerCheckpointReport {
|
scan_checkpoint: metrics.scan_checkpoint.map(|checkpoint| MadminScannerCheckpointReport {
|
||||||
@@ -615,10 +611,6 @@ mod test {
|
|||||||
current_started: chrono_to_jiff_timestamp(current_started),
|
current_started: chrono_to_jiff_timestamp(current_started),
|
||||||
last_cycle_partial_source: "usage".to_string(),
|
last_cycle_partial_source: "usage".to_string(),
|
||||||
last_cycle_partial_source_code: 1,
|
last_cycle_partial_source_code: 1,
|
||||||
cycle_timeout_total: 3,
|
|
||||||
cycle_recovery_required_total: 2,
|
|
||||||
cycle_last_progress_age: 17,
|
|
||||||
leader_lease_without_progress: true,
|
|
||||||
partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot {
|
partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot {
|
||||||
source: "usage".to_string(),
|
source: "usage".to_string(),
|
||||||
cycles: 2,
|
cycles: 2,
|
||||||
@@ -630,10 +622,6 @@ mod test {
|
|||||||
assert_eq!(scanner.current_started, chrono_to_jiff_timestamp(current_started));
|
assert_eq!(scanner.current_started, chrono_to_jiff_timestamp(current_started));
|
||||||
assert_eq!(scanner.last_cycle_partial_source, "usage");
|
assert_eq!(scanner.last_cycle_partial_source, "usage");
|
||||||
assert_eq!(scanner.last_cycle_partial_source_code, 1);
|
assert_eq!(scanner.last_cycle_partial_source_code, 1);
|
||||||
assert_eq!(scanner.cycle_timeout_total, 3);
|
|
||||||
assert_eq!(scanner.cycle_recovery_required_total, 2);
|
|
||||||
assert_eq!(scanner.cycle_last_progress_age, 17);
|
|
||||||
assert!(scanner.leader_lease_without_progress);
|
|
||||||
let usage = scanner
|
let usage = scanner
|
||||||
.partial_cycles_by_source
|
.partial_cycles_by_source
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ impl ECStore {
|
|||||||
lifecycle_guard: bucket_incarnation_fence
|
lifecycle_guard: bucket_incarnation_fence
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|guard| guard.namespace_lock_guard()),
|
.and_then(|guard| guard.namespace_lock_guard()),
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
"rebalance",
|
"rebalance",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,12 +53,11 @@ use crate::diagnostics::get::{
|
|||||||
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
|
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
|
||||||
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
|
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
|
||||||
};
|
};
|
||||||
use crate::disk::disk_store::{DiskStoreRenameDataExt, get_drive_metadata_timeout};
|
use crate::disk::disk_store::DiskStoreRenameDataExt;
|
||||||
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
|
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
|
||||||
use crate::disk::{
|
use crate::disk::{
|
||||||
BATCH_READ_VERSION_MAX_ITEMS, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp, DataDirDeleteStatus, Disk,
|
DataDirDeleteStatus, OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK,
|
||||||
OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction,
|
PartTransactionAction, STORAGE_FORMAT_FILE_BACKUP, part_transaction_path,
|
||||||
STORAGE_FORMAT_FILE_BACKUP, part_transaction_path,
|
|
||||||
};
|
};
|
||||||
use crate::erasure::coding::BitrotReader;
|
use crate::erasure::coding::BitrotReader;
|
||||||
use crate::io_support::bitrot::ShardReader;
|
use crate::io_support::bitrot::ShardReader;
|
||||||
@@ -76,7 +75,7 @@ use std::{
|
|||||||
future::Future,
|
future::Future,
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::{
|
sync::{
|
||||||
Arc, OnceLock,
|
OnceLock,
|
||||||
atomic::{AtomicUsize, Ordering},
|
atomic::{AtomicUsize, Ordering},
|
||||||
},
|
},
|
||||||
task::{Context, Poll},
|
task::{Context, Poll},
|
||||||
@@ -95,242 +94,6 @@ fn metadata_distribution_key(bucket: &str, object: &str) -> String {
|
|||||||
[bucket, object].join("/")
|
[bucket, object].join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_version_coalescing_enabled() -> bool {
|
|
||||||
let enabled = || {
|
|
||||||
rustfs_utils::get_env_opt_str(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE)
|
|
||||||
.is_some_and(|value| value.eq_ignore_ascii_case("auto") || value.eq_ignore_ascii_case("on"))
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
enabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
{
|
|
||||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
|
||||||
*ENABLED.get_or_init(enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_version_coalescing_delay() -> Duration {
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
let micros = rustfs_utils::get_env_u64(
|
|
||||||
ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS,
|
|
||||||
DEFAULT_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS,
|
|
||||||
);
|
|
||||||
Duration::from_micros(micros)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
{
|
|
||||||
static DELAY: OnceLock<Duration> = OnceLock::new();
|
|
||||||
*DELAY.get_or_init(|| {
|
|
||||||
Duration::from_micros(rustfs_utils::get_env_u64(
|
|
||||||
ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS,
|
|
||||||
DEFAULT_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CoalescedReadVersionRequest {
|
|
||||||
item: BatchReadVersionItem,
|
|
||||||
tx: oneshot::Sender<disk::error::Result<FileInfo>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
||||||
struct ReadVersionCoalescerKey {
|
|
||||||
disk: usize,
|
|
||||||
incl_free_versions: bool,
|
|
||||||
read_data: bool,
|
|
||||||
healing: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReadVersionCoalescerKey {
|
|
||||||
fn new(disk: &DiskStore, opts: &ReadOptions) -> Self {
|
|
||||||
Self {
|
|
||||||
disk: Arc::as_ptr(disk) as usize,
|
|
||||||
incl_free_versions: opts.incl_free_versions,
|
|
||||||
read_data: opts.read_data,
|
|
||||||
healing: opts.healing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ReadVersionCoalescer {
|
|
||||||
lanes: HashMap<ReadVersionCoalescerKey, Vec<CoalescedReadVersionRequest>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_version_coalescer() -> &'static Mutex<ReadVersionCoalescer> {
|
|
||||||
static COALESCER: OnceLock<Mutex<ReadVersionCoalescer>> = OnceLock::new();
|
|
||||||
COALESCER.get_or_init(|| Mutex::new(ReadVersionCoalescer::default()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_read_version_coalescer_event(event: &'static str, item_count: usize) {
|
|
||||||
counter!(
|
|
||||||
METRIC_GET_METADATA_READ_VERSION_COALESCER_TOTAL,
|
|
||||||
"event" => event,
|
|
||||||
"item_count" => item_count.to_string()
|
|
||||||
)
|
|
||||||
.increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_version_via_coalescer(
|
|
||||||
disk: DiskStore,
|
|
||||||
org_bucket: &str,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
version_id: &str,
|
|
||||||
opts: &ReadOptions,
|
|
||||||
allow_coalescing: bool,
|
|
||||||
) -> disk::error::Result<FileInfo> {
|
|
||||||
if !allow_coalescing || !read_version_coalescing_enabled() {
|
|
||||||
return disk.read_version(org_bucket, bucket, object, version_id, opts).await;
|
|
||||||
}
|
|
||||||
if !matches!(disk.as_ref(), Disk::Remote(_)) {
|
|
||||||
record_read_version_coalescer_event("bypass_non_remote", 1);
|
|
||||||
return disk.read_version(org_bucket, bucket, object, version_id, opts).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
let item = BatchReadVersionItem {
|
|
||||||
org_volume: org_bucket.to_string(),
|
|
||||||
volume: bucket.to_string(),
|
|
||||||
path: object.to_string(),
|
|
||||||
version_id: version_id.to_string(),
|
|
||||||
};
|
|
||||||
let lane_key = ReadVersionCoalescerKey::new(&disk, opts);
|
|
||||||
let pending = {
|
|
||||||
let mut coalescer = read_version_coalescer().lock().await;
|
|
||||||
let lane = coalescer.lanes.entry(lane_key).or_default();
|
|
||||||
let schedule_delayed_flush = lane.is_empty();
|
|
||||||
lane.push(CoalescedReadVersionRequest { item, tx });
|
|
||||||
if lane.len() >= BATCH_READ_VERSION_MAX_ITEMS {
|
|
||||||
coalescer.lanes.remove(&lane_key)
|
|
||||||
} else if schedule_delayed_flush {
|
|
||||||
let disk = disk.clone();
|
|
||||||
let task_opts = *opts;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
tokio::time::sleep(read_version_coalescing_delay()).await;
|
|
||||||
flush_read_version_coalescer_lane(lane_key, disk, task_opts).await;
|
|
||||||
});
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(pending) = pending {
|
|
||||||
flush_read_version_coalescer_pending(lane_key, disk, *opts, pending).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
rx.await
|
|
||||||
.unwrap_or_else(|_| Err(DiskError::other("coalesced read_version response channel closed")))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn flush_read_version_coalescer_lane(lane_key: ReadVersionCoalescerKey, disk: DiskStore, opts: ReadOptions) {
|
|
||||||
let pending = {
|
|
||||||
let mut coalescer = read_version_coalescer().lock().await;
|
|
||||||
coalescer.lanes.remove(&lane_key).unwrap_or_default()
|
|
||||||
};
|
|
||||||
flush_read_version_coalescer_pending(lane_key, disk, opts, pending).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn flush_read_version_coalescer_pending(
|
|
||||||
lane_key: ReadVersionCoalescerKey,
|
|
||||||
disk: DiskStore,
|
|
||||||
opts: ReadOptions,
|
|
||||||
pending: Vec<CoalescedReadVersionRequest>,
|
|
||||||
) {
|
|
||||||
if pending.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
let mut observed_paths = HashSet::new();
|
|
||||||
for request in &pending {
|
|
||||||
if observed_paths.insert(request.item.path.as_str()) {
|
|
||||||
disk_call_counters::record(&request.item.path, disk_call_counters::KIND_BATCH_READ_VERSION, lane_key.disk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut senders = Vec::with_capacity(pending.len());
|
|
||||||
let mut items = Vec::with_capacity(pending.len());
|
|
||||||
for request in pending {
|
|
||||||
senders.push(request.tx);
|
|
||||||
items.push(request.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected_items = items.clone();
|
|
||||||
record_read_version_coalescer_event("attempted_batch", items.len());
|
|
||||||
let result =
|
|
||||||
match tokio::time::timeout(get_drive_metadata_timeout(), disk.batch_read_version(BatchReadVersionReq { items, opts }))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(DiskError::Timeout),
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(responses) => {
|
|
||||||
let results = map_batch_read_version_responses(&expected_items, responses);
|
|
||||||
for (tx, result) in senders.into_iter().zip(results) {
|
|
||||||
let _ = tx.send(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
let message = err.to_string();
|
|
||||||
for tx in senders {
|
|
||||||
let _ = tx.send(Err(DiskError::other(message.clone())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_batch_read_version_responses(
|
|
||||||
expected_items: &[BatchReadVersionItem],
|
|
||||||
responses: Vec<BatchReadVersionResp>,
|
|
||||||
) -> Vec<crate::disk::error::Result<FileInfo>> {
|
|
||||||
let mut results = (0..expected_items.len())
|
|
||||||
.map(|_| Err(DiskError::other("coalesced read_version response missing")))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut seen = vec![false; expected_items.len()];
|
|
||||||
for response in responses {
|
|
||||||
let Some(expected) = expected_items.get(response.index) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(slot) = results.get_mut(response.index) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if seen[response.index] {
|
|
||||||
*slot = Err(DiskError::other("coalesced read_version response duplicate index"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen[response.index] = true;
|
|
||||||
if response.path != expected.path || response.version_id != expected.version_id {
|
|
||||||
*slot = Err(DiskError::other("coalesced read_version response identity mismatch"));
|
|
||||||
} else {
|
|
||||||
*slot = if response.success {
|
|
||||||
Ok(response.file_info)
|
|
||||||
} else {
|
|
||||||
Err(batch_read_version_response_error(response.error_code, response.error))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_read_version_response_error(error_code: u32, error: String) -> DiskError {
|
|
||||||
match DiskError::from_u32(error_code) {
|
|
||||||
Some(DiskError::Io(_)) | None => DiskError::other(error),
|
|
||||||
Some(error) => error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(in crate::set_disk) fn bounded_metadata_fanout_order(
|
pub(in crate::set_disk) fn bounded_metadata_fanout_order(
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
@@ -370,15 +133,11 @@ pub(in crate::set_disk) fn bounded_metadata_fanout_order(
|
|||||||
order
|
order
|
||||||
}
|
}
|
||||||
use tokio::io::{AsyncRead, ReadBuf};
|
use tokio::io::{AsyncRead, ReadBuf};
|
||||||
use tokio::sync::{Mutex, RwLock, oneshot};
|
use tokio::sync::RwLock;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
pub(in crate::set_disk) const EVENT_SET_DISK_READ: &str = "set_disk_read";
|
pub(in crate::set_disk) const EVENT_SET_DISK_READ: &str = "set_disk_read";
|
||||||
pub(in crate::set_disk) const ENV_RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP: &str = "RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP";
|
pub(in crate::set_disk) const ENV_RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP: &str = "RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP";
|
||||||
const ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE: &str = "RUSTFS_GET_METADATA_READ_VERSION_COALESCE";
|
|
||||||
const ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS: &str = "RUSTFS_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS";
|
|
||||||
const DEFAULT_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS: u64 = 200;
|
|
||||||
const METRIC_GET_METADATA_READ_VERSION_COALESCER_TOTAL: &str = "rustfs_get_metadata_read_version_coalescer_total";
|
|
||||||
pub(in crate::set_disk) const ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE: &str = "RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE";
|
pub(in crate::set_disk) const ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE: &str = "RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE";
|
||||||
/// Default reader-setup strategy for the GET read path (rustfs/backlog#1215,
|
/// Default reader-setup strategy for the GET read path (rustfs/backlog#1215,
|
||||||
/// #1159, #923).
|
/// #1159, #923).
|
||||||
@@ -2597,7 +2356,6 @@ impl SetDisks {
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
0,
|
0,
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((ress, errors))
|
Ok((ress, errors))
|
||||||
@@ -2628,36 +2386,6 @@ impl SetDisks {
|
|||||||
true,
|
true,
|
||||||
caller_allows_early_stop,
|
caller_allows_early_stop,
|
||||||
default_parity_count,
|
default_parity_count,
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub(in crate::set_disk) async fn read_all_fileinfo_observed_for_get_object(
|
|
||||||
disks: &[Option<DiskStore>],
|
|
||||||
org_bucket: &str,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
version_id: &str,
|
|
||||||
read_data: bool,
|
|
||||||
incl_free_versions: bool,
|
|
||||||
caller_allows_early_stop: bool,
|
|
||||||
default_parity_count: usize,
|
|
||||||
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
|
||||||
Self::read_all_fileinfo_inner(
|
|
||||||
disks,
|
|
||||||
org_bucket,
|
|
||||||
bucket,
|
|
||||||
object,
|
|
||||||
version_id,
|
|
||||||
read_data,
|
|
||||||
false,
|
|
||||||
incl_free_versions,
|
|
||||||
true,
|
|
||||||
caller_allows_early_stop,
|
|
||||||
default_parity_count,
|
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -2680,7 +2408,6 @@ impl SetDisks {
|
|||||||
// subset would fail write quorum (backlog#872 regression).
|
// subset would fail write quorum (backlog#872 regression).
|
||||||
caller_allows_early_stop: bool,
|
caller_allows_early_stop: bool,
|
||||||
default_parity_count: usize,
|
default_parity_count: usize,
|
||||||
allow_coalescing: bool,
|
|
||||||
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
||||||
let early_stop_enabled =
|
let early_stop_enabled =
|
||||||
caller_allows_early_stop && observe && (is_get_metadata_early_stop_enabled() || is_version_early_stop_enabled());
|
caller_allows_early_stop && observe && (is_get_metadata_early_stop_enabled() || is_version_early_stop_enabled());
|
||||||
@@ -2697,7 +2424,6 @@ impl SetDisks {
|
|||||||
healing,
|
healing,
|
||||||
incl_free_versions,
|
incl_free_versions,
|
||||||
default_parity_count,
|
default_parity_count,
|
||||||
allow_coalescing,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -2720,7 +2446,6 @@ impl SetDisks {
|
|||||||
healing,
|
healing,
|
||||||
incl_free_versions,
|
incl_free_versions,
|
||||||
observe,
|
observe,
|
||||||
allow_coalescing,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -2736,7 +2461,6 @@ impl SetDisks {
|
|||||||
healing: bool,
|
healing: bool,
|
||||||
incl_free_versions: bool,
|
incl_free_versions: bool,
|
||||||
observe: bool,
|
observe: bool,
|
||||||
allow_coalescing: bool,
|
|
||||||
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
||||||
let fanout_start = observe.then(Instant::now);
|
let fanout_start = observe.then(Instant::now);
|
||||||
let mut ress = Vec::with_capacity(disks.len());
|
let mut ress = Vec::with_capacity(disks.len());
|
||||||
@@ -2768,7 +2492,7 @@ impl SetDisks {
|
|||||||
if let Some(delay) = slowtail_fault.as_ref().and_then(|fault| fault.delay_for_disk(disk_index)) {
|
if let Some(delay) = slowtail_fault.as_ref().and_then(|fault| fault.delay_for_disk(disk_index)) {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
read_version_via_coalescer(disk, &org_bucket, &bucket, &object, &version_id, &task_opts, allow_coalescing)
|
disk.read_version(&org_bucket, &bucket, &object, &version_id, &task_opts)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
Err(DiskError::DiskNotFound)
|
Err(DiskError::DiskNotFound)
|
||||||
@@ -2835,7 +2559,6 @@ impl SetDisks {
|
|||||||
healing: bool,
|
healing: bool,
|
||||||
incl_free_versions: bool,
|
incl_free_versions: bool,
|
||||||
default_parity_count: usize,
|
default_parity_count: usize,
|
||||||
allow_coalescing: bool,
|
|
||||||
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
) -> disk::error::Result<(Vec<FileInfo>, Vec<Option<DiskError>>, MetadataFanoutDiagnostics)> {
|
||||||
let fanout_start = Instant::now();
|
let fanout_start = Instant::now();
|
||||||
let mut ress = vec![FileInfo::default(); disks.len()];
|
let mut ress = vec![FileInfo::default(); disks.len()];
|
||||||
@@ -2884,7 +2607,7 @@ impl SetDisks {
|
|||||||
if let Some(delay) = slowtail_fault.as_ref().and_then(|fault| fault.delay_for_disk(index)) {
|
if let Some(delay) = slowtail_fault.as_ref().and_then(|fault| fault.delay_for_disk(index)) {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
read_version_via_coalescer(disk, &org_bucket, &bucket, &object, &version_id, &task_opts, allow_coalescing)
|
disk.read_version(&org_bucket, &bucket, &object, &version_id, &task_opts)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
Err(DiskError::DiskNotFound)
|
Err(DiskError::DiskNotFound)
|
||||||
@@ -6014,7 +5737,6 @@ pub(crate) mod disk_call_counters {
|
|||||||
|
|
||||||
/// Kind label for the per-disk `read_version` metadata RPC.
|
/// Kind label for the per-disk `read_version` metadata RPC.
|
||||||
pub const KIND_READ_VERSION: &str = "read_version";
|
pub const KIND_READ_VERSION: &str = "read_version";
|
||||||
pub const KIND_BATCH_READ_VERSION: &str = "batch_read_version";
|
|
||||||
|
|
||||||
/// Registry key: (object, kind, disk_index).
|
/// Registry key: (object, kind, disk_index).
|
||||||
type CountKey = (String, String, usize);
|
type CountKey = (String, String, usize);
|
||||||
@@ -6738,286 +6460,6 @@ mod tests {
|
|||||||
drop(dirs);
|
drop(dirs);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn metadata_read_version_coalescer_bypasses_local_disks() {
|
|
||||||
const DISKS: usize = 4;
|
|
||||||
let bucket = "coalesced-read-version-local-bypass-bucket";
|
|
||||||
let object_a = "coalesced-local-object-a";
|
|
||||||
let object_b = "coalesced-local-object-b";
|
|
||||||
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
|
|
||||||
install_metadata_fanout_fileinfo(&disks, bucket, object_a, None).await;
|
|
||||||
install_metadata_fanout_fileinfo(&disks, bucket, object_b, None).await;
|
|
||||||
|
|
||||||
temp_env::async_with_vars(
|
|
||||||
[
|
|
||||||
(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE, Some("auto")),
|
|
||||||
(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE_DELAY_MICROS, Some("5000")),
|
|
||||||
],
|
|
||||||
async {
|
|
||||||
let calls = disk_call_counters::observe(object_a);
|
|
||||||
let disks_a = disks.clone();
|
|
||||||
let disks_b = disks.clone();
|
|
||||||
let read_a = tokio::spawn(async move {
|
|
||||||
SetDisks::read_all_fileinfo_observed_for_get_object(
|
|
||||||
&disks_a, "", bucket, object_a, "", false, false, false, 2,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map(|(file_infos, errors, _)| (file_infos, errors))
|
|
||||||
});
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
let read_b = tokio::spawn(async move {
|
|
||||||
SetDisks::read_all_fileinfo_observed_for_get_object(
|
|
||||||
&disks_b, "", bucket, object_b, "", false, false, false, 2,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map(|(file_infos, errors, _)| (file_infos, errors))
|
|
||||||
});
|
|
||||||
|
|
||||||
let (metadata_a, errs_a) = read_a
|
|
||||||
.await
|
|
||||||
.expect("first read task should not panic")
|
|
||||||
.expect("first coalesced read should resolve");
|
|
||||||
let (metadata_b, errs_b) = read_b
|
|
||||||
.await
|
|
||||||
.expect("second read task should not panic")
|
|
||||||
.expect("second coalesced read should resolve");
|
|
||||||
|
|
||||||
assert_eq!(metadata_a.iter().filter(|fi| fi.name == object_a).count(), DISKS);
|
|
||||||
assert_eq!(metadata_b.iter().filter(|fi| fi.name == object_b).count(), DISKS);
|
|
||||||
assert!(errs_a.iter().all(Option::is_none));
|
|
||||||
assert!(errs_b.iter().all(Option::is_none));
|
|
||||||
assert_eq!(
|
|
||||||
calls.total(disk_call_counters::KIND_READ_VERSION),
|
|
||||||
DISKS as u64,
|
|
||||||
"local disks still execute the ordinary per-disk read_version path"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
calls.total(disk_call_counters::KIND_BATCH_READ_VERSION),
|
|
||||||
0,
|
|
||||||
"GET coalescing targets internode RPC count only and must not batch local disk reads"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
drop(dirs);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn metadata_read_version_coalescer_requires_get_object_intent() {
|
|
||||||
const DISKS: usize = 4;
|
|
||||||
let bucket = "coalesced-read-version-default-bypass-bucket";
|
|
||||||
let object = "default-bypass-object";
|
|
||||||
let (dirs, disks) = call_counter_local_disks(bucket, DISKS).await;
|
|
||||||
install_metadata_fanout_fileinfo(&disks, bucket, object, None).await;
|
|
||||||
|
|
||||||
temp_env::async_with_vars([(ENV_RUSTFS_GET_METADATA_READ_VERSION_COALESCE, Some("auto"))], async {
|
|
||||||
let calls = disk_call_counters::observe(object);
|
|
||||||
let (metadata, errs) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
|
|
||||||
.await
|
|
||||||
.expect("default metadata read should resolve");
|
|
||||||
|
|
||||||
assert_eq!(metadata.iter().filter(|fi| fi.name == object).count(), DISKS);
|
|
||||||
assert!(errs.iter().all(Option::is_none));
|
|
||||||
assert_eq!(calls.total(disk_call_counters::KIND_READ_VERSION), DISKS as u64);
|
|
||||||
assert_eq!(
|
|
||||||
calls.total(disk_call_counters::KIND_BATCH_READ_VERSION),
|
|
||||||
0,
|
|
||||||
"non-GET metadata paths must bypass coalescer even when the env gate is enabled"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
drop(dirs);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_read_version_response_mapping_preserves_index_and_errors() {
|
|
||||||
let expected_items = vec![
|
|
||||||
BatchReadVersionItem {
|
|
||||||
org_volume: String::new(),
|
|
||||||
volume: "bucket".to_string(),
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
},
|
|
||||||
BatchReadVersionItem {
|
|
||||||
org_volume: String::new(),
|
|
||||||
volume: "bucket".to_string(),
|
|
||||||
path: "object-b".to_string(),
|
|
||||||
version_id: "v-b".to_string(),
|
|
||||||
},
|
|
||||||
BatchReadVersionItem {
|
|
||||||
org_volume: String::new(),
|
|
||||||
volume: "bucket".to_string(),
|
|
||||||
path: "object-c".to_string(),
|
|
||||||
version_id: "v-c".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let ok_file_info = FileInfo {
|
|
||||||
name: "object-a".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let responses = vec![
|
|
||||||
BatchReadVersionResp {
|
|
||||||
index: 2,
|
|
||||||
path: "object-c".to_string(),
|
|
||||||
version_id: "v-c".to_string(),
|
|
||||||
success: false,
|
|
||||||
file_info: FileInfo::default(),
|
|
||||||
error: "disk read failed".to_string(),
|
|
||||||
error_code: 0,
|
|
||||||
},
|
|
||||||
BatchReadVersionResp {
|
|
||||||
index: 0,
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
success: true,
|
|
||||||
file_info: ok_file_info,
|
|
||||||
error: String::new(),
|
|
||||||
error_code: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut results = map_batch_read_version_responses(&expected_items, responses).into_iter();
|
|
||||||
let first = results
|
|
||||||
.next()
|
|
||||||
.expect("slot 0 should exist")
|
|
||||||
.expect("slot 0 should map the success response by index");
|
|
||||||
assert_eq!(first.name, "object-a");
|
|
||||||
|
|
||||||
let missing = results
|
|
||||||
.next()
|
|
||||||
.expect("slot 1 should exist")
|
|
||||||
.expect_err("slot 1 should stay missing");
|
|
||||||
assert!(
|
|
||||||
missing.to_string().contains("response missing"),
|
|
||||||
"unexpected missing response error: {missing}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let failed = results
|
|
||||||
.next()
|
|
||||||
.expect("slot 2 should exist")
|
|
||||||
.expect_err("slot 2 should map the response error");
|
|
||||||
assert!(failed.to_string().contains("disk read failed"), "unexpected per-item error: {failed}");
|
|
||||||
assert!(results.next().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_read_version_response_mapping_preserves_typed_not_found_errors() {
|
|
||||||
let expected_items = vec![
|
|
||||||
BatchReadVersionItem {
|
|
||||||
org_volume: String::new(),
|
|
||||||
volume: "bucket".to_string(),
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
},
|
|
||||||
BatchReadVersionItem {
|
|
||||||
org_volume: String::new(),
|
|
||||||
volume: "bucket".to_string(),
|
|
||||||
path: "object-b".to_string(),
|
|
||||||
version_id: "v-b".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let results = map_batch_read_version_responses(
|
|
||||||
&expected_items,
|
|
||||||
vec![
|
|
||||||
BatchReadVersionResp {
|
|
||||||
index: 0,
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
success: false,
|
|
||||||
file_info: FileInfo::default(),
|
|
||||||
error: DiskError::FileNotFound.to_string(),
|
|
||||||
error_code: DiskError::FileNotFound.to_u32(),
|
|
||||||
},
|
|
||||||
BatchReadVersionResp {
|
|
||||||
index: 1,
|
|
||||||
path: "object-b".to_string(),
|
|
||||||
version_id: "v-b".to_string(),
|
|
||||||
success: false,
|
|
||||||
file_info: FileInfo::default(),
|
|
||||||
error: DiskError::FileVersionNotFound.to_string(),
|
|
||||||
error_code: DiskError::FileVersionNotFound.to_u32(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(results.first().expect("slot 0 should exist"), Err(DiskError::FileNotFound)));
|
|
||||||
assert!(matches!(
|
|
||||||
results.get(1).expect("slot 1 should exist"),
|
|
||||||
Err(DiskError::FileVersionNotFound)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_read_version_response_mapping_rejects_identity_mismatch_and_duplicate_index() {
|
|
||||||
let expected_items = vec![BatchReadVersionItem {
|
|
||||||
org_volume: String::new(),
|
|
||||||
volume: "bucket".to_string(),
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
}];
|
|
||||||
let mismatched = map_batch_read_version_responses(
|
|
||||||
&expected_items,
|
|
||||||
vec![BatchReadVersionResp {
|
|
||||||
index: 0,
|
|
||||||
path: "object-b".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
success: true,
|
|
||||||
file_info: FileInfo {
|
|
||||||
name: "object-b".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
error: String::new(),
|
|
||||||
error_code: 0,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.expect("slot 0 should exist")
|
|
||||||
.expect_err("identity mismatch should fail closed");
|
|
||||||
assert!(
|
|
||||||
mismatched.to_string().contains("identity mismatch"),
|
|
||||||
"unexpected mismatch error: {mismatched}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let duplicate = map_batch_read_version_responses(
|
|
||||||
&expected_items,
|
|
||||||
vec![
|
|
||||||
BatchReadVersionResp {
|
|
||||||
index: 0,
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
success: true,
|
|
||||||
file_info: FileInfo {
|
|
||||||
name: "object-a".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
error: String::new(),
|
|
||||||
error_code: 0,
|
|
||||||
},
|
|
||||||
BatchReadVersionResp {
|
|
||||||
index: 0,
|
|
||||||
path: "object-a".to_string(),
|
|
||||||
version_id: "v-a".to_string(),
|
|
||||||
success: true,
|
|
||||||
file_info: FileInfo {
|
|
||||||
name: "object-a".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
error: String::new(),
|
|
||||||
error_code: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.expect("slot 0 should exist")
|
|
||||||
.expect_err("duplicate response index should fail closed");
|
|
||||||
assert!(
|
|
||||||
duplicate.to_string().contains("duplicate index"),
|
|
||||||
"unexpected duplicate error: {duplicate}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Isolation guard: unobserved objects record nothing (so parallel tests do
|
/// Isolation guard: unobserved objects record nothing (so parallel tests do
|
||||||
/// not inflate one another), and a scope clears its own counts on drop.
|
/// not inflate one another), and a scope clears its own counts on drop.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -735,12 +735,8 @@ pub(crate) use core::io_primitives::disk_call_counters;
|
|||||||
mod ctx;
|
mod ctx;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod ops;
|
mod ops;
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use ops::multipart::NewMultipartUploadCommitObservation;
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
pub use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
|
pub use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) use ops::object::DeleteObjectCommitBarrier;
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
|
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
|
||||||
pub(crate) use ops::object::body_cache_plaintext_len;
|
pub(crate) use ops::object::body_cache_plaintext_len;
|
||||||
@@ -3029,16 +3025,6 @@ pub struct SetDisks {
|
|||||||
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
|
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
|
||||||
// each endpoint's canonical Arc, so an exact Arc set identifies the lock domain.
|
|
||||||
pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
|
|
||||||
left.iter()
|
|
||||||
.all(|left_client| right.iter().any(|right_client| Arc::ptr_eq(left_client, right_client)))
|
|
||||||
&& right
|
|
||||||
.iter()
|
|
||||||
.all(|right_client| left.iter().any(|left_client| Arc::ptr_eq(left_client, right_client)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const ERASURE_CACHE_MAX_ENTRIES: usize = 32;
|
const ERASURE_CACHE_MAX_ENTRIES: usize = 32;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
@@ -3614,15 +3600,6 @@ impl SetDisks {
|
|||||||
&self.ctx
|
&self.ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether both sets' namespace-lock implementations cover the same object key.
|
|
||||||
pub(crate) async fn shares_namespace_lock_domain(&self, other: &Self) -> bool {
|
|
||||||
match (self.ctx.is_dist_erasure().await, other.ctx.is_dist_erasure().await) {
|
|
||||||
(false, false) => Arc::ptr_eq(&self.local_lock_manager, &other.local_lock_manager),
|
|
||||||
(true, true) => same_distributed_lock_domain(&self.lockers, &other.lockers),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The lock manager this set actually uses (test-only; Phase 5 Slice 3).
|
/// The lock manager this set actually uses (test-only; Phase 5 Slice 3).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn local_lock_manager_for_test(&self) -> &Arc<rustfs_lock::GlobalLockManager> {
|
pub(crate) fn local_lock_manager_for_test(&self) -> &Arc<rustfs_lock::GlobalLockManager> {
|
||||||
@@ -4607,11 +4584,11 @@ fn should_preserve_delete_replication_state(opts: &ObjectOptions) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_force_delete_marker_for_missing_version(opts: &ObjectOptions) -> bool {
|
fn should_force_delete_marker_for_missing_version(opts: &ObjectOptions) -> bool {
|
||||||
opts.delete_marker || ((opts.versioned || opts.version_suspended) && opts.version_id.is_none() && !opts.data_movement)
|
opts.delete_marker || (opts.versioned && opts.version_id.is_none() && !opts.data_movement)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_delete_version_state(opts: &ObjectOptions, goi: &ObjectInfo, version_found: bool) -> (bool, bool) {
|
fn resolve_delete_version_state(opts: &ObjectOptions, goi: &ObjectInfo, version_found: bool) -> (bool, bool) {
|
||||||
let mut mark_delete = goi.version_id.is_some() || ((opts.versioned || opts.version_suspended) && opts.version_id.is_none());
|
let mut mark_delete = goi.version_id.is_some() || (opts.versioned && opts.version_id.is_none());
|
||||||
let mut delete_marker = opts.versioned;
|
let mut delete_marker = opts.versioned;
|
||||||
|
|
||||||
if opts.version_id.is_some() {
|
if opts.version_id.is_some() {
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ use crate::crash_inject::{self, CrashPoint};
|
|||||||
use crate::multipart_listing::paginate_multipart_listing;
|
use crate::multipart_listing::paginate_multipart_listing;
|
||||||
use futures::{StreamExt, stream};
|
use futures::{StreamExt, stream};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
#[cfg(test)]
|
|
||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -67,7 +65,6 @@ impl StaleMultipartCleanupGuard {
|
|||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum MultipartCommitPause {
|
pub enum MultipartCommitPause {
|
||||||
NewUploadBeforeLockLost,
|
|
||||||
PutPartBeforeLockAcquire,
|
PutPartBeforeLockAcquire,
|
||||||
PutPartBeforeLockLost,
|
PutPartBeforeLockLost,
|
||||||
PutPartAfterRename,
|
PutPartAfterRename,
|
||||||
@@ -159,72 +156,6 @@ impl Drop for MultipartCommitBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
struct NewMultipartUploadCommitObservationState {
|
|
||||||
bucket: String,
|
|
||||||
object: String,
|
|
||||||
committed: AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) struct NewMultipartUploadCommitObservation {
|
|
||||||
state: Arc<NewMultipartUploadCommitObservationState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
static NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION: std::sync::OnceLock<
|
|
||||||
std::sync::Mutex<Option<Arc<NewMultipartUploadCommitObservationState>>>,
|
|
||||||
> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl NewMultipartUploadCommitObservation {
|
|
||||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
|
||||||
let state = Arc::new(NewMultipartUploadCommitObservationState {
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
object: object.to_string(),
|
|
||||||
committed: AtomicBool::new(false),
|
|
||||||
});
|
|
||||||
let mut slot = NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("new multipart upload commit observation mutex should not poison");
|
|
||||||
assert!(slot.is_none(), "new multipart upload commit observation must be unique");
|
|
||||||
*slot = Some(Arc::clone(&state));
|
|
||||||
Self { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn committed(&self) -> bool {
|
|
||||||
self.state.committed.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Drop for NewMultipartUploadCommitObservation {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mut slot = NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("new multipart upload commit observation mutex should not poison");
|
|
||||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
||||||
*slot = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn observe_new_multipart_upload_commit(bucket: &str, object: &str) {
|
|
||||||
let state = NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("new multipart upload commit observation mutex should not poison")
|
|
||||||
.as_ref()
|
|
||||||
.filter(|state| state.bucket == bucket && state.object == object)
|
|
||||||
.cloned();
|
|
||||||
if let Some(state) = state {
|
|
||||||
state.committed.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) {
|
async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) {
|
||||||
let barrier = {
|
let barrier = {
|
||||||
@@ -1684,30 +1615,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
|
|
||||||
let upload_path = Self::get_multipart_upload_dir(bucket, object, upload_uuid.as_str(), opts.data_movement);
|
let upload_path = Self::get_multipart_upload_dir(bucket, object, upload_uuid.as_str(), opts.data_movement);
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
|
||||||
pause_multipart_commit(bucket, object, MultipartCommitPause::NewUploadBeforeLockLost).await;
|
|
||||||
if _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
|
||||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
||||||
mode: "new_multipart_upload_commit",
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
object: object.to_string(),
|
|
||||||
required: 1,
|
|
||||||
achieved: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if opts
|
|
||||||
.namespace_lock_fence
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
|
||||||
{
|
|
||||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
||||||
mode: "new_multipart_upload_outer_lock",
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
object: object.to_string(),
|
|
||||||
required: 1,
|
|
||||||
achieved: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||||
Self::write_unique_file_info(
|
Self::write_unique_file_info(
|
||||||
&shuffle_disks,
|
&shuffle_disks,
|
||||||
@@ -1719,8 +1626,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||||
#[cfg(test)]
|
|
||||||
observe_new_multipart_upload_commit(bucket, object);
|
|
||||||
|
|
||||||
// evalDisks
|
// evalDisks
|
||||||
|
|
||||||
|
|||||||
@@ -1294,7 +1294,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
|||||||
(prepared.snapshot, prepared.object_info)
|
(prepared.snapshot, prepared.object_info)
|
||||||
} else {
|
} else {
|
||||||
match self
|
match self
|
||||||
.get_object_fileinfo_for_get_object_reader(
|
.get_object_fileinfo(
|
||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts,
|
opts,
|
||||||
@@ -2123,27 +2123,14 @@ impl SetDisks {
|
|||||||
let erasure = Arc::new(erasure_from_file_info(&fi, false)?);
|
let erasure = Arc::new(erasure_from_file_info(&fi, false)?);
|
||||||
|
|
||||||
let put_object_size = known_put_object_storage_size(data.size());
|
let put_object_size = known_put_object_storage_size(data.size());
|
||||||
let shard_file_size_raw = erasure.shard_file_size(put_object_size);
|
|
||||||
let is_inline_buffer =
|
let is_inline_buffer =
|
||||||
storage_class_config.should_inline(shard_file_size_raw, erasure.data_shards, opts.versioned);
|
storage_class_config.should_inline(erasure.shard_file_size(put_object_size), erasure.data_shards, opts.versioned);
|
||||||
|
|
||||||
let collect_stage_timing = rustfs_io_metrics::put_stage_metrics_enabled() || issue3031_diag_enabled();
|
let collect_stage_timing = rustfs_io_metrics::put_stage_metrics_enabled() || issue3031_diag_enabled();
|
||||||
let shard_file_size = shard_file_size_raw;
|
let shard_file_size = erasure.shard_file_size(put_object_size);
|
||||||
let shard_size = erasure.shard_size();
|
let shard_size = erasure.shard_size();
|
||||||
let write_path = classify_put_write_path(is_inline_buffer, put_object_size, fi.erasure.block_size);
|
let write_path = classify_put_write_path(is_inline_buffer, put_object_size, fi.erasure.block_size);
|
||||||
let direct_inline_commit = matches!(write_path, SmallWritePath::Inline);
|
let direct_inline_commit = matches!(write_path, SmallWritePath::Inline);
|
||||||
{
|
|
||||||
use std::io::Write;
|
|
||||||
let msg = format!(
|
|
||||||
"INLINE_DEBUG: bucket={} obj={} size={} shard_fs={} ds={} bs={} inline={} direct={} path={} iblock={} ver={}\n",
|
|
||||||
bucket, object, put_object_size, shard_file_size_raw, erasure.data_shards, fi.erasure.block_size,
|
|
||||||
is_inline_buffer, direct_inline_commit, write_path.metric_label(), storage_class_config.inline_block(), opts.versioned
|
|
||||||
);
|
|
||||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/tmp/rustfs_inline_debug.log") {
|
|
||||||
let _ = f.write_all(msg.as_bytes());
|
|
||||||
}
|
|
||||||
let _ = std::io::stderr().write_all(msg.as_bytes());
|
|
||||||
}
|
|
||||||
rustfs_io_metrics::record_put_object_path(write_path.metric_label());
|
rustfs_io_metrics::record_put_object_path(write_path.metric_label());
|
||||||
let writer_setup_stage_start = collect_stage_timing.then(Instant::now);
|
let writer_setup_stage_start = collect_stage_timing.then(Instant::now);
|
||||||
let (mut writers, errors) = if direct_inline_commit {
|
let (mut writers, errors) = if direct_inline_commit {
|
||||||
@@ -2497,7 +2484,6 @@ impl SetDisks {
|
|||||||
})
|
})
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
notify_put_object_commit_namespace_acquired(bucket, object);
|
|
||||||
}
|
}
|
||||||
#[cfg(not(any(test, feature = "test-util")))]
|
#[cfg(not(any(test, feature = "test-util")))]
|
||||||
{
|
{
|
||||||
@@ -4645,7 +4631,6 @@ struct PutObjectCommitBarrierState {
|
|||||||
arrived: tokio::sync::Notify,
|
arrived: tokio::sync::Notify,
|
||||||
release: tokio::sync::Notify,
|
release: tokio::sync::Notify,
|
||||||
namespace_pending: tokio::sync::Notify,
|
namespace_pending: tokio::sync::Notify,
|
||||||
namespace_acquired: std::sync::atomic::AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
@@ -4667,7 +4652,6 @@ impl PutObjectCommitBarrier {
|
|||||||
arrived: tokio::sync::Notify::new(),
|
arrived: tokio::sync::Notify::new(),
|
||||||
release: tokio::sync::Notify::new(),
|
release: tokio::sync::Notify::new(),
|
||||||
namespace_pending: tokio::sync::Notify::new(),
|
namespace_pending: tokio::sync::Notify::new(),
|
||||||
namespace_acquired: std::sync::atomic::AtomicBool::new(false),
|
|
||||||
});
|
});
|
||||||
let mut slot = PUT_OBJECT_COMMIT_BARRIER
|
let mut slot = PUT_OBJECT_COMMIT_BARRIER
|
||||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||||
@@ -4702,10 +4686,6 @@ impl PutObjectCommitBarrier {
|
|||||||
.await
|
.await
|
||||||
.expect("put object should wait for the namespace lock after leaving the commit barrier");
|
.expect("put object should wait for the namespace lock after leaving the commit barrier");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn namespace_acquired(&self) -> bool {
|
|
||||||
self.state.namespace_acquired.load(std::sync::atomic::Ordering::Acquire)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
@@ -4762,22 +4742,6 @@ fn notify_put_object_commit_namespace_pending(bucket: &str, object: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
|
||||||
fn notify_put_object_commit_namespace_acquired(bucket: &str, object: &str) {
|
|
||||||
let barrier = PUT_OBJECT_COMMIT_BARRIER
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
|
||||||
.lock()
|
|
||||||
.expect("put object commit barrier mutex should not poison")
|
|
||||||
.iter()
|
|
||||||
.find(|barrier| {
|
|
||||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == PutObjectCommitPause::BeforeNamespace
|
|
||||||
})
|
|
||||||
.cloned();
|
|
||||||
if let Some(barrier) = barrier {
|
|
||||||
barrier.namespace_acquired.store(true, std::sync::atomic::Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
struct DeleteObjectCommitBarrierState {
|
struct DeleteObjectCommitBarrierState {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
@@ -4787,7 +4751,7 @@ struct DeleteObjectCommitBarrierState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) struct DeleteObjectCommitBarrier {
|
struct DeleteObjectCommitBarrier {
|
||||||
state: Arc<DeleteObjectCommitBarrierState>,
|
state: Arc<DeleteObjectCommitBarrierState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4797,7 +4761,7 @@ static DELETE_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
impl DeleteObjectCommitBarrier {
|
impl DeleteObjectCommitBarrier {
|
||||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
fn install(bucket: &str, object: &str) -> Self {
|
||||||
let state = Arc::new(DeleteObjectCommitBarrierState {
|
let state = Arc::new(DeleteObjectCommitBarrierState {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
object: object.to_string(),
|
object: object.to_string(),
|
||||||
@@ -4813,13 +4777,13 @@ impl DeleteObjectCommitBarrier {
|
|||||||
Self { state }
|
Self { state }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_until_paused(&self) {
|
async fn wait_until_paused(&self) {
|
||||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||||
.await
|
.await
|
||||||
.expect("delete object should reach the deterministic commit barrier");
|
.expect("delete object should reach the deterministic commit barrier");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn release(&self) {
|
fn release(&self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5941,7 +5905,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
if dobj.version_id.is_none() && (version_suspended || versioned) {
|
if dobj.version_id.is_none() && (version_suspended || versioned) {
|
||||||
vr.mod_time = Some(OffsetDateTime::now_utc());
|
vr.mod_time = Some(OffsetDateTime::now_utc());
|
||||||
vr.deleted = true;
|
vr.deleted = true;
|
||||||
vr.mark_deleted = true;
|
|
||||||
if versioned {
|
if versioned {
|
||||||
vr.version_id = Some(Uuid::new_v4());
|
vr.version_id = Some(Uuid::new_v4());
|
||||||
}
|
}
|
||||||
@@ -11828,7 +11791,6 @@ mod transition_upload_integrity_tests {
|
|||||||
crate::data_movement::SourceCleanupBucketFence {
|
crate::data_movement::SourceCleanupBucketFence {
|
||||||
expected_incarnation_id: None,
|
expected_incarnation_id: None,
|
||||||
lifecycle_guard: Some(&bucket_guard),
|
lifecycle_guard: Some(&bucket_guard),
|
||||||
..Default::default()
|
|
||||||
},
|
},
|
||||||
"test_data_movement",
|
"test_data_movement",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -259,30 +259,7 @@ impl SetDisks {
|
|||||||
read_data: bool,
|
read_data: bool,
|
||||||
caller_allows_early_stop: bool,
|
caller_allows_early_stop: bool,
|
||||||
) -> Result<GetObjectFileInfo> {
|
) -> Result<GetObjectFileInfo> {
|
||||||
self.get_object_fileinfo_gated_inner(bucket, object, opts, read_data, caller_allows_early_stop, false)
|
self.get_object_fileinfo_gated(bucket, object, opts, read_data, caller_allows_early_stop)
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(level = "debug", skip(self))]
|
|
||||||
#[hotpath::measure(impl_type = "SetDisks")]
|
|
||||||
pub(super) async fn get_object_fileinfo_for_get_object_reader(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
opts: &ObjectOptions,
|
|
||||||
read_data: bool,
|
|
||||||
caller_allows_early_stop: bool,
|
|
||||||
) -> Result<GetObjectFileInfo> {
|
|
||||||
let allow_read_version_coalescing = !crate::bucket::utils::is_meta_bucketname(bucket)
|
|
||||||
&& crate::runtime::global::get_metadata_read_version_coalescing_service_ready();
|
|
||||||
self.get_object_fileinfo_gated_inner(
|
|
||||||
bucket,
|
|
||||||
object,
|
|
||||||
opts,
|
|
||||||
read_data,
|
|
||||||
caller_allows_early_stop,
|
|
||||||
allow_read_version_coalescing,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,20 +275,6 @@ impl SetDisks {
|
|||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
read_data: bool,
|
read_data: bool,
|
||||||
allow_early_stop: bool,
|
allow_early_stop: bool,
|
||||||
) -> Result<GetObjectFileInfo> {
|
|
||||||
self.get_object_fileinfo_gated_inner(bucket, object, opts, read_data, allow_early_stop, false)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn get_object_fileinfo_gated_inner(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
opts: &ObjectOptions,
|
|
||||||
read_data: bool,
|
|
||||||
allow_early_stop: bool,
|
|
||||||
allow_read_version_coalescing: bool,
|
|
||||||
) -> Result<GetObjectFileInfo> {
|
) -> Result<GetObjectFileInfo> {
|
||||||
let vid = opts.version_id.clone().unwrap_or_default();
|
let vid = opts.version_id.clone().unwrap_or_default();
|
||||||
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||||
@@ -374,21 +337,7 @@ impl SetDisks {
|
|||||||
// read_all_fileinfo_observed (see read_all_fileinfo_early_stop in
|
// read_all_fileinfo_observed (see read_all_fileinfo_early_stop in
|
||||||
// core/io_primitives.rs); unsafe requests and callers that opt out
|
// core/io_primitives.rs); unsafe requests and callers that opt out
|
||||||
// (allow_early_stop=false) fall back to full-wait.
|
// (allow_early_stop=false) fall back to full-wait.
|
||||||
let (mut parts_metadata, errs, metadata_fanout_diagnostics) = if allow_read_version_coalescing {
|
let (mut parts_metadata, errs, metadata_fanout_diagnostics) = Self::read_all_fileinfo_observed(
|
||||||
Self::read_all_fileinfo_observed_for_get_object(
|
|
||||||
&disks,
|
|
||||||
"",
|
|
||||||
bucket,
|
|
||||||
object,
|
|
||||||
vid.as_str(),
|
|
||||||
read_data,
|
|
||||||
opts.incl_free_versions,
|
|
||||||
allow_early_stop,
|
|
||||||
self.default_parity_count,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
Self::read_all_fileinfo_observed(
|
|
||||||
&disks,
|
&disks,
|
||||||
"",
|
"",
|
||||||
bucket,
|
bucket,
|
||||||
@@ -400,8 +349,7 @@ impl SetDisks {
|
|||||||
allow_early_stop,
|
allow_early_stop,
|
||||||
self.default_parity_count,
|
self.default_parity_count,
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
};
|
|
||||||
let metadata_metrics_path = if crate::bucket::utils::is_meta_bucketname(bucket) {
|
let metadata_metrics_path = if crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||||
GET_OBJECT_PATH_INTERNAL_META
|
GET_OBJECT_PATH_INTERNAL_META
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,12 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::pools::POOL_META_NAME;
|
|
||||||
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::heal::HealOperations as _;
|
||||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
|
||||||
use rustfs_lock::NamespaceLockGuard;
|
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
|
||||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||||
@@ -35,119 +30,7 @@ fn invalid_heal_pool_index(pool_idx: usize, pool_count: usize) -> Error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum HealFormatPoolSkip {
|
|
||||||
Completed,
|
|
||||||
Retryable,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_heal_format_pool(
|
|
||||||
pool_idx: usize,
|
|
||||||
pool_cmd_line: &str,
|
|
||||||
pool_meta: &PoolMeta,
|
|
||||||
rebalance_meta: Option<&RebalanceMeta>,
|
|
||||||
) -> Option<HealFormatPoolSkip> {
|
|
||||||
let Some(pool) = pool_meta.pools.get(pool_idx) else {
|
|
||||||
return Some(HealFormatPoolSkip::Retryable);
|
|
||||||
};
|
|
||||||
|
|
||||||
if pool.id != pool_idx || pool_cmd_line.is_empty() || pool.cmd_line.is_empty() || pool.cmd_line != pool_cmd_line {
|
|
||||||
return Some(HealFormatPoolSkip::Retryable);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(decommission) = pool.decommission.as_ref() {
|
|
||||||
if decommission.complete {
|
|
||||||
return Some(HealFormatPoolSkip::Completed);
|
|
||||||
}
|
|
||||||
if decommission.failed || decommission.canceled || decommission.queued || pool_meta.is_suspended(pool_idx) {
|
|
||||||
return Some(HealFormatPoolSkip::Retryable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(meta) = rebalance_meta {
|
|
||||||
let Some(pool_stats) = meta.pool_stats.get(pool_idx) else {
|
|
||||||
return Some(HealFormatPoolSkip::Retryable);
|
|
||||||
};
|
|
||||||
if pool_stats.info.stopping || (pool_stats.participating && pool_stats.info.status == RebalStatus::Started) {
|
|
||||||
return Some(HealFormatPoolSkip::Retryable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn heal_format_pool_skip_error(skip: HealFormatPoolSkip) -> Error {
|
|
||||||
match skip {
|
|
||||||
HealFormatPoolSkip::Completed => StorageError::NoHealRequired,
|
|
||||||
HealFormatPoolSkip::Retryable => StorageError::SlowDown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn heal_format_fence_lost_error() -> Error {
|
|
||||||
StorageError::SlowDown
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ECStore {
|
impl ECStore {
|
||||||
async fn acquire_heal_format_fence(
|
|
||||||
&self,
|
|
||||||
) -> Result<(NamespaceLockGuard, NamespaceLockGuard, PoolMeta, Option<RebalanceMeta>)> {
|
|
||||||
let metadata_pool = self
|
|
||||||
.pools
|
|
||||||
.first()
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| Error::other("heal format requires at least one storage pool"))?;
|
|
||||||
|
|
||||||
// Metadata fence order is part of the decommission/rebalance protocol:
|
|
||||||
// pool.bin must always be acquired before rebalance.bin.
|
|
||||||
let pool_lock = metadata_pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
|
||||||
let pool_guard = pool_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
||||||
let rebalance_lock = metadata_pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
|
||||||
let rebalance_guard = rebalance_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
||||||
|
|
||||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
|
||||||
return Err(heal_format_fence_lost_error());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pool_meta = PoolMeta::default();
|
|
||||||
pool_meta.load_no_lock(metadata_pool.clone()).await?;
|
|
||||||
if pool_meta.pools.len() != self.pools.len()
|
|
||||||
|| pool_meta.pools.iter().enumerate().any(|(pool_idx, pool)| {
|
|
||||||
pool.id != pool_idx || pool.cmd_line.is_empty() || pool.cmd_line != self.pools[pool_idx].endpoints.cmd_line
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(heal_format_fence_lost_error());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rebalance_meta = RebalanceMeta::new();
|
|
||||||
let rebalance_meta = match rebalance_meta
|
|
||||||
.load_with_opts(
|
|
||||||
metadata_pool,
|
|
||||||
ObjectOptions {
|
|
||||||
no_lock: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => Some(rebalance_meta),
|
|
||||||
Err(Error::ConfigNotFound) => None,
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
if rebalance_meta
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|meta| meta.pool_stats.len() != self.pools.len())
|
|
||||||
{
|
|
||||||
return Err(heal_format_fence_lost_error());
|
|
||||||
}
|
|
||||||
|
|
||||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
|
||||||
return Err(heal_format_fence_lost_error());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((pool_guard, rebalance_guard, pool_meta, rebalance_meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_pools_for_heal_object(&self, opts: &HealOpts) -> Result<Vec<Arc<Sets>>> {
|
fn get_pools_for_heal_object(&self, opts: &HealOpts) -> Result<Vec<Arc<Sets>>> {
|
||||||
match opts.pool {
|
match opts.pool {
|
||||||
Some(pool_idx) => Ok(vec![
|
Some(pool_idx) => Ok(vec![
|
||||||
@@ -169,26 +52,9 @@ impl ECStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut count_no_heal = 0;
|
let mut count_no_heal = 0;
|
||||||
let mut count_completed = 0;
|
|
||||||
let mut first_error = None;
|
let mut first_error = None;
|
||||||
for (pool_idx, pool) in self.pools.iter().enumerate() {
|
for pool in self.pools.iter() {
|
||||||
let (pool_guard, rebalance_guard, pool_meta, rebalance_meta) = self.acquire_heal_format_fence().await?;
|
let (mut result, err) = pool.heal_format(dry_run).await?;
|
||||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
|
||||||
first_error.get_or_insert(heal_format_fence_lost_error());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(skip) = classify_heal_format_pool(pool_idx, &pool.endpoints.cmd_line, &pool_meta, rebalance_meta.as_ref())
|
|
||||||
{
|
|
||||||
if matches!(skip, HealFormatPoolSkip::Completed) {
|
|
||||||
count_completed += 1;
|
|
||||||
} else {
|
|
||||||
first_error.get_or_insert(heal_format_pool_skip_error(skip));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fence_lost = || pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost();
|
|
||||||
let (mut result, err) = pool.heal_format_with_fence(dry_run, fence_lost).await?;
|
|
||||||
if let Some(err) = err {
|
if let Some(err) = err {
|
||||||
match err {
|
match err {
|
||||||
StorageError::NoHealRequired => {
|
StorageError::NoHealRequired => {
|
||||||
@@ -203,18 +69,11 @@ impl ECStore {
|
|||||||
r.set_count += result.set_count;
|
r.set_count += result.set_count;
|
||||||
r.before.drives.append(&mut result.before.drives);
|
r.before.drives.append(&mut result.before.drives);
|
||||||
r.after.drives.append(&mut result.after.drives);
|
r.after.drives.append(&mut result.after.drives);
|
||||||
|
|
||||||
// A lease can be lost after the final write; fail closed before
|
|
||||||
// reporting the pool as successfully healed.
|
|
||||||
if pool_guard.is_lock_lost() || rebalance_guard.is_lock_lost() {
|
|
||||||
first_error.get_or_insert(heal_format_fence_lost_error());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(err) = first_error {
|
if let Some(err) = first_error {
|
||||||
return Ok((r, Some(err)));
|
return Ok((r, Some(err)));
|
||||||
}
|
}
|
||||||
if count_no_heal + count_completed == self.pools.len() {
|
if count_no_heal == self.pools.len() {
|
||||||
info!(
|
info!(
|
||||||
event = EVENT_HEAL_FORMAT_COMPLETED,
|
event = EVENT_HEAL_FORMAT_COMPLETED,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -443,7 +302,6 @@ mod tests {
|
|||||||
use crate::disk::{DeleteOptions, DiskOption, format::FormatV3, new_disk};
|
use crate::disk::{DeleteOptions, DiskOption, format::FormatV3, new_disk};
|
||||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||||
use crate::runtime::instance::InstanceContext;
|
use crate::runtime::instance::InstanceContext;
|
||||||
use crate::services::rebalance::{RebalanceInfo, RebalanceStats};
|
|
||||||
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations};
|
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations};
|
||||||
use crate::store::init_format::{load_format_erasure, save_format_file};
|
use crate::store::init_format::{load_format_erasure, save_format_file};
|
||||||
@@ -495,164 +353,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pool_meta_with_decommission(info: PoolDecommissionInfo) -> PoolMeta {
|
|
||||||
PoolMeta {
|
|
||||||
pools: vec![PoolStatus {
|
|
||||||
id: 0,
|
|
||||||
cmd_line: "pool-0".to_string(),
|
|
||||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
||||||
decommission: Some(info),
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heal_format_pool_state_barriers_are_classified() {
|
|
||||||
let active = pool_meta_with_decommission(PoolDecommissionInfo {
|
|
||||||
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &active, None),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
for info in [
|
|
||||||
PoolDecommissionInfo {
|
|
||||||
failed: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
PoolDecommissionInfo {
|
|
||||||
canceled: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
] {
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &pool_meta_with_decommission(info), None),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let completed = pool_meta_with_decommission(PoolDecommissionInfo {
|
|
||||||
complete: true,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &completed, None),
|
|
||||||
Some(HealFormatPoolSkip::Completed)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heal_format_pool_rebalance_barriers_and_identity_are_fail_closed() {
|
|
||||||
let identity_meta = pool_meta_with_decommission(PoolDecommissionInfo::default());
|
|
||||||
let rebalance = RebalanceMeta {
|
|
||||||
pool_stats: vec![RebalanceStats {
|
|
||||||
participating: true,
|
|
||||||
info: RebalanceInfo {
|
|
||||||
status: RebalStatus::Started,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&rebalance)),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
let stopping = RebalanceMeta {
|
|
||||||
pool_stats: vec![RebalanceStats {
|
|
||||||
info: RebalanceInfo {
|
|
||||||
stopping: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&stopping)),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
let identity = pool_meta_with_decommission(PoolDecommissionInfo::default());
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-new", &identity, None),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
let identity_without_decommission = PoolMeta {
|
|
||||||
pools: vec![PoolStatus {
|
|
||||||
id: 0,
|
|
||||||
cmd_line: "pool-0".to_string(),
|
|
||||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
||||||
decommission: None,
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-new", &identity_without_decommission, None),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "", &identity_meta, None),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &PoolMeta::default(), None),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
|
|
||||||
let stopped = RebalanceMeta {
|
|
||||||
stopped_at: Some(OffsetDateTime::UNIX_EPOCH),
|
|
||||||
pool_stats: vec![RebalanceStats {
|
|
||||||
participating: true,
|
|
||||||
info: RebalanceInfo {
|
|
||||||
status: RebalStatus::Stopped,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&stopped)).is_none());
|
|
||||||
|
|
||||||
let stopping_after_stop = RebalanceMeta {
|
|
||||||
stopped_at: Some(OffsetDateTime::UNIX_EPOCH),
|
|
||||||
pool_stats: vec![RebalanceStats {
|
|
||||||
participating: true,
|
|
||||||
info: RebalanceInfo {
|
|
||||||
status: RebalStatus::Started,
|
|
||||||
stopping: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
classify_heal_format_pool(0, "pool-0", &identity_meta, Some(&stopping_after_stop)),
|
|
||||||
Some(HealFormatPoolSkip::Retryable)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skipped_heal_format_pool_is_never_reported_as_success() {
|
|
||||||
assert!(matches!(
|
|
||||||
heal_format_pool_skip_error(HealFormatPoolSkip::Retryable),
|
|
||||||
StorageError::SlowDown
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
heal_format_pool_skip_error(HealFormatPoolSkip::Completed),
|
|
||||||
StorageError::NoHealRequired
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn multi_pool_heal_store() -> (tempfile::TempDir, Arc<ECStore>, CancellationToken) {
|
async fn multi_pool_heal_store() -> (tempfile::TempDir, Arc<ECStore>, CancellationToken) {
|
||||||
let temp_dir = tempfile::tempdir().expect("multi-pool heal test directory should be created");
|
let temp_dir = tempfile::tempdir().expect("multi-pool heal test directory should be created");
|
||||||
let mut pool_endpoints = Vec::new();
|
let mut pool_endpoints = Vec::new();
|
||||||
@@ -1189,18 +889,6 @@ mod tests {
|
|||||||
bucket_fence_registry: std::sync::Arc::default(),
|
bucket_fence_registry: std::sync::Arc::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = store
|
|
||||||
.handle_heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect_err("missing pool metadata must fail closed before format writes");
|
|
||||||
assert!(matches!(err, StorageError::SlowDown));
|
|
||||||
|
|
||||||
let pool_meta = PoolMeta::new(&store.pools, &PoolMeta::default());
|
|
||||||
pool_meta
|
|
||||||
.save(store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("pool metadata should be persisted before format heal");
|
|
||||||
|
|
||||||
let (result, err) = store
|
let (result, err) = store
|
||||||
.handle_heal_format(false)
|
.handle_heal_format(false)
|
||||||
.await
|
.await
|
||||||
@@ -1214,22 +902,5 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("the later pool should be healed despite the first pool error");
|
.expect("the later pool should be healed despite the first pool error");
|
||||||
assert_eq!(healed.erasure.this, recoverable_format.erasure.sets[0][2]);
|
assert_eq!(healed.erasure.this, recoverable_format.erasure.sets[0][2]);
|
||||||
|
|
||||||
let mut completed_meta = PoolMeta::new(&store.pools, &PoolMeta::default());
|
|
||||||
for status in &mut completed_meta.pools {
|
|
||||||
status.decommission = Some(PoolDecommissionInfo {
|
|
||||||
complete: true,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
completed_meta
|
|
||||||
.save(store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("completed pool metadata should be persisted");
|
|
||||||
let (_, err) = store
|
|
||||||
.handle_heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("completed pools should be reported as a no-op");
|
|
||||||
assert!(matches!(err, Some(StorageError::NoHealRequired)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ use crate::bucket::utils::check_put_object_part_args;
|
|||||||
use crate::bucket::utils::{check_valid_bucket_name, check_valid_bucket_name_strict, is_meta_bucketname};
|
use crate::bucket::utils::{check_valid_bucket_name, check_valid_bucket_name_strict, is_meta_bucketname};
|
||||||
use crate::cluster::rpc::{RemoteClient, S3PeerSys};
|
use crate::cluster::rpc::{RemoteClient, S3PeerSys};
|
||||||
use crate::config::storageclass;
|
use crate::config::storageclass;
|
||||||
use crate::core::pools::{DecommissionCanceler, PoolMeta};
|
use crate::core::pools::PoolMeta;
|
||||||
use crate::disk::endpoint::{Endpoint, EndpointType};
|
use crate::disk::endpoint::{Endpoint, EndpointType};
|
||||||
use crate::disk::{DiskAPI, DiskInfo, DiskInfoOptions};
|
use crate::disk::{DiskAPI, DiskInfo, DiskInfoOptions};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -151,7 +151,7 @@ pub(crate) mod init_format;
|
|||||||
pub(crate) mod list_objects;
|
pub(crate) mod list_objects;
|
||||||
mod multipart;
|
mod multipart;
|
||||||
mod object;
|
mod object;
|
||||||
pub(crate) use object::{ObjectLockDiagGuard, SourceCleanupMutationFence};
|
pub(crate) use object::ObjectLockDiagGuard;
|
||||||
pub use object::{
|
pub use object::{
|
||||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||||
SnapshotConsistencyError,
|
SnapshotConsistencyError,
|
||||||
@@ -176,7 +176,7 @@ pub struct ECStore {
|
|||||||
// pub local_disks: Vec<DiskStore>,
|
// pub local_disks: Vec<DiskStore>,
|
||||||
pub pool_meta: RwLock<PoolMeta>,
|
pub pool_meta: RwLock<PoolMeta>,
|
||||||
pub rebalance_meta: RwLock<Option<RebalanceMeta>>,
|
pub rebalance_meta: RwLock<Option<RebalanceMeta>>,
|
||||||
pub decommission_cancelers: RwLock<Vec<Option<DecommissionCanceler>>>,
|
pub decommission_cancelers: RwLock<Vec<Option<CancellationToken>>>,
|
||||||
/// Serializes rebalance/decommission start transitions.
|
/// Serializes rebalance/decommission start transitions.
|
||||||
///
|
///
|
||||||
/// Lock order: acquire `start_gate` before `pool_meta`, `rebalance_meta`,
|
/// Lock order: acquire `start_gate` before `pool_meta`, `rebalance_meta`,
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ impl ECStore {
|
|||||||
object: &str,
|
object: &str,
|
||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
) -> Result<MultipartUploadResult> {
|
) -> Result<MultipartUploadResult> {
|
||||||
self.handle_new_multipart_upload_with_pool_idx(bucket, object, opts, None)
|
self.handle_new_multipart_upload_with_pool_idx(bucket, object, opts)
|
||||||
.await
|
.await
|
||||||
.map(|(res, _, _)| res)
|
.map(|(res, _, _)| res)
|
||||||
}
|
}
|
||||||
@@ -410,22 +410,20 @@ impl ECStore {
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
|
||||||
) -> Result<(MultipartUploadResult, usize, Option<Uuid>)> {
|
) -> Result<(MultipartUploadResult, usize, Option<Uuid>)> {
|
||||||
check_new_multipart_args(bucket, object)?;
|
check_new_multipart_args(bucket, object)?;
|
||||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||||
|
let opts = &opts;
|
||||||
|
|
||||||
if self.single_pool() {
|
if self.single_pool() {
|
||||||
self.apply_decommission_target_mutation_fence(0, object, &mut opts, mutation_fence)
|
|
||||||
.await;
|
|
||||||
return self.pools[0]
|
return self.pools[0]
|
||||||
.new_multipart_upload(bucket, object, &opts)
|
.new_multipart_upload(bucket, object, opts)
|
||||||
.await
|
.await
|
||||||
.map(|res| (res, 0, opts.expected_bucket_incarnation_id));
|
.map(|res| (res, 0, opts.expected_bucket_incarnation_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.data_movement && opts.version_id.is_some() {
|
if opts.data_movement && opts.version_id.is_some() {
|
||||||
let idx = self.select_data_movement_pool_idx(bucket, object, -1, &opts, false).await?;
|
let idx = self.select_data_movement_pool_idx(bucket, object, -1, opts, false).await?;
|
||||||
if idx == opts.src_pool_idx {
|
if idx == opts.src_pool_idx {
|
||||||
return Err(StorageError::DataMovementOverwriteErr(
|
return Err(StorageError::DataMovementOverwriteErr(
|
||||||
bucket.to_owned(),
|
bucket.to_owned(),
|
||||||
@@ -433,9 +431,7 @@ impl ECStore {
|
|||||||
opts.version_id.clone().unwrap_or_default(),
|
opts.version_id.clone().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||||
.await;
|
|
||||||
let res = self.pools[idx].new_multipart_upload(bucket, object, &opts).await?;
|
|
||||||
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +454,7 @@ impl ECStore {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !res.uploads.is_empty() {
|
if !res.uploads.is_empty() {
|
||||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||||
.await;
|
|
||||||
let res = self.pools[idx].new_multipart_upload(bucket, object, &opts).await?;
|
|
||||||
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -473,9 +467,7 @@ impl ECStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||||
.await;
|
|
||||||
let res = self.pools[idx].new_multipart_upload(bucket, object, &opts).await?;
|
|
||||||
Ok((res, idx, opts.expected_bucket_incarnation_id))
|
Ok((res, idx, opts.expected_bucket_incarnation_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,14 +704,13 @@ impl ECStore {
|
|||||||
|
|
||||||
pub(crate) async fn complete_multipart_upload_for_data_movement(
|
pub(crate) async fn complete_multipart_upload_for_data_movement(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
target: (usize, Option<&ObjectLockDiagGuard>),
|
target_pool_idx: usize,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
upload_id: &str,
|
upload_id: &str,
|
||||||
uploaded_parts: Vec<CompletePart>,
|
uploaded_parts: Vec<CompletePart>,
|
||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
) -> Result<ObjectInfo> {
|
) -> Result<ObjectInfo> {
|
||||||
let (target_pool_idx, mutation_fence) = target;
|
|
||||||
check_complete_multipart_args(bucket, object, upload_id)?;
|
check_complete_multipart_args(bucket, object, upload_id)?;
|
||||||
if !opts.data_movement {
|
if !opts.data_movement {
|
||||||
return Err(Error::other("targeted multipart completion requires data_movement options"));
|
return Err(Error::other("targeted multipart completion requires data_movement options"));
|
||||||
@@ -748,8 +739,6 @@ impl ECStore {
|
|||||||
snapshot.add_lock_fences(&mut opts);
|
snapshot.add_lock_fences(&mut opts);
|
||||||
opts.object_lock_config_snapshot = Some(snapshot);
|
opts.object_lock_config_snapshot = Some(snapshot);
|
||||||
}
|
}
|
||||||
self.apply_decommission_target_mutation_fence(target_pool_idx, object, &mut opts, mutation_fence)
|
|
||||||
.await;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pause_data_movement_multipart_before_selected_completion(bucket).await;
|
pause_data_movement_multipart_before_selected_completion(bucket).await;
|
||||||
let pool = self
|
let pool = self
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ use crate::bucket::metadata_sys::{
|
|||||||
use crate::bucket::object_lock::objectlock_sys::{
|
use crate::bucket::object_lock::objectlock_sys::{
|
||||||
check_object_lock_for_deletion_with_state, ensure_recursive_force_delete_allowed_for_state,
|
check_object_lock_for_deletion_with_state, ensure_recursive_force_delete_allowed_for_state,
|
||||||
};
|
};
|
||||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
use crate::bucket::replication::ReplicationObjectBridge;
|
||||||
use crate::bucket::versioning::VersioningApi;
|
|
||||||
use crate::disk::OldCurrentSize;
|
use crate::disk::OldCurrentSize;
|
||||||
use crate::object_api::{NamespaceLockFence, ObjectLockConfigSnapshot};
|
use crate::object_api::{NamespaceLockFence, ObjectLockConfigSnapshot};
|
||||||
use crate::set_disk::{
|
use crate::set_disk::{
|
||||||
SetDisks, get_lock_acquire_timeout, get_object_lock_diag_slow_acquire_threshold, get_object_lock_diag_slow_hold_threshold,
|
get_lock_acquire_timeout, get_object_lock_diag_slow_acquire_threshold, get_object_lock_diag_slow_hold_threshold,
|
||||||
is_lock_optimization_enabled, is_object_lock_diag_enabled, same_distributed_lock_domain,
|
is_lock_optimization_enabled, is_object_lock_diag_enabled,
|
||||||
};
|
};
|
||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
namespace::NamespaceLocking as _,
|
namespace::NamespaceLocking as _,
|
||||||
@@ -353,8 +352,6 @@ impl fmt::Display for ObjectLockDiagMode {
|
|||||||
|
|
||||||
pub(crate) struct ObjectLockDiagGuard {
|
pub(crate) struct ObjectLockDiagGuard {
|
||||||
guard: rustfs_lock::NamespaceLockGuard,
|
guard: rustfs_lock::NamespaceLockGuard,
|
||||||
#[cfg(test)]
|
|
||||||
test_namespace_lock_fence: Option<NamespaceLockFence>,
|
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
op: &'static str,
|
op: &'static str,
|
||||||
bucket: Option<String>,
|
bucket: Option<String>,
|
||||||
@@ -376,8 +373,6 @@ impl ObjectLockDiagGuard {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
guard,
|
guard,
|
||||||
#[cfg(test)]
|
|
||||||
test_namespace_lock_fence: None,
|
|
||||||
enabled,
|
enabled,
|
||||||
op,
|
op,
|
||||||
bucket,
|
bucket,
|
||||||
@@ -398,115 +393,6 @@ impl ObjectLockDiagGuard {
|
|||||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||||
self.guard.is_lock_lost()
|
self.guard.is_lock_lost()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
|
||||||
opts.ensure_namespace_lock_fence();
|
|
||||||
if let Some(signal) = self.lock_lost_signal() {
|
|
||||||
opts.add_namespace_lock_lost_signal(signal);
|
|
||||||
}
|
|
||||||
#[cfg(test)]
|
|
||||||
if let Some(fence) = self.test_namespace_lock_fence.as_ref() {
|
|
||||||
opts.add_namespace_lock_fence_for_test(fence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(crate) enum DecommissionMutationFenceTestPhase {
|
|
||||||
Migration,
|
|
||||||
SourceCleanup,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
struct DecommissionMutationFenceLossState {
|
|
||||||
bucket: String,
|
|
||||||
object: String,
|
|
||||||
phase: DecommissionMutationFenceTestPhase,
|
|
||||||
fence: NamespaceLockFence,
|
|
||||||
loss_handle: Arc<std::sync::atomic::AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) struct DecommissionMutationFenceLossHook {
|
|
||||||
state: Arc<DecommissionMutationFenceLossState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
static DECOMMISSION_MUTATION_FENCE_LOSS_HOOK: std::sync::OnceLock<
|
|
||||||
std::sync::Mutex<Option<Arc<DecommissionMutationFenceLossState>>>,
|
|
||||||
> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl DecommissionMutationFenceLossHook {
|
|
||||||
pub(crate) fn install(bucket: &str, object: &str, phase: DecommissionMutationFenceTestPhase) -> Self {
|
|
||||||
let (fence, loss_handle) = NamespaceLockFence::loss_handle_for_test();
|
|
||||||
let state = Arc::new(DecommissionMutationFenceLossState {
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
object: object.to_string(),
|
|
||||||
phase,
|
|
||||||
fence,
|
|
||||||
loss_handle,
|
|
||||||
});
|
|
||||||
let mut slot = DECOMMISSION_MUTATION_FENCE_LOSS_HOOK
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("decommission mutation fence loss hooks should not poison");
|
|
||||||
assert!(slot.is_none(), "decommission mutation fence loss hook must be unique");
|
|
||||||
*slot = Some(Arc::clone(&state));
|
|
||||||
Self { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn mark_lost(&self) {
|
|
||||||
self.state.loss_handle.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Drop for DecommissionMutationFenceLossHook {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mut slot = DECOMMISSION_MUTATION_FENCE_LOSS_HOOK
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("decommission mutation fence loss hooks should not poison");
|
|
||||||
if slot.as_ref().is_some_and(|hook| Arc::ptr_eq(hook, &self.state)) {
|
|
||||||
*slot = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn decommission_mutation_fence_for_test(
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
phase: DecommissionMutationFenceTestPhase,
|
|
||||||
) -> Option<NamespaceLockFence> {
|
|
||||||
DECOMMISSION_MUTATION_FENCE_LOSS_HOOK
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("decommission mutation fence loss hooks should not poison")
|
|
||||||
.as_ref()
|
|
||||||
.filter(|hook| hook.bucket == bucket && hook.object == object && hook.phase == phase)
|
|
||||||
.map(|hook| hook.fence.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct SourceCleanupMutationFence {
|
|
||||||
guard: ObjectLockDiagGuard,
|
|
||||||
source_lock_covered: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SourceCleanupMutationFence {
|
|
||||||
pub(crate) fn source_lock_covered(&self) -> bool {
|
|
||||||
self.source_lock_covered
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
|
||||||
self.guard.is_lock_lost()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
|
||||||
self.guard.add_namespace_lock_fence(opts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque write-lock guard for the RestoreObject accept path; see
|
/// Opaque write-lock guard for the RestoreObject accept path; see
|
||||||
@@ -524,7 +410,10 @@ impl RestoreAcceptGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
pub fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
||||||
self.0.add_namespace_lock_fence(opts);
|
opts.ensure_namespace_lock_fence();
|
||||||
|
if let Some(signal) = self.0.lock_lost_signal() {
|
||||||
|
opts.add_namespace_lock_lost_signal(signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +690,16 @@ impl SelectObjectSnapshotLockLossWake {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LockRegistry clones its canonical client Arc for each endpoint host, so an
|
||||||
|
// exact Arc set identifies one distributed namespace-lock quorum domain.
|
||||||
|
fn same_distributed_lock_domain(left: &[Arc<dyn rustfs_lock::LockClient>], right: &[Arc<dyn rustfs_lock::LockClient>]) -> bool {
|
||||||
|
left.iter()
|
||||||
|
.all(|left_client| right.iter().any(|right_client| Arc::ptr_eq(left_client, right_client)))
|
||||||
|
&& right
|
||||||
|
.iter()
|
||||||
|
.all(|right_client| left.iter().any(|left_client| Arc::ptr_eq(left_client, right_client)))
|
||||||
|
}
|
||||||
|
|
||||||
impl AsyncRead for SelectObjectSnapshotReader {
|
impl AsyncRead for SelectObjectSnapshotReader {
|
||||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||||
if self.lock_loss_wake.poll_lost(cx) || self.lease.is_lost() {
|
if self.lock_loss_wake.poll_lost(cx) || self.lease.is_lost() {
|
||||||
@@ -906,7 +805,7 @@ fn resolve_latest_object_access(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_create_delete_marker_for_missing_object(opts: &ObjectOptions) -> bool {
|
fn should_create_delete_marker_for_missing_object(opts: &ObjectOptions) -> bool {
|
||||||
(opts.versioned || opts.version_suspended) && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
|
opts.versioned && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -914,8 +813,6 @@ struct DeleteAfterObjectLockSnapshotBarrierState {
|
|||||||
bucket: String,
|
bucket: String,
|
||||||
arrived: tokio::sync::Notify,
|
arrived: tokio::sync::Notify,
|
||||||
release: tokio::sync::Notify,
|
release: tokio::sync::Notify,
|
||||||
namespace_pending: tokio::sync::Notify,
|
|
||||||
namespace_acquired: AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -935,8 +832,6 @@ impl DeleteAfterObjectLockSnapshotBarrier {
|
|||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
arrived: tokio::sync::Notify::new(),
|
arrived: tokio::sync::Notify::new(),
|
||||||
release: tokio::sync::Notify::new(),
|
release: tokio::sync::Notify::new(),
|
||||||
namespace_pending: tokio::sync::Notify::new(),
|
|
||||||
namespace_acquired: AtomicBool::new(false),
|
|
||||||
});
|
});
|
||||||
let mut slot = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
let mut slot = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
@@ -954,18 +849,6 @@ impl DeleteAfterObjectLockSnapshotBarrier {
|
|||||||
pub(crate) fn release(&self) {
|
pub(crate) fn release(&self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn release_and_wait_until_namespace_pending(&self) {
|
|
||||||
let namespace_pending = self.state.namespace_pending.notified();
|
|
||||||
self.release();
|
|
||||||
tokio::time::timeout(Duration::from_secs(5), namespace_pending)
|
|
||||||
.await
|
|
||||||
.expect("delete should proceed to its namespace lock after leaving the snapshot barrier");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn namespace_acquired(&self) -> bool {
|
|
||||||
self.state.namespace_acquired.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -990,97 +873,6 @@ async fn pause_delete_after_object_lock_snapshot(bucket: &str) {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|state| state.bucket == bucket)
|
.filter(|state| state.bucket == bucket)
|
||||||
.cloned();
|
.cloned();
|
||||||
if let Some(state) = state {
|
|
||||||
state.arrived.notify_one();
|
|
||||||
state.release.notified().await;
|
|
||||||
state.namespace_pending.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn notify_delete_namespace_acquired(bucket: &str) {
|
|
||||||
let state = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("delete snapshot barrier mutex should not poison")
|
|
||||||
.as_ref()
|
|
||||||
.filter(|state| state.bucket == bucket)
|
|
||||||
.cloned();
|
|
||||||
if let Some(state) = state {
|
|
||||||
state.namespace_acquired.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
struct VersionedDeleteMarkerCommitBarrierState {
|
|
||||||
bucket: String,
|
|
||||||
object: String,
|
|
||||||
arrived: tokio::sync::Notify,
|
|
||||||
release: tokio::sync::Notify,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) struct VersionedDeleteMarkerCommitBarrier {
|
|
||||||
state: Arc<VersionedDeleteMarkerCommitBarrierState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
static VERSIONED_DELETE_MARKER_COMMIT_BARRIER: std::sync::OnceLock<
|
|
||||||
std::sync::Mutex<Option<Arc<VersionedDeleteMarkerCommitBarrierState>>>,
|
|
||||||
> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl VersionedDeleteMarkerCommitBarrier {
|
|
||||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
|
||||||
let state = Arc::new(VersionedDeleteMarkerCommitBarrierState {
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
object: object.to_string(),
|
|
||||||
arrived: tokio::sync::Notify::new(),
|
|
||||||
release: tokio::sync::Notify::new(),
|
|
||||||
});
|
|
||||||
let mut slot = VERSIONED_DELETE_MARKER_COMMIT_BARRIER
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("versioned delete-marker commit barrier mutex should not poison");
|
|
||||||
assert!(slot.is_none(), "versioned delete-marker commit barrier must be unique");
|
|
||||||
*slot = Some(Arc::clone(&state));
|
|
||||||
Self { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn wait_until_paused(&self) {
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
|
||||||
.await
|
|
||||||
.expect("versioned DELETE should reach the post-marker-commit barrier");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn release(&self) {
|
|
||||||
self.state.release.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Drop for VersionedDeleteMarkerCommitBarrier {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.state.release.notify_one();
|
|
||||||
let mut slot = VERSIONED_DELETE_MARKER_COMMIT_BARRIER
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("versioned delete-marker commit barrier mutex should not poison");
|
|
||||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
||||||
*slot = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
async fn pause_versioned_delete_marker_after_commit(bucket: &str, object: &str) {
|
|
||||||
let state = VERSIONED_DELETE_MARKER_COMMIT_BARRIER
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("versioned delete-marker commit barrier mutex should not poison")
|
|
||||||
.as_ref()
|
|
||||||
.filter(|state| state.bucket == bucket && state.object == object)
|
|
||||||
.cloned();
|
|
||||||
if let Some(state) = state {
|
if let Some(state) = state {
|
||||||
state.arrived.notify_one();
|
state.arrived.notify_one();
|
||||||
state.release.notified().await;
|
state.release.notified().await;
|
||||||
@@ -1121,160 +913,6 @@ fn writer_pool_lookup_opts(opts: &ObjectOptions, no_lock: bool) -> ObjectOptions
|
|||||||
lookup_opts
|
lookup_opts
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_pool_lookup_opts(opts: &ObjectOptions, no_lock: bool) -> ObjectOptions {
|
|
||||||
let mut lookup_opts = writer_pool_lookup_opts(opts, no_lock);
|
|
||||||
lookup_opts.skip_decommissioned = opts.data_movement;
|
|
||||||
lookup_opts
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_delete_from_all_pools(opts: &ObjectOptions, pool_count: usize) -> bool {
|
|
||||||
pool_count > 0 && (!opts.versioned && !opts.version_suspended || opts.version_id.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_delete_creates_latest_marker(object: &ObjectToDelete, delete_config_snapshot: &DeleteReplicationConfigSnapshot) -> bool {
|
|
||||||
if object.version_id.is_some() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let object_name = decode_dir_object(&object.object_name);
|
|
||||||
let (versioned, version_suspended) = delete_config_snapshot.versioning_config().delete_state(&object_name);
|
|
||||||
versioned || version_suspended
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_delete_targets_pool(creates_latest_marker: bool, marker_target_pool_idx: Option<usize>, pool_idx: usize) -> bool {
|
|
||||||
!creates_latest_marker || marker_target_pool_idx == Some(pool_idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
struct BatchDeletePoolErrorInjectionState {
|
|
||||||
bucket: String,
|
|
||||||
pool_idx: usize,
|
|
||||||
errors: std::collections::HashMap<String, Error>,
|
|
||||||
observed: std::sync::atomic::AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) struct BatchDeletePoolErrorInjection {
|
|
||||||
state: Arc<BatchDeletePoolErrorInjectionState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
static BATCH_DELETE_POOL_ERROR_INJECTION: std::sync::OnceLock<std::sync::Mutex<Option<Arc<BatchDeletePoolErrorInjectionState>>>> =
|
|
||||||
std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl BatchDeletePoolErrorInjection {
|
|
||||||
pub(crate) fn install(bucket: &str, pool_idx: usize, errors: Vec<(String, Error)>) -> Self {
|
|
||||||
let state = Arc::new(BatchDeletePoolErrorInjectionState {
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
pool_idx,
|
|
||||||
errors: errors.into_iter().collect(),
|
|
||||||
observed: std::sync::atomic::AtomicUsize::new(0),
|
|
||||||
});
|
|
||||||
let mut slot = BATCH_DELETE_POOL_ERROR_INJECTION
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("batch delete pool error injection mutex should not poison");
|
|
||||||
assert!(slot.is_none(), "batch delete pool error injection must be unique");
|
|
||||||
*slot = Some(Arc::clone(&state));
|
|
||||||
Self { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn observed(&self) -> usize {
|
|
||||||
self.state.observed.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl Drop for BatchDeletePoolErrorInjection {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mut slot = BATCH_DELETE_POOL_ERROR_INJECTION
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("batch delete pool error injection mutex should not poison");
|
|
||||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
|
||||||
*slot = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn inject_batch_delete_pool_errors(
|
|
||||||
bucket: &str,
|
|
||||||
pool_idx: usize,
|
|
||||||
object_names: &[String],
|
|
||||||
result: &mut (Vec<DeletedObject>, Vec<Option<Error>>),
|
|
||||||
) {
|
|
||||||
let state = BATCH_DELETE_POOL_ERROR_INJECTION
|
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
|
||||||
.lock()
|
|
||||||
.expect("batch delete pool error injection mutex should not poison")
|
|
||||||
.as_ref()
|
|
||||||
.filter(|state| state.bucket == bucket && state.pool_idx == pool_idx)
|
|
||||||
.cloned();
|
|
||||||
let Some(state) = state else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (idx, object_name) in object_names.iter().enumerate() {
|
|
||||||
let Some(error) = state.errors.get(object_name) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if result.1[idx].is_none() && result.0[idx].found {
|
|
||||||
result.1[idx] = Some(error.clone());
|
|
||||||
state.observed.fetch_add(1, Ordering::AcqRel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_batch_delete_pool_results<'a>(
|
|
||||||
initial_error: Option<Error>,
|
|
||||||
pool_results: impl IntoIterator<Item = (&'a DeletedObject, &'a Option<Error>)>,
|
|
||||||
) -> (Option<DeletedObject>, Option<Error>, bool) {
|
|
||||||
let mut failure = initial_error.map(|err| (None, err));
|
|
||||||
let mut deleted = None;
|
|
||||||
let mut fallback: Option<(DeletedObject, Option<Error>)> = None;
|
|
||||||
let mut attempted = false;
|
|
||||||
|
|
||||||
for (pool_delete, pool_error) in pool_results {
|
|
||||||
attempted = true;
|
|
||||||
match pool_error {
|
|
||||||
Some(err) if is_err_object_not_found(err) || is_err_version_not_found(err) => {
|
|
||||||
if fallback.as_ref().is_none_or(|(_, error)| error.is_none()) {
|
|
||||||
fallback = Some(((*pool_delete).clone(), Some(err.clone())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(err) => {
|
|
||||||
if failure.is_none() {
|
|
||||||
failure = Some((Some((*pool_delete).clone()), err.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None if pool_delete.found => {
|
|
||||||
if deleted.is_none() {
|
|
||||||
deleted = Some((*pool_delete).clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if fallback.is_none() {
|
|
||||||
fallback = Some(((*pool_delete).clone(), None));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((failed_delete, err)) = failure {
|
|
||||||
return (failed_delete, Some(err), attempted);
|
|
||||||
}
|
|
||||||
if let Some(deleted) = deleted {
|
|
||||||
return (Some(deleted), None, attempted);
|
|
||||||
}
|
|
||||||
if let Some((deleted, err)) = fallback {
|
|
||||||
return (Some(deleted), err, attempted);
|
|
||||||
}
|
|
||||||
|
|
||||||
(None, None, attempted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transition_restore_pool_opts(opts: &ObjectOptions) -> ObjectOptions {
|
fn transition_restore_pool_opts(opts: &ObjectOptions) -> ObjectOptions {
|
||||||
let mut lookup_opts = opts.clone();
|
let mut lookup_opts = opts.clone();
|
||||||
lookup_opts.skip_decommissioned = true;
|
lookup_opts.skip_decommissioned = true;
|
||||||
@@ -1903,89 +1541,6 @@ impl ECStore {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn acquire_decommission_object_mutation_fence(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
) -> Result<ObjectLockDiagGuard> {
|
|
||||||
if self.ctx.lock_manager().is_disabled() {
|
|
||||||
return Err(Error::other("decommission object migration requires namespace locking"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
let test_namespace_lock_fence =
|
|
||||||
decommission_mutation_fence_for_test(bucket, object, DecommissionMutationFenceTestPhase::Migration);
|
|
||||||
let object = encode_dir_object(object);
|
|
||||||
let mut opts = ObjectOptions::default();
|
|
||||||
let guard = self
|
|
||||||
.acquire_object_read_lock_if_needed("decommission_object", bucket, &object, &mut opts)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::other("decommission object migration failed to acquire its namespace fence"))?;
|
|
||||||
#[cfg(test)]
|
|
||||||
let guard = {
|
|
||||||
let mut guard = guard;
|
|
||||||
guard.test_namespace_lock_fence = test_namespace_lock_fence;
|
|
||||||
guard
|
|
||||||
};
|
|
||||||
Ok(guard)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn apply_decommission_target_mutation_fence(
|
|
||||||
&self,
|
|
||||||
target_pool_idx: usize,
|
|
||||||
object: &str,
|
|
||||||
opts: &mut ObjectOptions,
|
|
||||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
|
||||||
) {
|
|
||||||
let Some(mutation_fence) = mutation_fence else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
mutation_fence.add_namespace_lock_fence(opts);
|
|
||||||
let fixed_set = self.pools.first().and_then(|pool| pool.disk_set.first());
|
|
||||||
let target_set = self.pools.get(target_pool_idx).map(|pool| pool.get_disks_by_key(object));
|
|
||||||
opts.no_lock = match (fixed_set, target_set) {
|
|
||||||
(Some(fixed), Some(target)) => fixed.shares_namespace_lock_domain(&target).await,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn acquire_decommission_source_cleanup_fence(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
source_set: &SetDisks,
|
|
||||||
) -> Result<SourceCleanupMutationFence> {
|
|
||||||
if self.ctx.lock_manager().is_disabled() {
|
|
||||||
return Err(Error::other("decommission source cleanup requires namespace locking"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
crate::data_movement::notify_source_cleanup_mutation_fence_pending(bucket, object);
|
|
||||||
#[cfg(test)]
|
|
||||||
let test_namespace_lock_fence =
|
|
||||||
decommission_mutation_fence_for_test(bucket, object, DecommissionMutationFenceTestPhase::SourceCleanup);
|
|
||||||
let object = encode_dir_object(object);
|
|
||||||
let fixed_set = Arc::clone(&self.pools[0].disk_set[0]);
|
|
||||||
let source_lock_covered = fixed_set.shares_namespace_lock_domain(source_set).await;
|
|
||||||
// Lock order: fixed store mutation domain first; source cleanup takes its
|
|
||||||
// hashed source-domain lock second only when this guard does not cover it.
|
|
||||||
let guard = self
|
|
||||||
.acquire_object_write_lock("decommission_source_cleanup", bucket, &object)
|
|
||||||
.await?;
|
|
||||||
#[cfg(test)]
|
|
||||||
let guard = {
|
|
||||||
let mut guard = guard;
|
|
||||||
guard.test_namespace_lock_fence = test_namespace_lock_fence;
|
|
||||||
guard
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(SourceCleanupMutationFence {
|
|
||||||
guard,
|
|
||||||
source_lock_covered,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn acquire_all_object_read_locks(
|
pub(crate) async fn acquire_all_object_read_locks(
|
||||||
&self,
|
&self,
|
||||||
op: &'static str,
|
op: &'static str,
|
||||||
@@ -2439,17 +1994,14 @@ impl ECStore {
|
|||||||
object: &str,
|
object: &str,
|
||||||
data: &mut PutObjReader,
|
data: &mut PutObjReader,
|
||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
|
||||||
) -> Result<(usize, Result<ObjectInfo>)> {
|
) -> Result<(usize, Result<ObjectInfo>)> {
|
||||||
if !opts.data_movement {
|
if !opts.data_movement {
|
||||||
return Err(Error::other("data movement PUT requires data_movement options"));
|
return Err(Error::other("data movement PUT requires data_movement options"));
|
||||||
}
|
}
|
||||||
let (object, mut opts) = self.prepare_put_object(bucket, object, opts).await?;
|
let (object, opts) = self.prepare_put_object(bucket, object, opts).await?;
|
||||||
let idx = self
|
let idx = self
|
||||||
.select_put_object_pool_idx(bucket, object.as_str(), data.size(), &opts)
|
.select_put_object_pool_idx(bucket, object.as_str(), data.size(), &opts)
|
||||||
.await?;
|
.await?;
|
||||||
self.apply_decommission_target_mutation_fence(idx, object.as_str(), &mut opts, mutation_fence)
|
|
||||||
.await;
|
|
||||||
let result = self.pools[idx]
|
let result = self.pools[idx]
|
||||||
.put_object_with_old_current_size(bucket, &object, data, &opts)
|
.put_object_with_old_current_size(bucket, &object, data, &opts)
|
||||||
.await
|
.await
|
||||||
@@ -2918,10 +2470,6 @@ impl ECStore {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
if _object_lock_guard.is_some() {
|
|
||||||
notify_delete_namespace_acquired(bucket);
|
|
||||||
}
|
|
||||||
if let Some(trigger) = opts.lifecycle_delete_all.as_ref() {
|
if let Some(trigger) = opts.lifecycle_delete_all.as_ref() {
|
||||||
let configs = delete_all_configs.as_ref().ok_or(StorageError::PreconditionFailed)?;
|
let configs = delete_all_configs.as_ref().ok_or(StorageError::PreconditionFailed)?;
|
||||||
let expected_bucket_incarnation_id = opts.expected_bucket_incarnation_id.ok_or(StorageError::PreconditionFailed)?;
|
let expected_bucket_incarnation_id = opts.expected_bucket_incarnation_id.ok_or(StorageError::PreconditionFailed)?;
|
||||||
@@ -2955,7 +2503,7 @@ impl ECStore {
|
|||||||
return Ok(ObjectInfo::default());
|
return Ok(ObjectInfo::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
let gopts = delete_pool_lookup_opts(&opts, true);
|
let gopts = writer_pool_lookup_opts(&opts, true);
|
||||||
|
|
||||||
if opts.data_movement {
|
if opts.data_movement {
|
||||||
let existing_pool_info = self.get_pool_info_existing_with_opts(bucket, object, &gopts).await;
|
let existing_pool_info = self.get_pool_info_existing_with_opts(bucket, object, &gopts).await;
|
||||||
@@ -3060,8 +2608,6 @@ impl ECStore {
|
|||||||
Err(err) if is_err_object_not_found(&err) && should_create_delete_marker_for_missing_object(&opts) => {
|
Err(err) if is_err_object_not_found(&err) && should_create_delete_marker_for_missing_object(&opts) => {
|
||||||
let target_pool_idx = self.get_pool_idx_no_lock(bucket, object, 0).await?;
|
let target_pool_idx = self.get_pool_idx_no_lock(bucket, object, 0).await?;
|
||||||
let mut obj = self.pools[target_pool_idx].delete_object(bucket, object, opts).await?;
|
let mut obj = self.pools[target_pool_idx].delete_object(bucket, object, opts).await?;
|
||||||
#[cfg(test)]
|
|
||||||
pause_versioned_delete_marker_after_commit(bucket, object).await;
|
|
||||||
obj.name = decode_dir_object(object);
|
obj.name = decode_dir_object(object);
|
||||||
return Ok(obj);
|
return Ok(obj);
|
||||||
}
|
}
|
||||||
@@ -3100,7 +2646,7 @@ impl ECStore {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if should_delete_from_all_pools(&opts, errs.len()) {
|
if !errs.is_empty() && !opts.versioned && !opts.version_suspended {
|
||||||
let mut obj = match self.delete_object_from_all_pools(bucket, object, &opts, errs).await {
|
let mut obj = match self.delete_object_from_all_pools(bucket, object, &opts, errs).await {
|
||||||
Ok(obj) => obj,
|
Ok(obj) => obj,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -3124,8 +2670,6 @@ impl ECStore {
|
|||||||
|
|
||||||
match pool.delete_object(bucket, object, opts.clone()).await {
|
match pool.delete_object(bucket, object, opts.clone()).await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
#[cfg(test)]
|
|
||||||
pause_versioned_delete_marker_after_commit(bucket, object).await;
|
|
||||||
if let (Some(api), Some(je)) = (tier_journal_api.as_ref(), journal_entry.as_ref()) {
|
if let (Some(api), Some(je)) = (tier_journal_api.as_ref(), journal_entry.as_ref()) {
|
||||||
commit_prepared_tier_delete_journal_entry(api, je).await;
|
commit_prepared_tier_delete_journal_entry(api, je).await;
|
||||||
}
|
}
|
||||||
@@ -3273,104 +2817,32 @@ impl ECStore {
|
|||||||
Ok(guards) => guards,
|
Ok(guards) => guards,
|
||||||
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
if !_object_lock_guards.is_empty() {
|
|
||||||
notify_delete_namespace_acquired(bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
let delete_config_snapshot = opts
|
|
||||||
.delete_replication_config_snapshot
|
|
||||||
.as_deref()
|
|
||||||
.expect("batch delete replication config snapshot should be loaded");
|
|
||||||
let latest_marker_objects = objects
|
|
||||||
.iter()
|
|
||||||
.map(|object| batch_delete_creates_latest_marker(object, delete_config_snapshot))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let marker_target_results = join_all(objects.iter().zip(&latest_marker_objects).map(
|
|
||||||
|(object, creates_marker)| async move {
|
|
||||||
if *creates_marker {
|
|
||||||
Some(self.get_pool_idx_no_lock(bucket, &object.object_name, 0).await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
let mut marker_target_pool_indices = Vec::with_capacity(objects.len());
|
|
||||||
for (idx, target_result) in marker_target_results.into_iter().enumerate() {
|
|
||||||
match target_result {
|
|
||||||
Some(Ok(pool_idx)) => marker_target_pool_indices.push(Some(pool_idx)),
|
|
||||||
Some(Err(err)) => {
|
|
||||||
del_errs[idx] = Some(err);
|
|
||||||
marker_target_pool_indices.push(None);
|
|
||||||
}
|
|
||||||
None => marker_target_pool_indices.push(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut futures = Vec::with_capacity(self.pools.len());
|
let mut futures = Vec::with_capacity(self.pools.len());
|
||||||
|
|
||||||
for pool in self.pools.iter() {
|
for pool in self.pools.iter() {
|
||||||
if self.is_pool_rebalancing(pool.pool_idx).await {
|
if self.is_pool_rebalancing(pool.pool_idx).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
futures.push(pool.delete_objects_with_accounting(bucket, objects.clone(), opts.clone()));
|
||||||
let (object_indices, pool_objects): (Vec<_>, Vec<_>) = objects
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(idx, _)| {
|
|
||||||
batch_delete_targets_pool(latest_marker_objects[*idx], marker_target_pool_indices[*idx], pool.pool_idx)
|
|
||||||
})
|
|
||||||
.map(|(idx, object)| (idx, object.clone()))
|
|
||||||
.unzip();
|
|
||||||
if pool_objects.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool_opts = opts.clone();
|
|
||||||
futures.push(async move {
|
|
||||||
#[cfg(test)]
|
|
||||||
let pool_object_names = pool_objects
|
|
||||||
.iter()
|
|
||||||
.map(|object| object.object_name.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let result = pool.delete_objects(bucket, pool_objects, pool_opts).await;
|
|
||||||
#[cfg(test)]
|
|
||||||
let result = {
|
|
||||||
let mut result = result;
|
|
||||||
inject_batch_delete_pool_errors(bucket, pool.pool_idx, &pool_object_names, &mut result);
|
|
||||||
result
|
|
||||||
};
|
|
||||||
(object_indices, result)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = join_all(futures).await;
|
let results = join_all(futures).await;
|
||||||
|
|
||||||
for idx in 0..del_objects.len() {
|
for idx in 0..del_objects.len() {
|
||||||
let pool_results = results.iter().filter_map(|(object_indices, (dels, errs))| {
|
for (dels, errs, pool_accounting) in results.iter() {
|
||||||
let pool_object_idx = object_indices.binary_search(&idx).ok()?;
|
if errs[idx].is_none() && dels[idx].found {
|
||||||
Some((&dels[pool_object_idx], &errs[pool_object_idx]))
|
del_errs[idx] = None;
|
||||||
});
|
del_objects[idx] = dels[idx].clone();
|
||||||
let (deleted, error, attempted) = resolve_batch_delete_pool_results(del_errs[idx].take(), pool_results);
|
accounting[idx] = pool_accounting[idx].clone();
|
||||||
if let Some(deleted) = deleted {
|
break;
|
||||||
del_objects[idx] = deleted;
|
|
||||||
}
|
|
||||||
del_errs[idx] = error;
|
|
||||||
|
|
||||||
if !attempted && del_errs[idx].is_none() && latest_marker_objects[idx] {
|
|
||||||
del_objects[idx] = DeletedObject {
|
|
||||||
object_name: objects[idx].object_name.clone(),
|
|
||||||
version_id: objects[idx].version_id,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
del_errs[idx] = Some(StorageError::ObjectNotFound(bucket.to_owned(), objects[idx].object_name.clone()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
if del_errs[idx].is_none() {
|
||||||
for (idx, object) in objects.iter().enumerate() {
|
del_errs[idx] = errs[idx].clone();
|
||||||
if del_errs[idx].is_none() && del_objects[idx].delete_marker {
|
del_objects[idx] = dels[idx].clone();
|
||||||
pause_versioned_delete_marker_after_commit(bucket, &object.object_name).await;
|
accounting[idx] = pool_accounting[idx].clone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3945,80 +3417,6 @@ mod tests {
|
|||||||
assert!(!same_distributed_lock_domain(&[first, second], &[other]));
|
assert!(!same_distributed_lock_domain(&[first, second], &[other]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn decommission_fence_covers_dist_sets_with_same_clients_despite_different_namespaces() {
|
|
||||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
|
||||||
let (_dirs, original_sets) = make_local_two_set_sets_with_ctx(Arc::clone(&ctx)).await;
|
|
||||||
let mut second_set = (*original_sets.disk_set[1]).clone();
|
|
||||||
second_set.lockers = original_sets.disk_set[0].lockers.clone();
|
|
||||||
let mut sets = (*original_sets).clone();
|
|
||||||
sets.disk_set[1] = Arc::new(second_set);
|
|
||||||
let sets = Arc::new(sets);
|
|
||||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
sets.disk_set[0]
|
|
||||||
.lockers
|
|
||||||
.iter()
|
|
||||||
.zip(&sets.disk_set[1].lockers)
|
|
||||||
.all(|(fixed, hashed)| Arc::ptr_eq(fixed, hashed)),
|
|
||||||
"the regression requires identical distributed lock clients"
|
|
||||||
);
|
|
||||||
assert_ne!(sets.disk_set[0].set_index, sets.disk_set[1].set_index);
|
|
||||||
|
|
||||||
let pool_config = sets.endpoints.clone();
|
|
||||||
let store = new_prepared_reader_test_store_from_pools(vec![Arc::clone(&sets)], vec![pool_config], ctx);
|
|
||||||
let object = (0..1_000)
|
|
||||||
.map(|index| format!("decommission-dist-domain-{index}.bin"))
|
|
||||||
.find(|candidate| Arc::ptr_eq(&sets.get_disks_by_key(candidate), &sets.disk_set[1]))
|
|
||||||
.expect("a key should hash to the second set namespace");
|
|
||||||
let mutation_fence = store
|
|
||||||
.acquire_decommission_object_mutation_fence("bucket", &object)
|
|
||||||
.await
|
|
||||||
.expect("the fixed distributed mutation fence should be acquired");
|
|
||||||
let target_lock = sets.disk_set[1]
|
|
||||||
.new_ns_lock("bucket", &object)
|
|
||||||
.await
|
|
||||||
.expect("the hashed-set namespace lock should be created");
|
|
||||||
let target_err = target_lock
|
|
||||||
.get_write_lock(Duration::from_millis(50))
|
|
||||||
.await
|
|
||||||
.expect_err("the fixed read fence must conflict through the shared clients");
|
|
||||||
assert!(matches!(target_err, rustfs_lock::LockError::Timeout { .. }));
|
|
||||||
|
|
||||||
let mut put_opts = ObjectOptions::default();
|
|
||||||
store
|
|
||||||
.apply_decommission_target_mutation_fence(0, &object, &mut put_opts, Some(&mutation_fence))
|
|
||||||
.await;
|
|
||||||
assert!(put_opts.no_lock, "migration target PUT must reuse the covering fixed fence");
|
|
||||||
|
|
||||||
let mut multipart_opts = ObjectOptions::default();
|
|
||||||
store
|
|
||||||
.apply_decommission_target_mutation_fence(0, &object, &mut multipart_opts, Some(&mutation_fence))
|
|
||||||
.await;
|
|
||||||
assert!(multipart_opts.no_lock, "migration target multipart must reuse the covering fixed fence");
|
|
||||||
drop(mutation_fence);
|
|
||||||
|
|
||||||
let cleanup_object = (0..1_000)
|
|
||||||
.map(|index| format!("decommission-dist-cleanup-{index}.bin"))
|
|
||||||
.find(|candidate| Arc::ptr_eq(&sets.get_disks_by_key(candidate), &sets.disk_set[1]))
|
|
||||||
.expect("a cleanup key should hash to the second set namespace");
|
|
||||||
let source_fence = store
|
|
||||||
.acquire_decommission_source_cleanup_fence("bucket", &cleanup_object, sets.disk_set[1].as_ref())
|
|
||||||
.await
|
|
||||||
.expect("the fixed distributed cleanup fence should be acquired");
|
|
||||||
assert!(source_fence.source_lock_covered(), "source cleanup must reuse the covering fixed fence");
|
|
||||||
let source_lock = sets.disk_set[1]
|
|
||||||
.new_ns_lock("bucket", &cleanup_object)
|
|
||||||
.await
|
|
||||||
.expect("the source-set namespace lock should be created");
|
|
||||||
let source_err = source_lock
|
|
||||||
.get_read_lock(Duration::from_millis(50))
|
|
||||||
.await
|
|
||||||
.expect_err("the fixed write fence must conflict through the shared clients");
|
|
||||||
assert!(matches!(source_err, rustfs_lock::LockError::Timeout { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn select_snapshot_version_matching_normalizes_null_and_uuid_forms() {
|
fn select_snapshot_version_matching_normalizes_null_and_uuid_forms() {
|
||||||
let nil = Uuid::nil();
|
let nil = Uuid::nil();
|
||||||
@@ -5078,159 +4476,6 @@ mod tests {
|
|||||||
assert_eq!(lookup_opts.version_id.as_deref(), Some("vid-1"));
|
assert_eq!(lookup_opts.version_id.as_deref(), Some("vid-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ordinary_delete_lookup_includes_decommission_source_and_skips_rebalance_source() {
|
|
||||||
let lookup_opts = delete_pool_lookup_opts(&ObjectOptions::default(), true);
|
|
||||||
|
|
||||||
assert!(lookup_opts.no_lock);
|
|
||||||
assert!(!lookup_opts.skip_decommissioned);
|
|
||||||
assert!(lookup_opts.skip_rebalancing);
|
|
||||||
|
|
||||||
let explicit_version = delete_pool_lookup_opts(
|
|
||||||
&ObjectOptions {
|
|
||||||
versioned: true,
|
|
||||||
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert!(!explicit_version.skip_decommissioned);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delete_fans_out_for_unversioned_and_explicit_version_mutations() {
|
|
||||||
assert!(should_delete_from_all_pools(&ObjectOptions::default(), 1));
|
|
||||||
assert!(should_delete_from_all_pools(
|
|
||||||
&ObjectOptions {
|
|
||||||
versioned: true,
|
|
||||||
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
));
|
|
||||||
assert!(!should_delete_from_all_pools(
|
|
||||||
&ObjectOptions {
|
|
||||||
versioned: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
1,
|
|
||||||
));
|
|
||||||
assert!(!should_delete_from_all_pools(&ObjectOptions::default(), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_delete_identifies_only_latest_versioned_markers() {
|
|
||||||
let versioned = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
|
||||||
s3s::dto::VersioningConfiguration {
|
|
||||||
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let latest = ObjectToDelete {
|
|
||||||
object_name: "latest".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(batch_delete_creates_latest_marker(&latest, &versioned));
|
|
||||||
assert!(!batch_delete_targets_pool(true, Some(1), 0));
|
|
||||||
assert!(batch_delete_targets_pool(true, Some(1), 1));
|
|
||||||
assert!(!batch_delete_targets_pool(true, Some(1), 2));
|
|
||||||
|
|
||||||
let explicit = ObjectToDelete {
|
|
||||||
object_name: "explicit".to_string(),
|
|
||||||
version_id: Some(uuid::Uuid::new_v4()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert!(!batch_delete_creates_latest_marker(&explicit, &versioned));
|
|
||||||
assert!(batch_delete_targets_pool(false, Some(1), 0));
|
|
||||||
|
|
||||||
let unversioned = DeleteReplicationConfigSnapshot::default();
|
|
||||||
assert!(!batch_delete_creates_latest_marker(&latest, &unversioned));
|
|
||||||
assert!(batch_delete_targets_pool(false, None, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_delete_pool_failures_override_success_in_any_pool_order() {
|
|
||||||
let success = DeletedObject {
|
|
||||||
object_name: "object".to_string(),
|
|
||||||
found: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let source_errors = [
|
|
||||||
StorageError::ErasureWriteQuorum,
|
|
||||||
StorageError::NamespaceLockQuorumUnavailable {
|
|
||||||
mode: "delete_objects_commit",
|
|
||||||
bucket: "bucket".to_string(),
|
|
||||||
object: "object".to_string(),
|
|
||||||
required: 1,
|
|
||||||
achieved: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for source_error in source_errors {
|
|
||||||
for source_first in [true, false] {
|
|
||||||
let failed = (DeletedObject::default(), Some(source_error.clone()));
|
|
||||||
let succeeded = (success.clone(), None);
|
|
||||||
let pool_results = if source_first {
|
|
||||||
vec![failed, succeeded]
|
|
||||||
} else {
|
|
||||||
vec![succeeded, failed]
|
|
||||||
};
|
|
||||||
|
|
||||||
let (_, error, attempted) =
|
|
||||||
resolve_batch_delete_pool_results(None, pool_results.iter().map(|(deleted, error)| (deleted, error)));
|
|
||||||
|
|
||||||
assert!(attempted);
|
|
||||||
assert_eq!(error, Some(source_error.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_delete_ignores_missing_pool_only_after_another_pool_succeeds() {
|
|
||||||
let success = DeletedObject {
|
|
||||||
object_name: "object".to_string(),
|
|
||||||
found: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let missing_errors = [
|
|
||||||
StorageError::ObjectNotFound("bucket".to_string(), "object".to_string()),
|
|
||||||
StorageError::VersionNotFound("bucket".to_string(), "object".to_string(), "version".to_string()),
|
|
||||||
];
|
|
||||||
|
|
||||||
for missing_error in missing_errors {
|
|
||||||
let missing = (DeletedObject::default(), Some(missing_error.clone()));
|
|
||||||
for missing_first in [true, false] {
|
|
||||||
let succeeded = (success.clone(), None);
|
|
||||||
let pool_results = if missing_first {
|
|
||||||
vec![missing.clone(), succeeded]
|
|
||||||
} else {
|
|
||||||
vec![succeeded, missing.clone()]
|
|
||||||
};
|
|
||||||
let (deleted, error, attempted) =
|
|
||||||
resolve_batch_delete_pool_results(None, pool_results.iter().map(|(deleted, error)| (deleted, error)));
|
|
||||||
|
|
||||||
assert!(attempted);
|
|
||||||
let deleted = deleted.expect("successful pool result should be retained");
|
|
||||||
assert!(deleted.found);
|
|
||||||
assert_eq!(deleted.object_name, success.object_name.as_str());
|
|
||||||
assert!(error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
let missing_only = [missing];
|
|
||||||
let (_, error, attempted) =
|
|
||||||
resolve_batch_delete_pool_results(None, missing_only.iter().map(|(deleted, error)| (deleted, error)));
|
|
||||||
assert!(attempted);
|
|
||||||
assert_eq!(error, Some(missing_error));
|
|
||||||
}
|
|
||||||
|
|
||||||
let silent_missing = [(DeletedObject::default(), None)];
|
|
||||||
let (_, error, attempted) =
|
|
||||||
resolve_batch_delete_pool_results(None, silent_missing.iter().map(|(deleted, error)| (deleted, error)));
|
|
||||||
assert!(attempted);
|
|
||||||
assert!(error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_movement_pool_lookup_opts_keeps_no_lock_for_tiered_moves() {
|
fn data_movement_pool_lookup_opts_keeps_no_lock_for_tiered_moves() {
|
||||||
let lookup_opts = data_movement_pool_lookup_opts(
|
let lookup_opts = data_movement_pool_lookup_opts(
|
||||||
|
|||||||
@@ -859,7 +859,6 @@ fn lifecycle_delete_all_test_failure(phase: crate::object_api::LifecycleDeleteAl
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::bucket::replication::{ReplicationStatusType, VersionPurgeStatusType};
|
|
||||||
use crate::config::storageclass::{CLASS_RRS, CLASS_STANDARD, lookup_config_for_pools_without_env};
|
use crate::config::storageclass::{CLASS_RRS, CLASS_STANDARD, lookup_config_for_pools_without_env};
|
||||||
use crate::disk::error::DiskError;
|
use crate::disk::error::DiskError;
|
||||||
use crate::layout::endpoint::Endpoint;
|
use crate::layout::endpoint::Endpoint;
|
||||||
@@ -1424,14 +1423,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn object_info_with_identity(unix_ts: i64, delete_marker: bool, version_id: Uuid, etag: Option<String>) -> ObjectInfo {
|
|
||||||
ObjectInfo {
|
|
||||||
version_id: Some(version_id),
|
|
||||||
etag,
|
|
||||||
..object_info_with_mod_time(unix_ts, delete_marker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_latest_object_info_candidates_returns_latest_delete_marker() {
|
fn resolve_latest_object_info_candidates_returns_latest_delete_marker() {
|
||||||
let candidates = vec![
|
let candidates = vec![
|
||||||
@@ -1455,7 +1446,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_latest_object_info_candidates_prefers_higher_pool_idx_on_equal_mod_time_for_equivalent_candidates() {
|
fn resolve_latest_object_info_candidates_prefers_higher_pool_idx_on_equal_mod_time() {
|
||||||
let candidates = vec![
|
let candidates = vec![
|
||||||
LatestObjectInfoCandidate {
|
LatestObjectInfoCandidate {
|
||||||
info: Some(object_info_with_mod_time(10, false)),
|
info: Some(object_info_with_mod_time(10, false)),
|
||||||
@@ -1475,382 +1466,6 @@ mod tests {
|
|||||||
assert_eq!(idx, 1);
|
assert_eq!(idx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_keeps_index_fallback_for_fully_equivalent_identities() {
|
|
||||||
let candidates = vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()))),
|
|
||||||
idx: 2,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()))),
|
|
||||||
idx: 7,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let (info, idx) = resolve_latest_object_info_candidates(candidates, "bucket", "object", &ObjectOptions::default())
|
|
||||||
.expect("equivalent replicas must resolve deterministically");
|
|
||||||
|
|
||||||
assert_eq!(idx, 7);
|
|
||||||
assert_eq!(info.version_id, Some(Uuid::from_u128(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_equal_time_version_id_conflict() {
|
|
||||||
let candidates = vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()))),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(2), Some("etag-a".to_string()))),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let err = resolve_latest_object_info_candidates(candidates, "bucket", "object", &ObjectOptions::default())
|
|
||||||
.expect_err("divergent version ids must not silently resolve to the higher pool index");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::ErasureReadQuorum);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_equal_time_etag_conflict() {
|
|
||||||
let candidates = vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-old".to_string()))),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-new".to_string()))),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let err = resolve_latest_object_info_candidates(candidates, "bucket", "object", &ObjectOptions::default())
|
|
||||||
.expect_err("divergent etags must not silently resolve to the higher pool index");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::ErasureReadQuorum);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_equal_time_delete_marker_conflict() {
|
|
||||||
let candidates = vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), None)),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, true, Uuid::from_u128(1), Some("etag-a".to_string()))),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let err = resolve_latest_object_info_candidates(candidates, "bucket", "object", &ObjectOptions::default())
|
|
||||||
.expect_err("a delete marker tied with a live version must not be masked by the pool index");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::ErasureReadQuorum);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_equal_time_identity_conflict(left: ObjectInfo, right: ObjectInfo) {
|
|
||||||
let err = resolve_latest_object_info_candidates(
|
|
||||||
vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(left),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(right),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.expect_err("equal-time identity divergence must fail closed");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::ErasureReadQuorum);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_equal_time_payload_identity_conflicts() {
|
|
||||||
let base = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
|
|
||||||
let mut data_dir = base.clone();
|
|
||||||
data_dir.data_dir = Some(Uuid::from_u128(2));
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), data_dir);
|
|
||||||
|
|
||||||
let mut size = base.clone();
|
|
||||||
size.size = 1;
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), size);
|
|
||||||
|
|
||||||
let mut actual_size = base.clone();
|
|
||||||
actual_size.actual_size = 1;
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), actual_size);
|
|
||||||
|
|
||||||
let mut checksum = base.clone();
|
|
||||||
checksum.checksum = Some(bytes::Bytes::from_static(b"checksum"));
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), checksum);
|
|
||||||
|
|
||||||
let mut parts = base.clone();
|
|
||||||
parts.parts = std::sync::Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
|
||||||
etag: "part-etag".to_string(),
|
|
||||||
number: 1,
|
|
||||||
size: 1,
|
|
||||||
..Default::default()
|
|
||||||
}]);
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), parts);
|
|
||||||
|
|
||||||
let mut transition = base;
|
|
||||||
transition.transitioned_object.tier = "tier-a".to_string();
|
|
||||||
assert_equal_time_identity_conflict(
|
|
||||||
object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string())),
|
|
||||||
transition,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_accepts_internal_metadata_aliases() {
|
|
||||||
let base = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
let mut rustfs_alias = base.clone();
|
|
||||||
rustfs_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
"x-rustfs-internal-compression".to_string(),
|
|
||||||
"zstd".to_string(),
|
|
||||||
)]));
|
|
||||||
let mut minio_alias = base.clone();
|
|
||||||
minio_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
"X-MINIO-INTERNAL-COMPRESSION".to_string(),
|
|
||||||
"zstd".to_string(),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let (_, idx) = resolve_latest_object_info_candidates(
|
|
||||||
vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(rustfs_alias),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(minio_alias),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.expect("same-value internal aliases should resolve");
|
|
||||||
assert_eq!(idx, 1);
|
|
||||||
|
|
||||||
let mut dual_alias = base.clone();
|
|
||||||
dual_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([
|
|
||||||
("x-rustfs-internal-compression".to_string(), "zstd".to_string()),
|
|
||||||
("x-minio-internal-compression".to_string(), "zstd".to_string()),
|
|
||||||
]));
|
|
||||||
let mut single_alias = base;
|
|
||||||
single_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
"x-rustfs-internal-compression".to_string(),
|
|
||||||
"zstd".to_string(),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let (_, idx) = resolve_latest_object_info_candidates(
|
|
||||||
vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(dual_alias),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(single_alias),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.expect("dual-key and single-key internal metadata should resolve");
|
|
||||||
assert_eq!(idx, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_different_internal_metadata_alias_values() {
|
|
||||||
let base = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
let mut rustfs_alias = base.clone();
|
|
||||||
rustfs_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
"x-rustfs-internal-compression".to_string(),
|
|
||||||
"zstd".to_string(),
|
|
||||||
)]));
|
|
||||||
let mut minio_alias = base;
|
|
||||||
minio_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
"x-minio-internal-compression".to_string(),
|
|
||||||
"snappy".to_string(),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
assert_equal_time_identity_conflict(rustfs_alias, minio_alias);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_preserves_dynamic_internal_metadata_identity_case() {
|
|
||||||
for suffix_prefix in ["replication-reset-", "replication-delete-marker-version-"] {
|
|
||||||
let base = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
let mut rustfs_alias = base.clone();
|
|
||||||
rustfs_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
format!(
|
|
||||||
"X-RUSTFS-INTERNAL-{}{suffix}",
|
|
||||||
suffix_prefix.to_uppercase(),
|
|
||||||
suffix = "arn:aws:s3:::Bucket"
|
|
||||||
),
|
|
||||||
"value".to_string(),
|
|
||||||
)]));
|
|
||||||
let mut minio_alias = base.clone();
|
|
||||||
minio_alias.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
format!("x-minio-internal-{suffix_prefix}arn:aws:s3:::Bucket"),
|
|
||||||
"value".to_string(),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let (_, idx) = resolve_latest_object_info_candidates(
|
|
||||||
vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(rustfs_alias.clone()),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(minio_alias),
|
|
||||||
idx: 1,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.expect("dynamic internal aliases with the same target should resolve");
|
|
||||||
assert_eq!(idx, 1);
|
|
||||||
|
|
||||||
let mut different_target_case = base;
|
|
||||||
different_target_case.user_defined = std::sync::Arc::new(std::collections::HashMap::from([(
|
|
||||||
format!("x-minio-internal-{suffix_prefix}arn:aws:s3:::bucket"),
|
|
||||||
"value".to_string(),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
assert_equal_time_identity_conflict(rustfs_alias, different_target_case);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_conflicting_internal_metadata_aliases_in_one_candidate() {
|
|
||||||
let base = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
let mut first = base.clone();
|
|
||||||
first.user_defined = std::sync::Arc::new(std::collections::HashMap::from([
|
|
||||||
("x-rustfs-internal-compression".to_string(), "zstd".to_string()),
|
|
||||||
("x-minio-internal-compression".to_string(), "snappy".to_string()),
|
|
||||||
]));
|
|
||||||
let mut second = base;
|
|
||||||
second.user_defined = first.user_defined.clone();
|
|
||||||
|
|
||||||
assert_equal_time_identity_conflict(first, second);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_replication_identity_conflict() {
|
|
||||||
let base = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
|
|
||||||
let mut replication = base.clone();
|
|
||||||
replication.replication_status_internal = Some("PENDING".to_string());
|
|
||||||
replication.replication_status = ReplicationStatusType::Pending;
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), replication);
|
|
||||||
|
|
||||||
let mut purge = base.clone();
|
|
||||||
purge.version_purge_status_internal = Some("PENDING".to_string());
|
|
||||||
purge.version_purge_status = VersionPurgeStatusType::Pending;
|
|
||||||
assert_equal_time_identity_conflict(base.clone(), purge);
|
|
||||||
|
|
||||||
let mut decision = base;
|
|
||||||
decision.replication_decision = "replicate".to_string();
|
|
||||||
assert_equal_time_identity_conflict(
|
|
||||||
object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string())),
|
|
||||||
decision,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_rejects_none_vs_unix_epoch_mod_time() {
|
|
||||||
let mut without_mod_time = object_info_with_identity(0, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
without_mod_time.mod_time = None;
|
|
||||||
let with_unix_epoch = object_info_with_identity(0, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
|
||||||
|
|
||||||
assert_equal_time_identity_conflict(without_mod_time, with_unix_epoch);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_ignores_older_identity_conflicts() {
|
|
||||||
let latest = object_info_with_identity(20, false, Uuid::from_u128(1), Some("etag-latest".to_string()));
|
|
||||||
let mut older = object_info_with_identity(10, true, Uuid::from_u128(2), Some("etag-old".to_string()));
|
|
||||||
older.data_dir = Some(Uuid::from_u128(2));
|
|
||||||
|
|
||||||
let (info, idx) = resolve_latest_object_info_candidates(
|
|
||||||
vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(latest),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(older),
|
|
||||||
idx: 9,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.expect("older identity divergence must not affect the latest candidate");
|
|
||||||
|
|
||||||
assert_eq!(idx, 0);
|
|
||||||
assert_eq!(
|
|
||||||
info.mod_time,
|
|
||||||
Some(OffsetDateTime::from_unix_timestamp(20).expect("operation should succeed"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_latest_object_info_candidates_ignores_not_found_pools_when_resolving() {
|
|
||||||
let candidates = vec![
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: Some(object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()))),
|
|
||||||
idx: 0,
|
|
||||||
err: None,
|
|
||||||
},
|
|
||||||
LatestObjectInfoCandidate {
|
|
||||||
info: None,
|
|
||||||
idx: 1,
|
|
||||||
err: Some(Error::ObjectNotFound("bucket".to_string(), "object".to_string())),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let (info, idx) = resolve_latest_object_info_candidates(candidates, "bucket", "object", &ObjectOptions::default())
|
|
||||||
.expect("not-found pools must not block resolution of found candidates");
|
|
||||||
|
|
||||||
assert_eq!(idx, 0);
|
|
||||||
assert_eq!(info.version_id, Some(Uuid::from_u128(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_latest_object_info_candidates_returns_non_not_found_error() {
|
fn resolve_latest_object_info_candidates_returns_non_not_found_error() {
|
||||||
let err = resolve_latest_object_info_candidates(
|
let err = resolve_latest_object_info_candidates(
|
||||||
|
|||||||
@@ -12,14 +12,10 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
use crate::error::{Error, Result, StorageError, is_err_object_not_found, is_err_version_not_found};
|
use crate::error::{Error, Result, StorageError, is_err_object_not_found, is_err_version_not_found};
|
||||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||||
use rustfs_utils::http::metadata_compat::{
|
|
||||||
SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX, SUFFIX_REPLICATION_RESET_ARN_PREFIX,
|
|
||||||
strip_internal_prefix_preserving_case,
|
|
||||||
};
|
|
||||||
use rustfs_utils::path::decode_dir_object;
|
use rustfs_utils::path::decode_dir_object;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
@@ -77,7 +73,7 @@ pub(super) fn resolve_rebalance_delete_from_all_pools_result(
|
|||||||
object: &str,
|
object: &str,
|
||||||
) -> Result<ObjectInfo> {
|
) -> Result<ObjectInfo> {
|
||||||
result.map_err(|err| {
|
result.map_err(|err| {
|
||||||
if matches!(&err, Error::PreconditionFailed | Error::PrefixAccessDenied(_, _)) {
|
if err == Error::PreconditionFailed {
|
||||||
err
|
err
|
||||||
} else {
|
} else {
|
||||||
Error::other(format!("failed to delete rebalance source object {bucket}/{object}: {err}"))
|
Error::other(format!("failed to delete rebalance source object {bucket}/{object}: {err}"))
|
||||||
@@ -90,7 +86,7 @@ fn is_ignorable_rebalance_delete_error(err: &Error) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rebalance_delete_pool_error(pool_idx: usize, bucket: &str, object: &str, err: Error) -> Error {
|
fn rebalance_delete_pool_error(pool_idx: usize, bucket: &str, object: &str, err: Error) -> Error {
|
||||||
if matches!(&err, Error::PreconditionFailed | Error::PrefixAccessDenied(_, _)) {
|
if err == Error::PreconditionFailed {
|
||||||
err
|
err
|
||||||
} else {
|
} else {
|
||||||
Error::other(format!("pool {pool_idx} delete failed for {bucket}/{object}: {err}"))
|
Error::other(format!("pool {pool_idx} delete failed for {bucket}/{object}: {err}"))
|
||||||
@@ -141,158 +137,37 @@ pub(super) fn rebalance_disk_set_lookup_error(pool_idx: usize, set_idx: usize, p
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn latest_candidate_mod_time(candidate: &LatestObjectInfoCandidate) -> Option<OffsetDateTime> {
|
|
||||||
candidate
|
|
||||||
.info
|
|
||||||
.as_ref()
|
|
||||||
.map(|info| info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_transition_identity(left: &ObjectInfo, right: &ObjectInfo) -> bool {
|
|
||||||
left.transition_version_state == right.transition_version_state
|
|
||||||
&& left.transitioned_object.name == right.transitioned_object.name
|
|
||||||
&& left.transitioned_object.version_id == right.transitioned_object.version_id
|
|
||||||
&& left.transitioned_object.tier == right.transitioned_object.tier
|
|
||||||
&& left.transitioned_object.free_version == right.transitioned_object.free_version
|
|
||||||
&& left.transitioned_object.status == right.transitioned_object.status
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
|
||||||
struct LatestUserDefinedIdentity {
|
|
||||||
internal: HashMap<String, String>,
|
|
||||||
other: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_internal_identity_suffix(key: &str) -> Option<String> {
|
|
||||||
let suffix = strip_internal_prefix_preserving_case(key)?;
|
|
||||||
|
|
||||||
for dynamic_prefix in [
|
|
||||||
SUFFIX_REPLICATION_RESET_ARN_PREFIX,
|
|
||||||
SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX,
|
|
||||||
] {
|
|
||||||
let prefix_len = dynamic_prefix.len();
|
|
||||||
if let (Some(prefix), Some(remainder)) = (suffix.get(..prefix_len), suffix.get(prefix_len..))
|
|
||||||
&& prefix.eq_ignore_ascii_case(dynamic_prefix)
|
|
||||||
{
|
|
||||||
return Some(format!("{dynamic_prefix}{remainder}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(suffix.to_lowercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_user_defined_identity(user_defined: &HashMap<String, String>) -> Option<LatestUserDefinedIdentity> {
|
|
||||||
let mut identity = LatestUserDefinedIdentity {
|
|
||||||
internal: HashMap::with_capacity(user_defined.len()),
|
|
||||||
other: HashMap::with_capacity(user_defined.len()),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (key, value) in user_defined {
|
|
||||||
if let Some(suffix) = normalize_internal_identity_suffix(key) {
|
|
||||||
if identity
|
|
||||||
.internal
|
|
||||||
.insert(suffix, value.clone())
|
|
||||||
.is_some_and(|previous| previous != *value)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
identity.other.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_user_defined_identity(left: &ObjectInfo, right: &ObjectInfo) -> bool {
|
|
||||||
match (
|
|
||||||
normalize_user_defined_identity(&left.user_defined),
|
|
||||||
normalize_user_defined_identity(&right.user_defined),
|
|
||||||
) {
|
|
||||||
(Some(left), Some(right)) => left == right,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pool-specific erasure geometry is intentionally excluded: `get_object_info`
|
|
||||||
/// returns each pool's own `data_blocks`/`parity_blocks`, so those values can
|
|
||||||
/// differ for the same object version while the selected winner still carries
|
|
||||||
/// the chosen pool's layout. `put_object_reader` is also intentionally
|
|
||||||
/// excluded because it is a transient request handle that `ObjectInfo::clone`
|
|
||||||
/// drops. Every other ObjectInfo field is part of the production-visible
|
|
||||||
/// identity and must agree before the pool index can provide a deterministic
|
|
||||||
/// tie-break.
|
|
||||||
fn same_latest_object_info_identity(left: &ObjectInfo, right: &ObjectInfo) -> bool {
|
|
||||||
left.bucket == right.bucket
|
|
||||||
&& left.name == right.name
|
|
||||||
&& left.storage_class == right.storage_class
|
|
||||||
&& left.mod_time == right.mod_time
|
|
||||||
&& left.size == right.size
|
|
||||||
&& left.actual_size == right.actual_size
|
|
||||||
&& left.is_dir == right.is_dir
|
|
||||||
&& same_user_defined_identity(left, right)
|
|
||||||
&& left.user_tags == right.user_tags
|
|
||||||
&& left.version_id == right.version_id
|
|
||||||
&& left.data_dir == right.data_dir
|
|
||||||
&& left.delete_marker == right.delete_marker
|
|
||||||
&& same_transition_identity(left, right)
|
|
||||||
&& left.restore_ongoing == right.restore_ongoing
|
|
||||||
&& left.restore_expires == right.restore_expires
|
|
||||||
&& left.parts == right.parts
|
|
||||||
&& left.is_latest == right.is_latest
|
|
||||||
&& left.content_type == right.content_type
|
|
||||||
&& left.content_encoding == right.content_encoding
|
|
||||||
&& left.expires == right.expires
|
|
||||||
&& left.num_versions == right.num_versions
|
|
||||||
&& left.successor_mod_time == right.successor_mod_time
|
|
||||||
&& left.etag == right.etag
|
|
||||||
&& left.inlined == right.inlined
|
|
||||||
&& left.metadata_only == right.metadata_only
|
|
||||||
&& left.version_only == right.version_only
|
|
||||||
&& left.replication_status_internal == right.replication_status_internal
|
|
||||||
&& left.replication_status == right.replication_status
|
|
||||||
&& left.version_purge_status_internal == right.version_purge_status_internal
|
|
||||||
&& left.version_purge_status == right.version_purge_status
|
|
||||||
&& left.replication_decision == right.replication_decision
|
|
||||||
&& left.checksum == right.checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn resolve_latest_object_info_candidates(
|
pub(super) fn resolve_latest_object_info_candidates(
|
||||||
candidates: Vec<LatestObjectInfoCandidate>,
|
mut candidates: Vec<LatestObjectInfoCandidate>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
opts: &ObjectOptions,
|
opts: &ObjectOptions,
|
||||||
) -> Result<(ObjectInfo, usize)> {
|
) -> Result<(ObjectInfo, usize)> {
|
||||||
let latest_mod_time = candidates.iter().filter_map(latest_candidate_mod_time).max();
|
candidates.sort_by(|a, b| {
|
||||||
|
let a_mod = if let Some(info) = &a.info {
|
||||||
if let Some(latest_mod_time) = latest_mod_time {
|
info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH)
|
||||||
let mut latest_candidates = candidates
|
} else {
|
||||||
.into_iter()
|
OffsetDateTime::UNIX_EPOCH
|
||||||
.filter(|candidate| latest_candidate_mod_time(candidate) == Some(latest_mod_time))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
latest_candidates.sort_by(|left, right| right.idx.cmp(&left.idx));
|
|
||||||
|
|
||||||
let Some(winner) = latest_candidates.first() else {
|
|
||||||
return Err(Error::ErasureReadQuorum);
|
|
||||||
};
|
|
||||||
let Some(winner_info) = winner.info.as_ref() else {
|
|
||||||
return Err(Error::ErasureReadQuorum);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if latest_candidates.iter().skip(1).any(|candidate| {
|
let b_mod = if let Some(info) = &b.info {
|
||||||
candidate
|
info.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH)
|
||||||
.info
|
} else {
|
||||||
.as_ref()
|
OffsetDateTime::UNIX_EPOCH
|
||||||
.is_none_or(|info| !same_latest_object_info_identity(winner_info, info))
|
};
|
||||||
}) {
|
|
||||||
return Err(Error::ErasureReadQuorum);
|
if a_mod == b_mod {
|
||||||
|
return if a.idx < b.idx { Ordering::Greater } else { Ordering::Less };
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok((winner_info.clone(), winner.idx));
|
b_mod.cmp(&a_mod)
|
||||||
}
|
});
|
||||||
|
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
|
if let Some(info) = candidate.info {
|
||||||
|
return Ok((info, candidate.idx));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(err) = candidate.err
|
if let Some(err) = candidate.err
|
||||||
&& !is_err_object_not_found(&err)
|
&& !is_err_object_not_found(&err)
|
||||||
&& !is_err_version_not_found(&err)
|
&& !is_err_version_not_found(&err)
|
||||||
@@ -316,18 +191,6 @@ mod tests {
|
|||||||
assert_eq!(err, Error::PreconditionFailed);
|
assert_eq!(err, Error::PreconditionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rebalance_delete_result_preserves_prefix_access_denied() {
|
|
||||||
let err = resolve_rebalance_delete_from_all_pools_result(
|
|
||||||
Err(Error::PrefixAccessDenied("bucket".to_owned(), "object".to_owned())),
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
)
|
|
||||||
.expect_err("prefix access denial should remain structured");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::PrefixAccessDenied("bucket".to_owned(), "object".to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rebalance_delete_pool_result_preserves_precondition_failed() {
|
fn rebalance_delete_pool_result_preserves_precondition_failed() {
|
||||||
let err = resolve_rebalance_delete_from_all_pools_results(
|
let err = resolve_rebalance_delete_from_all_pools_results(
|
||||||
@@ -342,19 +205,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(err, Error::PreconditionFailed);
|
assert_eq!(err, Error::PreconditionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rebalance_delete_pool_result_preserves_prefix_access_denied() {
|
|
||||||
let err = resolve_rebalance_delete_from_all_pools_results(
|
|
||||||
vec![RebalanceDeletePoolResult {
|
|
||||||
pool_idx: 0,
|
|
||||||
result: Err(Error::PrefixAccessDenied("bucket".to_owned(), "object".to_owned())),
|
|
||||||
}],
|
|
||||||
"bucket",
|
|
||||||
"object",
|
|
||||||
)
|
|
||||||
.expect_err("prefix access denial should remain structured");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::PrefixAccessDenied("bucket".to_owned(), "object".to_owned()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::heal::{
|
use crate::heal::{
|
||||||
progress::HealProgress,
|
progress::{HealProgress, add_bytes, increment_counter},
|
||||||
resume::{
|
resume::{
|
||||||
CheckpointManager, ReplacementTargetIdentity, ResumeManager, ResumeUtils, compose_key,
|
CheckpointManager, CheckpointObjectOutcome, ReplacementTargetIdentity, ResumeManager, ResumeUtils, compose_key,
|
||||||
replacement_target_identities_match,
|
replacement_target_identities_match,
|
||||||
},
|
},
|
||||||
storage::{HealStorageAPI, next_heal_listing_token},
|
storage::{HealStorageAPI, next_heal_listing_token},
|
||||||
@@ -410,6 +410,9 @@ impl ErasureSetHealer {
|
|||||||
&& state.successful_objects == 0
|
&& state.successful_objects == 0
|
||||||
&& state.failed_objects == 0
|
&& state.failed_objects == 0
|
||||||
&& state.skipped_objects == 0
|
&& state.skipped_objects == 0
|
||||||
|
&& state.skipped_new_versions == 0
|
||||||
|
&& state.skipped_ilm_expired == 0
|
||||||
|
&& state.processed_bytes == 0
|
||||||
{
|
{
|
||||||
// schedule_retry persists the authoritative resume reset before
|
// schedule_retry persists the authoritative resume reset before
|
||||||
// resetting the checkpoint. Reapply the checkpoint reset after
|
// resetting the checkpoint. Reapply the checkpoint reset after
|
||||||
@@ -474,6 +477,23 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
// 2. initialize progress
|
// 2. initialize progress
|
||||||
self.initialize_progress(buckets, &state).await;
|
self.initialize_progress(buckets, &state).await;
|
||||||
|
let (baseline_known, baseline_count, baseline_size, baseline_generation) = {
|
||||||
|
let baseline = self.progress.read().await;
|
||||||
|
(
|
||||||
|
baseline.baseline_known,
|
||||||
|
baseline.objects_total_count,
|
||||||
|
baseline.objects_total_size,
|
||||||
|
baseline.baseline_generation,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if baseline_known {
|
||||||
|
resume_manager
|
||||||
|
.set_progress_baseline(baseline_count, baseline_size, baseline_generation)
|
||||||
|
.await?;
|
||||||
|
checkpoint_manager
|
||||||
|
.set_progress_baseline(baseline_count, baseline_size, baseline_generation)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. continue from checkpoint
|
// 3. continue from checkpoint
|
||||||
let current_bucket_index = checkpoint.current_bucket_index;
|
let current_bucket_index = checkpoint.current_bucket_index;
|
||||||
@@ -483,12 +503,66 @@ impl ErasureSetHealer {
|
|||||||
let mut successful_objects = state.successful_objects;
|
let mut successful_objects = state.successful_objects;
|
||||||
let mut failed_objects = state.failed_objects;
|
let mut failed_objects = state.failed_objects;
|
||||||
let mut skipped_objects = state.skipped_objects;
|
let mut skipped_objects = state.skipped_objects;
|
||||||
|
let checkpoint_has_progress = checkpoint.baseline_known
|
||||||
|
|| checkpoint.successful_objects > 0
|
||||||
|
|| checkpoint.failed_object_count > 0
|
||||||
|
|| checkpoint.skipped_object_count > 0
|
||||||
|
|| checkpoint.skipped_new_versions > 0
|
||||||
|
|| checkpoint.skipped_ilm_expired > 0
|
||||||
|
|| checkpoint.processed_bytes > 0
|
||||||
|
|| checkpoint.total_objects > 0
|
||||||
|
|| checkpoint.total_bytes > 0
|
||||||
|
|| checkpoint.baseline_generation.is_some()
|
||||||
|
|| checkpoint.counter_unknown;
|
||||||
|
let checkpoint_generation_mismatch = checkpoint.baseline_known && checkpoint.baseline_generation != baseline_generation;
|
||||||
|
let mut restored_counter_unknown = state.counter_unknown || checkpoint.counter_unknown;
|
||||||
|
if checkpoint_has_progress {
|
||||||
|
successful_objects = checkpoint.successful_objects;
|
||||||
|
failed_objects = checkpoint.failed_object_count;
|
||||||
|
skipped_objects = checkpoint.skipped_object_count;
|
||||||
|
let restored_processed_objects = successful_objects
|
||||||
|
.checked_add(failed_objects)
|
||||||
|
.and_then(|value| value.checked_add(skipped_objects))
|
||||||
|
.and_then(|value| value.checked_add(checkpoint.skipped_new_versions))
|
||||||
|
.and_then(|value| value.checked_add(checkpoint.skipped_ilm_expired));
|
||||||
|
let checkpoint_counter_overflow = restored_processed_objects.is_none();
|
||||||
|
restored_counter_unknown |= checkpoint_counter_overflow;
|
||||||
|
processed_objects = restored_processed_objects.unwrap_or(u64::MAX);
|
||||||
|
let mut progress = self.progress.write().await;
|
||||||
|
progress.objects_scanned = processed_objects;
|
||||||
|
progress.objects_healed = successful_objects;
|
||||||
|
progress.objects_failed = failed_objects;
|
||||||
|
progress.skipped_objects = skipped_objects;
|
||||||
|
progress.skipped_new_versions = checkpoint.skipped_new_versions;
|
||||||
|
progress.skipped_ilm_expired = checkpoint.skipped_ilm_expired;
|
||||||
|
if checkpoint.baseline_known && !checkpoint_generation_mismatch {
|
||||||
|
progress.objects_total_count = checkpoint.total_objects;
|
||||||
|
progress.objects_total_size = checkpoint.total_bytes;
|
||||||
|
progress.baseline_generation = checkpoint.baseline_generation;
|
||||||
|
progress.baseline_known = true;
|
||||||
|
}
|
||||||
|
progress.bytes_processed = checkpoint.processed_bytes;
|
||||||
|
progress.counter_unknown = state.counter_unknown || checkpoint.counter_unknown;
|
||||||
|
progress.refresh_progress_percentage();
|
||||||
|
if checkpoint_generation_mismatch || checkpoint_counter_overflow || progress.counter_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checkpoint_generation_mismatch {
|
||||||
|
restored_counter_unknown = true;
|
||||||
|
}
|
||||||
|
if restored_counter_unknown {
|
||||||
|
checkpoint_manager.mark_counter_unknown().await?;
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
let mut failed_buckets = 0u64;
|
let mut failed_buckets = 0u64;
|
||||||
|
|
||||||
// 4. process remaining buckets
|
// 4. process remaining buckets
|
||||||
for (bucket_idx, bucket) in buckets.iter().enumerate().skip(current_bucket_index) {
|
for (bucket_idx, bucket) in buckets.iter().enumerate().skip(current_bucket_index) {
|
||||||
// check if completed
|
// check if completed
|
||||||
if state.completed_buckets.contains(bucket) {
|
if state.completed_buckets.contains(bucket) {
|
||||||
|
checkpoint_manager.complete_bucket(bucket_idx.saturating_add(1)).await?;
|
||||||
|
current_object_index = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,13 +590,42 @@ impl ErasureSetHealer {
|
|||||||
return bucket_result;
|
return bucket_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update checkpoint position
|
|
||||||
checkpoint_manager.update_position(bucket_idx, current_object_index).await?;
|
|
||||||
|
|
||||||
// update progress
|
// update progress
|
||||||
resume_manager
|
let progress_snapshot = self.progress.read().await;
|
||||||
.update_progress(processed_objects, successful_objects, failed_objects, skipped_objects)
|
let bytes_processed = progress_snapshot.bytes_processed;
|
||||||
|
let skipped_new_versions = progress_snapshot.skipped_new_versions;
|
||||||
|
let skipped_ilm_expired = progress_snapshot.skipped_ilm_expired;
|
||||||
|
let counter_unknown = progress_snapshot.counter_unknown;
|
||||||
|
drop(progress_snapshot);
|
||||||
|
// The checkpoint is the recovery authority for object progress.
|
||||||
|
// Publish its counters and fence before the resume summary so a
|
||||||
|
// crash between the two stores cannot make recovery select newer
|
||||||
|
// summary bytes with an older checkpoint ledger.
|
||||||
|
if counter_unknown {
|
||||||
|
checkpoint_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
|
checkpoint_manager
|
||||||
|
.update_progress(successful_objects, failed_objects, skipped_objects, bytes_processed)
|
||||||
.await?;
|
.await?;
|
||||||
|
checkpoint_manager
|
||||||
|
.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired)
|
||||||
|
.await?;
|
||||||
|
checkpoint_manager.update_position(bucket_idx, current_object_index).await?;
|
||||||
|
resume_manager
|
||||||
|
.update_progress_with_bytes(
|
||||||
|
processed_objects,
|
||||||
|
successful_objects,
|
||||||
|
failed_objects,
|
||||||
|
skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
resume_manager
|
||||||
|
.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired)
|
||||||
|
.await?;
|
||||||
|
if counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
|
|
||||||
// check cancel status
|
// check cancel status
|
||||||
if self.cancel_token.is_cancelled() {
|
if self.cancel_token.is_cancelled() {
|
||||||
@@ -542,6 +645,7 @@ impl ErasureSetHealer {
|
|||||||
match bucket_result {
|
match bucket_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
resume_manager.complete_bucket(bucket).await?;
|
resume_manager.complete_bucket(bucket).await?;
|
||||||
|
checkpoint_manager.complete_bucket(bucket_idx.saturating_add(1)).await?;
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
event = EVENT_HEAL_ERASURE_BUCKET_STATE,
|
event = EVENT_HEAL_ERASURE_BUCKET_STATE,
|
||||||
@@ -567,7 +671,9 @@ impl ErasureSetHealer {
|
|||||||
error = %e,
|
error = %e,
|
||||||
"Erasure set bucket heal failed"
|
"Erasure set bucket heal failed"
|
||||||
);
|
);
|
||||||
// continue to next bucket, do not interrupt the whole process
|
// A single durable cursor and ledger cannot safely preserve
|
||||||
|
// this bucket while processing a later one.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,20 +881,48 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
// Per-version dedup identity — the single canonical key.
|
// Per-version dedup identity — the single canonical key.
|
||||||
let key = compose_key(&item.name, item.version_id.as_deref());
|
let key = compose_key(&item.name, item.version_id.as_deref());
|
||||||
if checkpoint.processed_objects.contains(&key) || checkpoint.skipped_objects.contains(&key) {
|
if checkpoint.processed_objects.contains(&key)
|
||||||
|
|| checkpoint.failed_objects.contains(&key)
|
||||||
|
|| checkpoint.skipped_objects.contains(&key)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_skip_new_version(item.mod_time_unix_nanos, started_at_secs) {
|
if should_skip_new_version(item.mod_time_unix_nanos, started_at_secs) {
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
let counter_ok = increment_counter(processed_objects);
|
||||||
*processed_objects = processed_objects.saturating_add(1);
|
|
||||||
completed_in_page = completed_in_page.saturating_add(1);
|
completed_in_page = completed_in_page.saturating_add(1);
|
||||||
counter!("rustfs_heal_skipped_new_versions_total").increment(1);
|
counter!("rustfs_heal_skipped_new_versions_total").increment(1);
|
||||||
{
|
let (skipped_new, skipped_ilm, counter_unknown) = {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.record_skipped_new_version();
|
progress.record_skipped_new_version();
|
||||||
progress.set_current_object(Some(format!("skipped_new: {bucket}/{}", item.name)));
|
progress.set_current_object(Some(format!("skipped_new: {bucket}/{}", item.name)));
|
||||||
progress.update_progress(*processed_objects, *successful_objects, *failed_objects, bytes_processed);
|
progress.update_object_progress(
|
||||||
|
*processed_objects,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if !counter_ok {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(progress.skipped_new_versions, progress.skipped_ilm_expired, progress.counter_unknown)
|
||||||
|
};
|
||||||
|
checkpoint_manager
|
||||||
|
.record_object_outcome(
|
||||||
|
key,
|
||||||
|
CheckpointObjectOutcome::Processed,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
skipped_new,
|
||||||
|
skipped_ilm,
|
||||||
|
!counter_ok || counter_unknown,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !counter_ok || counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
@@ -820,15 +954,40 @@ impl ErasureSetHealer {
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
let counter_ok = increment_counter(processed_objects);
|
||||||
*processed_objects = processed_objects.saturating_add(1);
|
|
||||||
completed_in_page = completed_in_page.saturating_add(1);
|
completed_in_page = completed_in_page.saturating_add(1);
|
||||||
counter!("rustfs_heal_skipped_ilm_expired_total").increment(1);
|
counter!("rustfs_heal_skipped_ilm_expired_total").increment(1);
|
||||||
{
|
let (skipped_new, skipped_ilm, counter_unknown) = {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.record_skipped_ilm_expired();
|
progress.record_skipped_ilm_expired();
|
||||||
progress.set_current_object(Some(format!("skipped_ilm: {bucket}/{}", item.name)));
|
progress.set_current_object(Some(format!("skipped_ilm: {bucket}/{}", item.name)));
|
||||||
progress.update_progress(*processed_objects, *successful_objects, *failed_objects, bytes_processed);
|
progress.update_object_progress(
|
||||||
|
*processed_objects,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if !counter_ok {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(progress.skipped_new_versions, progress.skipped_ilm_expired, progress.counter_unknown)
|
||||||
|
};
|
||||||
|
checkpoint_manager
|
||||||
|
.record_object_outcome(
|
||||||
|
key,
|
||||||
|
CheckpointObjectOutcome::Processed,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
skipped_new,
|
||||||
|
skipped_ilm,
|
||||||
|
!counter_ok || counter_unknown,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !counter_ok || counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
@@ -954,11 +1113,11 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
while let Some((key, object, version_id, result)) = page_tasks.next().await {
|
while let Some((key, object, version_id, result)) = page_tasks.next().await {
|
||||||
let (object_size, result) = result;
|
let (object_size, result) = result;
|
||||||
match result {
|
let mut telemetry_unknown = false;
|
||||||
|
let checkpoint_outcome = match result {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
*successful_objects += 1;
|
telemetry_unknown |= !increment_counter(successful_objects);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
@@ -971,11 +1130,11 @@ impl ErasureSetHealer {
|
|||||||
state = "healed",
|
state = "healed",
|
||||||
"Erasure set object healed"
|
"Erasure set object healed"
|
||||||
);
|
);
|
||||||
|
CheckpointObjectOutcome::Processed
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
telemetry_unknown |= !increment_counter(successful_objects);
|
||||||
*successful_objects += 1;
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
@@ -988,12 +1147,12 @@ impl ErasureSetHealer {
|
|||||||
state = "missing_treated_as_ok",
|
state = "missing_treated_as_ok",
|
||||||
"Erasure set missing object treated as ok"
|
"Erasure set missing object treated as ok"
|
||||||
);
|
);
|
||||||
|
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 }) => {
|
Err(Error::TransientSkip { message }) => {
|
||||||
*skipped_objects += 1;
|
telemetry_unknown |= !increment_counter(skipped_objects);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
checkpoint_manager.add_skipped_object(key).await?;
|
|
||||||
demote_to_debug_when!(!take_failure_log_sample(&mut transient_skip_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
demote_to_debug_when!(!take_failure_log_sample(&mut transient_skip_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
component = LOG_COMPONENT_HEAL,
|
component = LOG_COMPONENT_HEAL,
|
||||||
@@ -1006,11 +1165,11 @@ impl ErasureSetHealer {
|
|||||||
error = %message,
|
error = %message,
|
||||||
"Erasure set object heal skipped due to transient error"
|
"Erasure set object heal skipped due to transient error"
|
||||||
});
|
});
|
||||||
|
CheckpointObjectOutcome::Skipped
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
*failed_objects += 1;
|
telemetry_unknown |= !increment_counter(failed_objects);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
checkpoint_manager.add_failed_object(key).await?;
|
|
||||||
demote_to_debug_when!(!take_failure_log_sample(&mut failure_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
demote_to_debug_when!(!take_failure_log_sample(&mut failure_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
component = LOG_COMPONENT_HEAL,
|
component = LOG_COMPONENT_HEAL,
|
||||||
@@ -1023,15 +1182,42 @@ impl ErasureSetHealer {
|
|||||||
error = %err,
|
error = %err,
|
||||||
"Erasure set object heal failed"
|
"Erasure set object heal failed"
|
||||||
});
|
});
|
||||||
|
CheckpointObjectOutcome::Failed
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
*processed_objects += 1;
|
telemetry_unknown |= !increment_counter(processed_objects);
|
||||||
completed_in_page += 1;
|
completed_in_page += 1;
|
||||||
{
|
let (progress_unknown, skipped_new_versions, skipped_ilm_expired) = {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||||
progress.update_progress(*processed_objects, *successful_objects, *failed_objects, bytes_processed);
|
progress.update_object_progress(
|
||||||
|
*processed_objects,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if telemetry_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(progress.counter_unknown, progress.skipped_new_versions, progress.skipped_ilm_expired)
|
||||||
|
};
|
||||||
|
checkpoint_manager
|
||||||
|
.record_object_outcome(
|
||||||
|
key,
|
||||||
|
checkpoint_outcome,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
skipped_new_versions,
|
||||||
|
skipped_ilm_expired,
|
||||||
|
telemetry_unknown || progress_unknown,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if telemetry_unknown || progress_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if completed_in_page.is_multiple_of(100) {
|
if completed_in_page.is_multiple_of(100) {
|
||||||
@@ -1041,16 +1227,22 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
*current_object_index = global_obj_idx;
|
*current_object_index = global_obj_idx;
|
||||||
|
|
||||||
// Persist the authoritative cursor FIRST (points at the next page
|
// Persist the checkpoint ledger and page position before exposing
|
||||||
// boundary), then prune the per-version dedup sets. Both are
|
// the next resume cursor. A crash before cursor publication keeps
|
||||||
// idempotent under crash: heal_object re-heals safely.
|
// the page identities available for exact-once replay.
|
||||||
let next_cursor = if is_truncated { next_token.clone() } else { None };
|
checkpoint_manager.advance_page(bucket_index, *current_object_index).await?;
|
||||||
resume_manager.set_resume_cursor(next_cursor.clone()).await?;
|
|
||||||
checkpoint_manager.complete_page(bucket_index, *current_object_index).await?;
|
|
||||||
// Check if there are more pages
|
// Check if there are more pages
|
||||||
if !is_truncated {
|
if !is_truncated {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
continuation_token = next_heal_listing_token(bucket, "", next_token, is_truncated)?;
|
||||||
|
if continuation_token.is_none() {
|
||||||
|
// A truncated page without a continuation token is terminal.
|
||||||
|
// Retain its ledger until bucket completion is durable.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
resume_manager.set_resume_cursor(continuation_token.clone()).await?;
|
||||||
|
checkpoint_manager.prune_completed_page().await?;
|
||||||
|
|
||||||
// Anti-loop guard: an empty page reported as truncated cannot advance
|
// Anti-loop guard: an empty page reported as truncated cannot advance
|
||||||
// the cursor (there is no last identity to move past), so treat it as a
|
// the cursor (there is no last identity to move past), so treat it as a
|
||||||
@@ -1069,12 +1261,6 @@ impl ErasureSetHealer {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
previous_page_last = page_last;
|
previous_page_last = page_last;
|
||||||
|
|
||||||
continuation_token = next_heal_listing_token(bucket, "", next_token, is_truncated)?;
|
|
||||||
if continuation_token.is_none() {
|
|
||||||
// Truncated but no continuation token: treat as end of listing.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1083,10 +1269,66 @@ impl ErasureSetHealer {
|
|||||||
/// initialize progress tracking
|
/// initialize progress tracking
|
||||||
async fn initialize_progress(&self, _buckets: &[String], state: &crate::heal::resume::ResumeState) {
|
async fn initialize_progress(&self, _buckets: &[String], state: &crate::heal::resume::ResumeState) {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.objects_scanned = state.total_objects;
|
let existing_baseline = (
|
||||||
|
progress.objects_total_count,
|
||||||
|
progress.objects_total_size,
|
||||||
|
progress.baseline_generation,
|
||||||
|
progress.progress_state,
|
||||||
|
progress.baseline_known,
|
||||||
|
);
|
||||||
|
let baseline_generation_mismatch =
|
||||||
|
state.baseline_known && existing_baseline.4 && state.baseline_generation != existing_baseline.2;
|
||||||
|
let use_persisted_baseline = state.baseline_known && !baseline_generation_mismatch;
|
||||||
|
progress.objects_scanned = state.processed_objects;
|
||||||
progress.objects_healed = state.successful_objects;
|
progress.objects_healed = state.successful_objects;
|
||||||
progress.objects_failed = state.failed_objects;
|
progress.objects_failed = state.failed_objects;
|
||||||
progress.bytes_processed = 0; // Resume state tracks object counts, not byte counters.
|
progress.skipped_objects = state.skipped_objects;
|
||||||
|
progress.skipped_new_versions = state.skipped_new_versions;
|
||||||
|
progress.skipped_ilm_expired = state.skipped_ilm_expired;
|
||||||
|
progress.bytes_processed = state.processed_bytes;
|
||||||
|
progress.counter_unknown = state.counter_unknown;
|
||||||
|
if use_persisted_baseline
|
||||||
|
|| existing_baseline.0 > 0
|
||||||
|
|| existing_baseline.1 > 0
|
||||||
|
|| existing_baseline.2.is_some()
|
||||||
|
|| existing_baseline.4
|
||||||
|
{
|
||||||
|
progress.objects_total_count = if use_persisted_baseline {
|
||||||
|
state.total_objects
|
||||||
|
} else {
|
||||||
|
existing_baseline.0
|
||||||
|
};
|
||||||
|
progress.objects_total_size = if use_persisted_baseline {
|
||||||
|
state.total_bytes
|
||||||
|
} else {
|
||||||
|
existing_baseline.1
|
||||||
|
};
|
||||||
|
progress.baseline_generation = if use_persisted_baseline {
|
||||||
|
state.baseline_generation
|
||||||
|
} else {
|
||||||
|
existing_baseline.2
|
||||||
|
};
|
||||||
|
progress.baseline_known = use_persisted_baseline
|
||||||
|
|| existing_baseline.0 > 0
|
||||||
|
|| existing_baseline.1 > 0
|
||||||
|
|| existing_baseline.2.is_some()
|
||||||
|
|| existing_baseline.4;
|
||||||
|
}
|
||||||
|
progress.progress_state = if use_persisted_baseline
|
||||||
|
|| existing_baseline.0 > 0
|
||||||
|
|| existing_baseline.1 > 0
|
||||||
|
|| existing_baseline.2.is_some()
|
||||||
|
|| existing_baseline.4
|
||||||
|
{
|
||||||
|
crate::heal::progress::HealProgressState::Running
|
||||||
|
} else {
|
||||||
|
crate::heal::progress::HealProgressState::Indeterminate
|
||||||
|
};
|
||||||
|
if baseline_generation_mismatch || state.counter_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
progress.ledger_complete = false;
|
||||||
|
progress.refresh_progress_percentage();
|
||||||
progress.start_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.start_time));
|
progress.start_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.start_time));
|
||||||
progress.last_update_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.last_update));
|
progress.last_update_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.last_update));
|
||||||
progress.set_current_object(state.current_object.clone());
|
progress.set_current_object(state.current_object.clone());
|
||||||
@@ -1264,8 +1506,8 @@ mod resume_loop_tests {
|
|||||||
};
|
};
|
||||||
use crate::heal::progress::HealProgress;
|
use crate::heal::progress::HealProgress;
|
||||||
use crate::heal::resume::{
|
use crate::heal::resume::{
|
||||||
CheckpointManager, RESUME_CHECKPOINT_FILE, ReplacementTargetIdentity, ResumeDeleteFailure, ResumeManager, ResumeUtils,
|
CheckpointManager, CheckpointObjectOutcome, RESUME_CHECKPOINT_FILE, ReplacementTargetIdentity, ResumeDeleteFailure,
|
||||||
compose_key,
|
ResumeManager, ResumeUtils, compose_key,
|
||||||
};
|
};
|
||||||
use crate::heal::storage::{HealLifecycleExpiryContext, HealListItem, HealObjectInfo, HealStorageAPI};
|
use crate::heal::storage::{HealLifecycleExpiryContext, HealListItem, HealObjectInfo, HealStorageAPI};
|
||||||
use crate::heal::storage_api::status::BucketInfo;
|
use crate::heal::storage_api::status::BucketInfo;
|
||||||
@@ -1405,6 +1647,7 @@ mod resume_loop_tests {
|
|||||||
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
|
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
|
||||||
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
|
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
|
||||||
fail_listing: AtomicBool,
|
fail_listing: AtomicBool,
|
||||||
|
fail_listing_buckets: Mutex<HashSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeStorage {
|
impl FakeStorage {
|
||||||
@@ -1441,6 +1684,9 @@ mod resume_loop_tests {
|
|||||||
fn fail_listing(&self) {
|
fn fail_listing(&self) {
|
||||||
self.fail_listing.store(true, Ordering::SeqCst);
|
self.fail_listing.store(true, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
fn fail_bucket_listing(&self, bucket: &str) {
|
||||||
|
self.fail_listing_buckets.lock().unwrap().insert(bucket.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -1531,7 +1777,7 @@ mod resume_loop_tests {
|
|||||||
}
|
}
|
||||||
async fn list_objects_for_heal_page(
|
async fn list_objects_for_heal_page(
|
||||||
&self,
|
&self,
|
||||||
_bucket: &str,
|
bucket: &str,
|
||||||
_prefix: &str,
|
_prefix: &str,
|
||||||
continuation_token: Option<&str>,
|
continuation_token: Option<&str>,
|
||||||
include_lifecycle_object_info: bool,
|
include_lifecycle_object_info: bool,
|
||||||
@@ -1540,7 +1786,7 @@ mod resume_loop_tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push(include_lifecycle_object_info);
|
.push(include_lifecycle_object_info);
|
||||||
if self.fail_listing.load(Ordering::SeqCst) {
|
if self.fail_listing.load(Ordering::SeqCst) || self.fail_listing_buckets.lock().unwrap().contains(bucket) {
|
||||||
return Err(Error::other("injected listing failure"));
|
return Err(Error::other("injected listing failure"));
|
||||||
}
|
}
|
||||||
let key = continuation_token.map(str::to_string);
|
let key = continuation_token.map(str::to_string);
|
||||||
@@ -1918,6 +2164,49 @@ mod resume_loop_tests {
|
|||||||
assert!(state.completed_buckets.is_empty(), "the failed bucket must remain resumable");
|
assert!(state.completed_buckets.is_empty(), "the failed bucket must remain resumable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_failure_stops_before_a_later_bucket_checkpoint() {
|
||||||
|
let env = make_env().await;
|
||||||
|
let task_id = ResumeUtils::generate_task_id();
|
||||||
|
let buckets = vec!["a".to_string(), "b".to_string()];
|
||||||
|
let resume = ResumeManager::new(
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
task_id.clone(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
buckets.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::new(env.healer.disk.clone(), task_id.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.storage.fail_bucket_listing("a");
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert!(resume.schedule_retry().await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&buckets, "pool_0_set_0", &resume, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("the first bucket failure must keep the pass incomplete");
|
||||||
|
let persisted = checkpoint.get_checkpoint().await;
|
||||||
|
assert_eq!(persisted.current_bucket_index, 0);
|
||||||
|
assert!(resume.get_state().await.completed_buckets.is_empty());
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&buckets, "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("recovery must retry the earlier failed bucket");
|
||||||
|
assert!(!resumed.get_state().await.completed);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn completed_resume_state_is_not_selected_for_a_new_heal() {
|
async fn completed_resume_state_is_not_selected_for_a_new_heal() {
|
||||||
let env = make_env().await;
|
let env = make_env().await;
|
||||||
@@ -2107,8 +2396,175 @@ mod resume_loop_tests {
|
|||||||
let mut names: Vec<String> = env.storage.calls().into_iter().map(|(n, _)| n).collect();
|
let mut names: Vec<String> = env.storage.calls().into_iter().map(|(n, _)| n).collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
assert_eq!(names, vec!["a", "b", "c", "d"], "every object exactly once, none dropped/doubled");
|
assert_eq!(names, vec!["a", "b", "c", "d"], "every object exactly once, none dropped/doubled");
|
||||||
// Final page not truncated => cursor cleared.
|
// Keep the final page cursor until the outer loop durably completes the
|
||||||
assert_eq!(env.resume.resume_cursor().await, None);
|
// bucket, so a crash can replay only this page against its identities.
|
||||||
|
assert_eq!(env.resume.resume_cursor().await, Some("t1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persisted_failure_waits_for_the_bounded_retry_after_page_replay() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("object", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
env.checkpoint
|
||||||
|
.record_object_outcome(
|
||||||
|
compose_key("object", Some("v1")),
|
||||||
|
CheckpointObjectOutcome::Failed,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.checkpoint.advance_page(0, 1).await.unwrap();
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("the persisted failure must schedule a bounded retry");
|
||||||
|
assert!(
|
||||||
|
env.storage.calls().is_empty(),
|
||||||
|
"the failed identity must not be repeated in the same pass"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("the bounded retry must heal the object");
|
||||||
|
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
|
||||||
|
let state = resumed.get_state().await;
|
||||||
|
assert_eq!(state.successful_objects, 1);
|
||||||
|
assert_eq!(state.failed_objects, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn final_page_crash_replays_only_the_retained_page_identities() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("first", Some("v1"), false)],
|
||||||
|
next: Some("final-page".to_string()),
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
env.storage.set_page(
|
||||||
|
Some("final-page"),
|
||||||
|
Page {
|
||||||
|
items: vec![item("last", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||||
|
result.expect("the bucket pass must finish before the simulated crash");
|
||||||
|
assert_eq!((processed, successful, failed, skipped), (2, 2, 0, 0));
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("the retained final-page ledger must make recovery exact");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.storage.calls(),
|
||||||
|
vec![
|
||||||
|
("first".to_string(), Some("v1".to_string())),
|
||||||
|
("last".to_string(), Some("v1".to_string()))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let state = resumed.get_state().await;
|
||||||
|
assert_eq!(state.successful_objects, 2);
|
||||||
|
assert_eq!(state.processed_objects, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn truncated_page_without_token_retains_its_replay_ledger() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("object", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||||
|
result.expect("the tokenless truncated page is a terminal page");
|
||||||
|
assert_eq!((processed, successful, failed, skipped), (1, 1, 0, 0));
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("terminal-page recovery must not replay a durable identity");
|
||||||
|
|
||||||
|
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
|
||||||
|
let state = resumed.get_state().await;
|
||||||
|
assert_eq!(state.successful_objects, 1);
|
||||||
|
assert_eq!(state.processed_objects, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completed_bucket_reconciles_its_final_page_checkpoint_after_crash() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("object", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, _, _, _, result) = run(&env).await;
|
||||||
|
result.expect("the bucket pass must finish before the simulated crash");
|
||||||
|
env.resume.complete_bucket("b").await.unwrap();
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("recovery must finish the checkpoint transition without replaying the bucket");
|
||||||
|
|
||||||
|
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
|
||||||
|
let checkpoint = checkpoint.get_checkpoint().await;
|
||||||
|
assert_eq!(checkpoint.current_bucket_index, 1);
|
||||||
|
assert!(checkpoint.processed_objects.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2012,16 +2012,44 @@ impl HealManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut snapshot = HealProgress::default();
|
let mut snapshot = HealProgress::default();
|
||||||
|
let mut has_object_sweep = false;
|
||||||
|
let mut all_object_baselines_known = true;
|
||||||
|
let mut counter_overflow = false;
|
||||||
|
let mut stage_current = 0_u64;
|
||||||
|
let mut stage_total = 0_u64;
|
||||||
for task in active_tasks {
|
for task in active_tasks {
|
||||||
let progress = task.get_progress().await;
|
let progress = task.get_progress().await;
|
||||||
snapshot.objects_scanned = snapshot.objects_scanned.saturating_add(progress.objects_scanned);
|
let object_sweep = matches!(progress.kind, crate::heal::progress::HealProgressKind::ObjectSweep);
|
||||||
snapshot.objects_healed = snapshot.objects_healed.saturating_add(progress.objects_healed);
|
has_object_sweep |= object_sweep;
|
||||||
snapshot.objects_failed = snapshot.objects_failed.saturating_add(progress.objects_failed);
|
if object_sweep {
|
||||||
snapshot.skipped_new_versions = snapshot.skipped_new_versions.saturating_add(progress.skipped_new_versions);
|
all_object_baselines_known &= progress.baseline_known;
|
||||||
snapshot.skipped_ilm_expired = snapshot.skipped_ilm_expired.saturating_add(progress.skipped_ilm_expired);
|
}
|
||||||
snapshot.objects_total_count = snapshot.objects_total_count.saturating_add(progress.objects_total_count);
|
counter_overflow |=
|
||||||
snapshot.objects_total_size = snapshot.objects_total_size.saturating_add(progress.objects_total_size);
|
progress.counter_unknown || matches!(progress.progress_state, crate::heal::progress::HealProgressState::Unknown);
|
||||||
snapshot.bytes_processed = snapshot.bytes_processed.saturating_add(progress.bytes_processed);
|
match stage_current.checked_add(progress.stage_current) {
|
||||||
|
Some(sum) => stage_current = sum,
|
||||||
|
None => counter_overflow = true,
|
||||||
|
}
|
||||||
|
match stage_total.checked_add(progress.stage_total) {
|
||||||
|
Some(sum) => stage_total = sum,
|
||||||
|
None => counter_overflow = true,
|
||||||
|
}
|
||||||
|
for (target, value) in [
|
||||||
|
(&mut snapshot.objects_scanned, progress.objects_scanned),
|
||||||
|
(&mut snapshot.objects_healed, progress.objects_healed),
|
||||||
|
(&mut snapshot.objects_failed, progress.objects_failed),
|
||||||
|
(&mut snapshot.skipped_objects, progress.skipped_objects),
|
||||||
|
(&mut snapshot.skipped_new_versions, progress.skipped_new_versions),
|
||||||
|
(&mut snapshot.skipped_ilm_expired, progress.skipped_ilm_expired),
|
||||||
|
(&mut snapshot.objects_total_count, progress.objects_total_count),
|
||||||
|
(&mut snapshot.objects_total_size, progress.objects_total_size),
|
||||||
|
(&mut snapshot.bytes_processed, progress.bytes_processed),
|
||||||
|
] {
|
||||||
|
match target.checked_add(value) {
|
||||||
|
Some(sum) => *target = sum,
|
||||||
|
None => counter_overflow = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
snapshot.start_time = match (snapshot.start_time, progress.start_time) {
|
snapshot.start_time = match (snapshot.start_time, progress.start_time) {
|
||||||
(Some(current), Some(next)) => Some(current.min(next)),
|
(Some(current), Some(next)) => Some(current.min(next)),
|
||||||
(None, next) => next,
|
(None, next) => next,
|
||||||
@@ -2036,7 +2064,36 @@ impl HealManager {
|
|||||||
snapshot.current_object = progress.current_object;
|
snapshot.current_object = progress.current_object;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
snapshot.kind = if has_object_sweep {
|
||||||
|
crate::heal::progress::HealProgressKind::ObjectSweep
|
||||||
|
} else {
|
||||||
|
crate::heal::progress::HealProgressKind::Stage
|
||||||
|
};
|
||||||
|
snapshot.stage_current = stage_current;
|
||||||
|
snapshot.stage_total = stage_total;
|
||||||
|
snapshot.baseline_known = has_object_sweep && all_object_baselines_known;
|
||||||
|
snapshot.progress_state = if counter_overflow {
|
||||||
|
crate::heal::progress::HealProgressState::Unknown
|
||||||
|
} else if has_object_sweep && !all_object_baselines_known {
|
||||||
|
crate::heal::progress::HealProgressState::Indeterminate
|
||||||
|
} else if has_object_sweep {
|
||||||
|
crate::heal::progress::HealProgressState::Running
|
||||||
|
} else if stage_total == 0 {
|
||||||
|
crate::heal::progress::HealProgressState::Indeterminate
|
||||||
|
} else {
|
||||||
|
crate::heal::progress::HealProgressState::Running
|
||||||
|
};
|
||||||
|
if counter_overflow {
|
||||||
|
snapshot.progress_percentage = 0.0;
|
||||||
|
} else if !has_object_sweep {
|
||||||
|
snapshot.progress_percentage = if stage_total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
((stage_current as f64 / stage_total as f64) * 100.0).min(99.999)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
snapshot.refresh_progress_percentage();
|
snapshot.refresh_progress_percentage();
|
||||||
|
}
|
||||||
snapshot.refresh_estimated_completion_time();
|
snapshot.refresh_estimated_completion_time();
|
||||||
Some(snapshot)
|
Some(snapshot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,70 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
pub(crate) fn increment_counter(counter: &mut u64) -> bool {
|
||||||
|
match counter.checked_add(1) {
|
||||||
|
Some(next) => {
|
||||||
|
*counter = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*counter = u64::MAX;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_bytes(total: &mut u64, amount: u64) -> bool {
|
||||||
|
match total.checked_add(amount) {
|
||||||
|
Some(next) => {
|
||||||
|
*total = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*total = u64::MAX;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum HealProgressKind {
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
Stage,
|
||||||
|
ObjectSweep,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the object ledger can produce a meaningful percentage.
|
||||||
|
///
|
||||||
|
/// A zero-valued baseline is not a completed scan: it means that no complete
|
||||||
|
/// usage snapshot was available. Keep this state explicit so callers do not
|
||||||
|
/// mistake the legacy `0.0` wire value for a measured zero-percent result.
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum HealProgressState {
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
Indeterminate,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct HealProgress {
|
pub struct HealProgress {
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: HealProgressKind,
|
||||||
/// Objects scanned
|
/// Objects scanned
|
||||||
pub objects_scanned: u64,
|
pub objects_scanned: u64,
|
||||||
/// Objects healed
|
/// Objects healed
|
||||||
pub objects_healed: u64,
|
pub objects_healed: u64,
|
||||||
/// Objects failed
|
/// Objects failed
|
||||||
pub objects_failed: u64,
|
pub objects_failed: u64,
|
||||||
|
/// Versions deferred for a later retry pass.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_objects: u64,
|
||||||
/// Versions skipped because they were written after this heal started
|
/// Versions skipped because they were written after this heal started
|
||||||
pub skipped_new_versions: u64,
|
pub skipped_new_versions: u64,
|
||||||
/// Versions skipped because lifecycle already selected them for expiry
|
/// Versions skipped because lifecycle already selected them for expiry
|
||||||
@@ -44,11 +99,38 @@ pub struct HealProgress {
|
|||||||
pub last_update_time: Option<SystemTime>,
|
pub last_update_time: Option<SystemTime>,
|
||||||
/// Estimated completion time
|
/// Estimated completion time
|
||||||
pub estimated_completion_time: Option<SystemTime>,
|
pub estimated_completion_time: Option<SystemTime>,
|
||||||
|
/// Current stage number. Stage updates are intentionally independent from
|
||||||
|
/// the object ledger below.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stage_current: u64,
|
||||||
|
/// Number of stages in the current task.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stage_total: u64,
|
||||||
|
/// Explicitly distinguishes a missing usage baseline from measured 0%.
|
||||||
|
#[serde(default)]
|
||||||
|
pub progress_state: HealProgressState,
|
||||||
|
/// True only after the task's durable completion ledger was committed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ledger_complete: bool,
|
||||||
|
/// Generation of the usage snapshot used for the baseline, if available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_generation: Option<u64>,
|
||||||
|
/// Whether the baseline was explicitly observed. This is separate from
|
||||||
|
/// the counters so a known empty scope (0 objects, 0 bytes) is not
|
||||||
|
/// confused with a legacy snapshot that omitted the baseline fields.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_known: bool,
|
||||||
|
/// Internal telemetry fence set when an aggregate counter overflows or
|
||||||
|
/// becomes inconsistent. It prevents a later refresh from fabricating a
|
||||||
|
/// percentage from the poisoned values.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_unknown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HealProgress {
|
impl HealProgress {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
kind: HealProgressKind::Unknown,
|
||||||
start_time: Some(SystemTime::now()),
|
start_time: Some(SystemTime::now()),
|
||||||
last_update_time: Some(SystemTime::now()),
|
last_update_time: Some(SystemTime::now()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -56,12 +138,87 @@ impl HealProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_progress(&mut self, scanned: u64, healed: u64, failed: u64, bytes: u64) {
|
pub fn update_progress(&mut self, scanned: u64, healed: u64, failed: u64, bytes: u64) {
|
||||||
|
self.update_object_sweep_progress(scanned, healed, failed, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_object_sweep_progress(&mut self, scanned: u64, healed: u64, failed: u64, bytes: u64) {
|
||||||
|
self.kind = HealProgressKind::ObjectSweep;
|
||||||
self.objects_scanned = scanned;
|
self.objects_scanned = scanned;
|
||||||
self.objects_healed = healed;
|
self.objects_healed = healed;
|
||||||
self.objects_failed = failed;
|
self.objects_failed = failed;
|
||||||
self.bytes_processed = bytes;
|
self.bytes_processed = bytes;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
|
||||||
|
let explicit_skipped = match self.skipped_new_versions.checked_add(self.skipped_ilm_expired) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
self.mark_unknown();
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let skipped = healed
|
||||||
|
.checked_add(failed)
|
||||||
|
.and_then(|value| value.checked_add(explicit_skipped))
|
||||||
|
.and_then(|value| scanned.checked_sub(value))
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.update_object_progress(scanned, healed, failed, skipped, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update task stage progress without modifying object counters.
|
||||||
|
pub fn update_stage(&mut self, current: u64, total: u64) {
|
||||||
|
let object_sweep_active = matches!(self.kind, HealProgressKind::ObjectSweep);
|
||||||
|
if !object_sweep_active {
|
||||||
|
self.kind = HealProgressKind::Stage;
|
||||||
|
}
|
||||||
|
self.ledger_complete = false;
|
||||||
|
self.stage_current = current.min(total);
|
||||||
|
self.stage_total = total;
|
||||||
|
if object_sweep_active {
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
self.refresh_progress_percentage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.progress_state = if total == 0 {
|
||||||
|
HealProgressState::Indeterminate
|
||||||
|
} else {
|
||||||
|
HealProgressState::Running
|
||||||
|
};
|
||||||
|
self.progress_percentage = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(current as f64 / total as f64 * 100.0).min(100.0)
|
||||||
|
};
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the disjoint object ledger. `scanned` is the number of terminal
|
||||||
|
/// object outcomes and must equal healed + failed + deferred skipped plus
|
||||||
|
/// the two terminal skip classes. Overflow is a corrupt/unknown counter
|
||||||
|
/// state, not a reason to abort a completed heal.
|
||||||
|
pub fn update_object_progress(&mut self, scanned: u64, healed: u64, failed: u64, skipped: u64, bytes: u64) {
|
||||||
|
self.kind = HealProgressKind::ObjectSweep;
|
||||||
|
// `skipped` is the transient/deferred class. The two explicit skip
|
||||||
|
// counters are terminal classifications too, so include them in the
|
||||||
|
// same ledger without making callers maintain a second aggregate.
|
||||||
|
let outcomes = healed
|
||||||
|
.checked_add(failed)
|
||||||
|
.and_then(|value| value.checked_add(skipped))
|
||||||
|
.and_then(|value| value.checked_add(self.skipped_new_versions))
|
||||||
|
.and_then(|value| value.checked_add(self.skipped_ilm_expired));
|
||||||
|
self.objects_scanned = scanned;
|
||||||
|
self.objects_healed = healed;
|
||||||
|
self.objects_failed = failed;
|
||||||
|
self.skipped_objects = skipped;
|
||||||
|
self.bytes_processed = bytes;
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
self.ledger_complete = false;
|
||||||
|
if outcomes != Some(scanned) {
|
||||||
|
// Telemetry corruption must not abort a heal. Preserve the
|
||||||
|
// counters for diagnostics, but do not derive a percentage from a
|
||||||
|
// double-counted or overflowing ledger.
|
||||||
|
self.mark_unknown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
@@ -69,50 +226,88 @@ impl HealProgress {
|
|||||||
pub fn set_total_baseline(&mut self, objects_total_count: u64, objects_total_size: u64) {
|
pub fn set_total_baseline(&mut self, objects_total_count: u64, objects_total_size: u64) {
|
||||||
self.objects_total_count = objects_total_count;
|
self.objects_total_count = objects_total_count;
|
||||||
self.objects_total_size = objects_total_size;
|
self.objects_total_size = objects_total_size;
|
||||||
|
self.baseline_known = true;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_total_baseline_with_generation(&mut self, objects_total_count: u64, objects_total_size: u64, generation: u64) {
|
||||||
|
self.baseline_generation = Some(generation);
|
||||||
|
self.set_total_baseline(objects_total_count, objects_total_size);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_skipped_new_version(&mut self) {
|
pub fn record_skipped_new_version(&mut self) {
|
||||||
self.skipped_new_versions = self.skipped_new_versions.saturating_add(1);
|
let Some(next) = self.skipped_new_versions.checked_add(1) else {
|
||||||
|
self.mark_unknown();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.skipped_new_versions = next;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_skipped_ilm_expired(&mut self) {
|
pub fn record_skipped_ilm_expired(&mut self) {
|
||||||
self.skipped_ilm_expired = self.skipped_ilm_expired.saturating_add(1);
|
let Some(next) = self.skipped_ilm_expired.checked_add(1) else {
|
||||||
|
self.mark_unknown();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.skipped_ilm_expired = next;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn completed_for_baseline(&self) -> u64 {
|
fn completed_for_baseline(&self) -> Option<u64> {
|
||||||
self.objects_healed
|
self.objects_healed
|
||||||
.saturating_add(self.objects_failed)
|
.checked_add(self.objects_failed)?
|
||||||
.saturating_add(self.skipped_new_versions)
|
.checked_add(self.skipped_objects)?
|
||||||
.saturating_add(self.skipped_ilm_expired)
|
.checked_add(self.skipped_new_versions)?
|
||||||
|
.checked_add(self.skipped_ilm_expired)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn refresh_progress_percentage(&mut self) {
|
pub(crate) fn refresh_progress_percentage(&mut self) {
|
||||||
|
if self.ledger_complete {
|
||||||
|
self.progress_state = HealProgressState::Completed;
|
||||||
|
self.progress_percentage = 100.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.counter_unknown {
|
||||||
|
self.progress_state = HealProgressState::Unknown;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.baseline_known {
|
||||||
|
self.progress_state = HealProgressState::Indeterminate;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
self.estimated_completion_time = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.objects_total_size > 0 {
|
if self.objects_total_size > 0 {
|
||||||
self.progress_percentage = ((self.bytes_processed as f64 / self.objects_total_size as f64) * 100.0).min(100.0);
|
self.progress_percentage = ((self.bytes_processed as f64 / self.objects_total_size as f64) * 100.0).min(100.0);
|
||||||
|
self.progress_percentage = self.progress_percentage.min(99.999);
|
||||||
|
self.progress_state = HealProgressState::Running;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.objects_total_count > 0 {
|
if self.objects_total_count > 0 {
|
||||||
let completed = self.completed_for_baseline();
|
let Some(completed) = self.completed_for_baseline() else {
|
||||||
|
self.progress_state = HealProgressState::Unknown;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
return;
|
||||||
|
};
|
||||||
self.progress_percentage = ((completed as f64 / self.objects_total_count as f64) * 100.0).min(100.0);
|
self.progress_percentage = ((completed as f64 / self.objects_total_count as f64) * 100.0).min(100.0);
|
||||||
|
self.progress_percentage = self.progress_percentage.min(99.999);
|
||||||
|
self.progress_state = HealProgressState::Running;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.baseline_known {
|
||||||
let total = self
|
self.progress_state = HealProgressState::Running;
|
||||||
.objects_scanned
|
self.progress_percentage = 0.0;
|
||||||
.saturating_add(self.objects_healed)
|
return;
|
||||||
.saturating_add(self.objects_failed);
|
|
||||||
if total > 0 {
|
|
||||||
self.progress_percentage = (self.objects_healed as f64 / total as f64) * 100.0;
|
|
||||||
}
|
}
|
||||||
|
self.progress_state = HealProgressState::Indeterminate;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_current_object(&mut self, object: Option<String>) {
|
pub fn set_current_object(&mut self, object: Option<String>) {
|
||||||
@@ -125,7 +320,11 @@ impl HealProgress {
|
|||||||
self.estimated_completion_time = None;
|
self.estimated_completion_time = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.is_completed() || !(0.0..100.0).contains(&self.progress_percentage) || self.bytes_processed == 0 {
|
if self.is_completed()
|
||||||
|
|| self.progress_percentage <= 0.0
|
||||||
|
|| self.progress_percentage >= 100.0
|
||||||
|
|| self.bytes_processed == 0
|
||||||
|
{
|
||||||
self.estimated_completion_time = None;
|
self.estimated_completion_time = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,18 +341,39 @@ impl HealProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
if self.progress_percentage >= 100.0 {
|
self.ledger_complete
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if self.objects_total_count > 0 || self.objects_total_size > 0 {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.objects_scanned > 0 && self.objects_healed.saturating_add(self.objects_failed) >= self.objects_scanned
|
/// Mark telemetry unknown while allowing the underlying heal operation to
|
||||||
|
/// continue. This is used for corrupt/overflowing counters at the
|
||||||
|
/// observability boundary; it must never turn a successful heal into an
|
||||||
|
/// execution error.
|
||||||
|
pub fn mark_unknown(&mut self) {
|
||||||
|
self.counter_unknown = true;
|
||||||
|
self.progress_state = HealProgressState::Unknown;
|
||||||
|
self.ledger_complete = false;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
self.estimated_completion_time = None;
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the object ledger terminal only after the enclosing task has
|
||||||
|
/// committed all durable resume state and cleanup fences.
|
||||||
|
pub fn mark_completed(&mut self) {
|
||||||
|
let telemetry_unknown = self.counter_unknown || self.progress_state == HealProgressState::Unknown;
|
||||||
|
self.ledger_complete = true;
|
||||||
|
if !telemetry_unknown {
|
||||||
|
self.progress_state = HealProgressState::Completed;
|
||||||
|
}
|
||||||
|
self.progress_percentage = 100.0;
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
self.estimated_completion_time = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_success_rate(&self) -> f64 {
|
pub fn get_success_rate(&self) -> f64 {
|
||||||
let total = self.objects_healed + self.objects_failed;
|
let Some(total) = self.objects_healed.checked_add(self.objects_failed) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
(self.objects_healed as f64 / total as f64) * 100.0
|
(self.objects_healed as f64 / total as f64) * 100.0
|
||||||
} else {
|
} else {
|
||||||
@@ -230,6 +450,7 @@ mod tests {
|
|||||||
assert_eq!(progress.objects_scanned, 0);
|
assert_eq!(progress.objects_scanned, 0);
|
||||||
assert_eq!(progress.objects_healed, 0);
|
assert_eq!(progress.objects_healed, 0);
|
||||||
assert_eq!(progress.objects_failed, 0);
|
assert_eq!(progress.objects_failed, 0);
|
||||||
|
assert_eq!(progress.skipped_objects, 0);
|
||||||
assert_eq!(progress.skipped_new_versions, 0);
|
assert_eq!(progress.skipped_new_versions, 0);
|
||||||
assert_eq!(progress.skipped_ilm_expired, 0);
|
assert_eq!(progress.skipped_ilm_expired, 0);
|
||||||
assert_eq!(progress.objects_total_count, 0);
|
assert_eq!(progress.objects_total_count, 0);
|
||||||
@@ -250,10 +471,8 @@ mod tests {
|
|||||||
assert_eq!(progress.objects_healed, 8);
|
assert_eq!(progress.objects_healed, 8);
|
||||||
assert_eq!(progress.objects_failed, 2);
|
assert_eq!(progress.objects_failed, 2);
|
||||||
assert_eq!(progress.bytes_processed, 1024);
|
assert_eq!(progress.bytes_processed, 1024);
|
||||||
// Progress percentage should be calculated based on healed/total
|
assert_eq!(progress.progress_state, HealProgressState::Indeterminate);
|
||||||
// total = scanned + healed + failed = 10 + 8 + 2 = 20
|
assert_eq!(progress.progress_percentage, 0.0);
|
||||||
// healed/total = 8/20 = 0.4 = 40%
|
|
||||||
assert!((progress.progress_percentage - 40.0).abs() < 0.001);
|
|
||||||
assert!(progress.last_update_time.is_some());
|
assert!(progress.last_update_time.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +481,8 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.start_time = Some(SystemTime::now() - Duration::from_secs(10));
|
progress.start_time = Some(SystemTime::now() - Duration::from_secs(10));
|
||||||
|
|
||||||
progress.update_progress(100, 25, 0, 4096);
|
progress.set_total_baseline(100, 16384);
|
||||||
|
progress.update_progress(25, 25, 0, 4096);
|
||||||
|
|
||||||
let eta = progress
|
let eta = progress
|
||||||
.estimated_completion_time
|
.estimated_completion_time
|
||||||
@@ -275,7 +495,7 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.set_total_baseline(10, 8192);
|
progress.set_total_baseline(10, 8192);
|
||||||
|
|
||||||
progress.update_progress(100, 25, 0, 4096);
|
progress.update_progress(25, 25, 0, 4096);
|
||||||
|
|
||||||
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
@@ -285,7 +505,7 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.set_total_baseline(10, 0);
|
progress.set_total_baseline(10, 0);
|
||||||
|
|
||||||
progress.update_progress(100, 3, 2, 0);
|
progress.update_progress(5, 3, 2, 0);
|
||||||
|
|
||||||
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
@@ -295,7 +515,7 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.set_total_baseline(10, 0);
|
progress.set_total_baseline(10, 0);
|
||||||
|
|
||||||
progress.update_progress(100, 3, 2, 0);
|
progress.update_progress(5, 3, 2, 0);
|
||||||
progress.record_skipped_new_version();
|
progress.record_skipped_new_version();
|
||||||
|
|
||||||
assert_eq!(progress.skipped_new_versions, 1);
|
assert_eq!(progress.skipped_new_versions, 1);
|
||||||
@@ -336,7 +556,8 @@ mod tests {
|
|||||||
fn test_heal_progress_update_progress_all_healed() {
|
fn test_heal_progress_update_progress_all_healed() {
|
||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
// When scanned=0, healed=10, failed=0: total=10, progress = 10/10 = 100%
|
// When scanned=0, healed=10, failed=0: total=10, progress = 10/10 = 100%
|
||||||
progress.update_progress(0, 10, 0, 2048);
|
progress.update_progress(10, 10, 0, 2048);
|
||||||
|
progress.mark_completed();
|
||||||
|
|
||||||
// All healed, should be 100%
|
// All healed, should be 100%
|
||||||
assert!((progress.progress_percentage - 100.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 100.0).abs() < 0.001);
|
||||||
@@ -394,6 +615,7 @@ mod tests {
|
|||||||
assert_eq!(json["objectsScanned"], 10);
|
assert_eq!(json["objectsScanned"], 10);
|
||||||
assert_eq!(json["objectsHealed"], 8);
|
assert_eq!(json["objectsHealed"], 8);
|
||||||
assert_eq!(json["objectsFailed"], 2);
|
assert_eq!(json["objectsFailed"], 2);
|
||||||
|
assert_eq!(json["skippedObjects"], 0);
|
||||||
assert_eq!(json["skippedNewVersions"], 0);
|
assert_eq!(json["skippedNewVersions"], 0);
|
||||||
assert_eq!(json["skippedIlmExpired"], 0);
|
assert_eq!(json["skippedIlmExpired"], 0);
|
||||||
assert_eq!(json["bytesProcessed"], 1024);
|
assert_eq!(json["bytesProcessed"], 1024);
|
||||||
@@ -405,6 +627,7 @@ mod tests {
|
|||||||
fn test_heal_progress_is_completed_by_percentage() {
|
fn test_heal_progress_is_completed_by_percentage() {
|
||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.update_progress(10, 10, 0, 1024);
|
progress.update_progress(10, 10, 0, 1024);
|
||||||
|
progress.mark_completed();
|
||||||
|
|
||||||
assert!(progress.is_completed());
|
assert!(progress.is_completed());
|
||||||
}
|
}
|
||||||
@@ -415,7 +638,7 @@ mod tests {
|
|||||||
progress.objects_scanned = 10;
|
progress.objects_scanned = 10;
|
||||||
progress.objects_healed = 8;
|
progress.objects_healed = 8;
|
||||||
progress.objects_failed = 2;
|
progress.objects_failed = 2;
|
||||||
// healed + failed = 8 + 2 = 10 >= scanned = 10
|
progress.mark_completed();
|
||||||
assert!(progress.is_completed());
|
assert!(progress.is_completed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +678,66 @@ mod tests {
|
|||||||
assert!((progress.get_success_rate() - 100.0).abs() < 0.001);
|
assert!((progress.get_success_rate() - 100.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_object_progress_reaches_terminal_100() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
assert!(!progress.is_completed());
|
||||||
|
progress.mark_completed();
|
||||||
|
assert!(progress.is_completed());
|
||||||
|
assert_eq!(progress.progress_percentage, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_without_baseline_is_indeterminate() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
assert_eq!(progress.progress_state, HealProgressState::Indeterminate);
|
||||||
|
assert_eq!(progress.progress_percentage, 0.0);
|
||||||
|
assert!(progress.estimated_completion_time.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_retry_is_exactly_once() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.set_total_baseline(1, 128);
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
assert_eq!(progress.objects_scanned, 1);
|
||||||
|
assert_eq!(progress.objects_healed, 1);
|
||||||
|
assert_eq!(progress.bytes_processed, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_never_triggers_cleanup_before_terminal_ledger_empty() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.progress_percentage = 100.0;
|
||||||
|
assert!(!progress.is_completed());
|
||||||
|
progress.mark_completed();
|
||||||
|
assert!(progress.is_completed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_counter_overflow_is_marked_unknown_without_aborting_completed_heal() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(u64::MAX, u64::MAX, 1, 0, 0);
|
||||||
|
assert_eq!(progress.progress_state, HealProgressState::Unknown);
|
||||||
|
progress.mark_completed();
|
||||||
|
assert!(progress.is_completed());
|
||||||
|
assert_eq!(progress.progress_state, HealProgressState::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_updates_do_not_double_count_object_outcomes() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(2, 1, 0, 1, 256);
|
||||||
|
progress.update_stage(3, 4);
|
||||||
|
assert_eq!(progress.kind, HealProgressKind::ObjectSweep);
|
||||||
|
assert_eq!(progress.objects_scanned, 2);
|
||||||
|
assert_eq!(progress.objects_healed, 1);
|
||||||
|
assert_eq!(progress.skipped_objects, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_heal_statistics_new() {
|
fn test_heal_statistics_new() {
|
||||||
let stats = HealStatistics::new();
|
let stats = HealStatistics::new();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ mod checkpoint;
|
|||||||
mod replacement;
|
mod replacement;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use checkpoint::{CheckpointManager, ResumeCheckpoint};
|
pub use checkpoint::{CheckpointManager, CheckpointObjectOutcome, ResumeCheckpoint};
|
||||||
pub(crate) use replacement::replacement_target_identities_match;
|
pub(crate) use replacement::replacement_target_identities_match;
|
||||||
use replacement::replacement_targets_match_identities;
|
use replacement::replacement_targets_match_identities;
|
||||||
pub use replacement::{
|
pub use replacement::{
|
||||||
@@ -340,6 +340,12 @@ pub struct ResumeState {
|
|||||||
pub failed_objects: u64,
|
pub failed_objects: u64,
|
||||||
/// skipped objects
|
/// skipped objects
|
||||||
pub skipped_objects: u64,
|
pub skipped_objects: u64,
|
||||||
|
/// Terminal versions skipped because they were newer than the heal start.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_new_versions: u64,
|
||||||
|
/// Terminal versions handed to lifecycle expiry.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_ilm_expired: u64,
|
||||||
/// current bucket
|
/// current bucket
|
||||||
pub current_bucket: Option<String>,
|
pub current_bucket: Option<String>,
|
||||||
/// current object
|
/// current object
|
||||||
@@ -354,6 +360,24 @@ pub struct ResumeState {
|
|||||||
pub retry_count: u32,
|
pub retry_count: u32,
|
||||||
/// max retries
|
/// max retries
|
||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
|
/// Bytes accounted by the object ledger; additive for old snapshots.
|
||||||
|
#[serde(default)]
|
||||||
|
pub processed_bytes: u64,
|
||||||
|
/// Total bytes from a complete usage snapshot, when available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_bytes: u64,
|
||||||
|
/// Generation of the usage snapshot used for the baseline.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_generation: Option<u64>,
|
||||||
|
/// Whether the usage baseline is known. Missing in old snapshots means
|
||||||
|
/// indeterminate rather than a measured zero baseline.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_known: bool,
|
||||||
|
/// Persistent telemetry fence for counter/byte overflow or corruption.
|
||||||
|
/// It must survive a restart so a saturated snapshot is never presented as
|
||||||
|
/// a measured percentage on the next resume.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_unknown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResumeState {
|
impl ResumeState {
|
||||||
@@ -377,6 +401,8 @@ impl ResumeState {
|
|||||||
successful_objects: 0,
|
successful_objects: 0,
|
||||||
failed_objects: 0,
|
failed_objects: 0,
|
||||||
skipped_objects: 0,
|
skipped_objects: 0,
|
||||||
|
skipped_new_versions: 0,
|
||||||
|
skipped_ilm_expired: 0,
|
||||||
current_bucket: None,
|
current_bucket: None,
|
||||||
current_object: None,
|
current_object: None,
|
||||||
completed_buckets: Vec::new(),
|
completed_buckets: Vec::new(),
|
||||||
@@ -384,6 +410,11 @@ impl ResumeState {
|
|||||||
error_message: None,
|
error_message: None,
|
||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
baseline_generation: None,
|
||||||
|
baseline_known: false,
|
||||||
|
counter_unknown: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +443,39 @@ impl ResumeState {
|
|||||||
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_progress_with_bytes(
|
||||||
|
&mut self,
|
||||||
|
processed: u64,
|
||||||
|
successful: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
|
processed_bytes: u64,
|
||||||
|
) {
|
||||||
|
self.update_progress(processed, successful, failed, skipped);
|
||||||
|
self.processed_bytes = processed_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skipped_version_counts(&mut self, new_versions: u64, ilm_expired: u64) {
|
||||||
|
self.skipped_new_versions = new_versions;
|
||||||
|
self.skipped_ilm_expired = ilm_expired;
|
||||||
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_progress_baseline(&mut self, total_objects: u64, total_bytes: u64, generation: Option<u64>) {
|
||||||
|
self.total_objects = total_objects;
|
||||||
|
self.total_bytes = total_bytes;
|
||||||
|
self.baseline_generation = generation;
|
||||||
|
// This method is called only after a complete usage snapshot has been
|
||||||
|
// validated. A complete but empty snapshot is still a known baseline.
|
||||||
|
self.baseline_known = true;
|
||||||
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_counter_unknown(&mut self) {
|
||||||
|
self.counter_unknown = true;
|
||||||
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_current_item(&mut self, bucket: Option<String>, object: Option<String>) {
|
pub fn set_current_item(&mut self, bucket: Option<String>, object: Option<String>) {
|
||||||
self.current_bucket = bucket;
|
self.current_bucket = bucket;
|
||||||
self.current_object = object;
|
self.current_object = object;
|
||||||
@@ -437,6 +501,7 @@ impl ResumeState {
|
|||||||
if let Some(pos) = self.pending_buckets.iter().position(|b| b == bucket) {
|
if let Some(pos) = self.pending_buckets.iter().position(|b| b == bucket) {
|
||||||
self.pending_buckets.remove(pos);
|
self.pending_buckets.remove(pos);
|
||||||
}
|
}
|
||||||
|
self.resume_cursor = None;
|
||||||
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +519,10 @@ impl ResumeState {
|
|||||||
self.successful_objects = 0;
|
self.successful_objects = 0;
|
||||||
self.failed_objects = 0;
|
self.failed_objects = 0;
|
||||||
self.skipped_objects = 0;
|
self.skipped_objects = 0;
|
||||||
|
self.skipped_new_versions = 0;
|
||||||
|
self.skipped_ilm_expired = 0;
|
||||||
|
self.processed_bytes = 0;
|
||||||
|
self.counter_unknown = false;
|
||||||
self.completed = false;
|
self.completed = false;
|
||||||
// A retry re-scans every bucket from the beginning, so the version
|
// A retry re-scans every bucket from the beginning, so the version
|
||||||
// cursor must be cleared too — otherwise the retry would resume mid-scan.
|
// cursor must be cleared too — otherwise the retry would resume mid-scan.
|
||||||
@@ -476,14 +545,28 @@ impl ResumeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_progress_percentage(&self) -> f64 {
|
pub fn get_progress_percentage(&self) -> f64 {
|
||||||
|
if self.completed {
|
||||||
|
return 100.0;
|
||||||
|
}
|
||||||
|
if self.counter_unknown {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if !self.baseline_known {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if self.total_bytes > 0 {
|
||||||
|
return ((self.processed_bytes as f64 / self.total_bytes as f64) * 100.0).min(99.999);
|
||||||
|
}
|
||||||
if self.total_objects == 0 {
|
if self.total_objects == 0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
(self.processed_objects as f64 / self.total_objects as f64) * 100.0
|
((self.processed_objects as f64 / self.total_objects as f64) * 100.0).min(99.999)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_success_rate(&self) -> f64 {
|
pub fn get_success_rate(&self) -> f64 {
|
||||||
let total = self.successful_objects + self.failed_objects;
|
let Some(total) = self.successful_objects.checked_add(self.failed_objects) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
@@ -754,6 +837,14 @@ impl ResumeManager {
|
|||||||
state.successful_objects = 0;
|
state.successful_objects = 0;
|
||||||
state.failed_objects = 0;
|
state.failed_objects = 0;
|
||||||
state.skipped_objects = 0;
|
state.skipped_objects = 0;
|
||||||
|
state.skipped_new_versions = 0;
|
||||||
|
state.skipped_ilm_expired = 0;
|
||||||
|
state.processed_bytes = 0;
|
||||||
|
state.total_objects = 0;
|
||||||
|
state.total_bytes = 0;
|
||||||
|
state.baseline_generation = None;
|
||||||
|
state.baseline_known = false;
|
||||||
|
state.counter_unknown = false;
|
||||||
state.completed = false;
|
state.completed = false;
|
||||||
state.completed_buckets.clear();
|
state.completed_buckets.clear();
|
||||||
state.schema_version = CURRENT_RESUME_SCHEMA;
|
state.schema_version = CURRENT_RESUME_SCHEMA;
|
||||||
@@ -838,6 +929,41 @@ impl ResumeManager {
|
|||||||
self.save_state_throttled().await
|
self.save_state_throttled().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_progress_with_bytes(
|
||||||
|
&self,
|
||||||
|
processed: u64,
|
||||||
|
successful: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
|
processed_bytes: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.update_progress_with_bytes(processed, successful, failed, skipped, processed_bytes);
|
||||||
|
drop(state);
|
||||||
|
self.save_state_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_progress_baseline(&self, total_objects: u64, total_bytes: u64, generation: Option<u64>) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_progress_baseline(total_objects, total_bytes, generation);
|
||||||
|
drop(state);
|
||||||
|
self.save_state_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_counter_unknown(&self) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.mark_counter_unknown();
|
||||||
|
drop(state);
|
||||||
|
self.save_state().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_skipped_version_counts(&self, new_versions: u64, ilm_expired: u64) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_skipped_version_counts(new_versions, ilm_expired);
|
||||||
|
drop(state);
|
||||||
|
self.save_state_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Set current item. Called once per healed object, so persistence is
|
/// Set current item. Called once per healed object, so persistence is
|
||||||
/// throttled: the in-memory state always updates, but the snapshot is only
|
/// throttled: the in-memory state always updates, but the snapshot is only
|
||||||
/// written every `PERSIST_EVERY_MUTATIONS` calls or `PERSIST_INTERVAL`.
|
/// written every `PERSIST_EVERY_MUTATIONS` calls or `PERSIST_INTERVAL`.
|
||||||
@@ -882,7 +1008,7 @@ impl ResumeManager {
|
|||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
state.complete_bucket(bucket);
|
state.complete_bucket(bucket);
|
||||||
drop(state);
|
drop(state);
|
||||||
self.save_state_throttled().await
|
self.save_state().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// mark task completed
|
/// mark task completed
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const EVENT_HEAL_CHECKPOINT_STATE: &str = "heal_checkpoint_state";
|
|||||||
/// to the new `compose_key` identities, so a stale checkpoint is discarded.
|
/// to the new `compose_key` identities, so a stale checkpoint is discarded.
|
||||||
pub(super) const CURRENT_CHECKPOINT_SCHEMA: u32 = 5;
|
pub(super) const CURRENT_CHECKPOINT_SCHEMA: u32 = 5;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum CheckpointObjectOutcome {
|
||||||
|
Processed,
|
||||||
|
Failed,
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
/// resume checkpoint
|
/// resume checkpoint
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ResumeCheckpoint {
|
pub struct ResumeCheckpoint {
|
||||||
@@ -57,6 +64,30 @@ pub struct ResumeCheckpoint {
|
|||||||
pub failed_objects: HashSet<String>,
|
pub failed_objects: HashSet<String>,
|
||||||
/// skipped objects
|
/// skipped objects
|
||||||
pub skipped_objects: HashSet<String>,
|
pub skipped_objects: HashSet<String>,
|
||||||
|
/// Aggregate object ledger counters restored alongside the dedup sets.
|
||||||
|
#[serde(default)]
|
||||||
|
pub successful_objects: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub failed_object_count: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_object_count: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_new_versions: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_ilm_expired: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub processed_bytes: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_objects: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_bytes: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_generation: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_known: bool,
|
||||||
|
/// Persistent telemetry fence for counter/byte overflow or corruption.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_unknown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResumeCheckpoint {
|
impl ResumeCheckpoint {
|
||||||
@@ -70,6 +101,17 @@ impl ResumeCheckpoint {
|
|||||||
processed_objects: HashSet::new(),
|
processed_objects: HashSet::new(),
|
||||||
failed_objects: HashSet::new(),
|
failed_objects: HashSet::new(),
|
||||||
skipped_objects: HashSet::new(),
|
skipped_objects: HashSet::new(),
|
||||||
|
successful_objects: 0,
|
||||||
|
failed_object_count: 0,
|
||||||
|
skipped_object_count: 0,
|
||||||
|
skipped_new_versions: 0,
|
||||||
|
skipped_ilm_expired: 0,
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_objects: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
baseline_generation: None,
|
||||||
|
baseline_known: false,
|
||||||
|
counter_unknown: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +133,34 @@ impl ResumeCheckpoint {
|
|||||||
self.skipped_objects.insert(object);
|
self.skipped_objects.insert(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_progress(&mut self, successful: u64, failed: u64, skipped: u64, bytes: u64) {
|
||||||
|
self.successful_objects = successful;
|
||||||
|
self.failed_object_count = failed;
|
||||||
|
self.skipped_object_count = skipped;
|
||||||
|
self.processed_bytes = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_progress_baseline(&mut self, total_objects: u64, total_bytes: u64, generation: Option<u64>) {
|
||||||
|
self.total_objects = total_objects;
|
||||||
|
self.total_bytes = total_bytes;
|
||||||
|
self.baseline_generation = generation;
|
||||||
|
// The caller has already validated that this is a complete snapshot;
|
||||||
|
// preserve the distinction between a known empty scope and an old
|
||||||
|
// checkpoint that omitted all baseline fields.
|
||||||
|
self.baseline_known = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_counter_unknown(&mut self) {
|
||||||
|
self.counter_unknown = true;
|
||||||
|
self.checkpoint_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skipped_version_counts(&mut self, new_versions: u64, ilm_expired: u64) {
|
||||||
|
self.skipped_new_versions = new_versions;
|
||||||
|
self.skipped_ilm_expired = ilm_expired;
|
||||||
|
self.checkpoint_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
/// Advance past a fully-processed page: objects below `object_index` are
|
/// Advance past a fully-processed page: objects below `object_index` are
|
||||||
/// skipped by position on resume, so the per-object sets no longer need
|
/// skipped by position on resume, so the per-object sets no longer need
|
||||||
/// their entries and would otherwise grow with the whole bucket.
|
/// their entries and would otherwise grow with the whole bucket.
|
||||||
@@ -107,6 +177,17 @@ impl ResumeCheckpoint {
|
|||||||
self.update_position(0, 0);
|
self.update_position(0, 0);
|
||||||
self.processed_objects.clear();
|
self.processed_objects.clear();
|
||||||
self.skipped_objects.clear();
|
self.skipped_objects.clear();
|
||||||
|
self.successful_objects = 0;
|
||||||
|
self.failed_object_count = 0;
|
||||||
|
self.skipped_object_count = 0;
|
||||||
|
self.skipped_new_versions = 0;
|
||||||
|
self.skipped_ilm_expired = 0;
|
||||||
|
self.processed_bytes = 0;
|
||||||
|
self.total_objects = 0;
|
||||||
|
self.total_bytes = 0;
|
||||||
|
self.baseline_generation = None;
|
||||||
|
self.baseline_known = false;
|
||||||
|
self.counter_unknown = false;
|
||||||
self.failed_objects.clear();
|
self.failed_objects.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +266,17 @@ impl CheckpointManager {
|
|||||||
checkpoint.processed_objects.clear();
|
checkpoint.processed_objects.clear();
|
||||||
checkpoint.failed_objects.clear();
|
checkpoint.failed_objects.clear();
|
||||||
checkpoint.skipped_objects.clear();
|
checkpoint.skipped_objects.clear();
|
||||||
|
checkpoint.successful_objects = 0;
|
||||||
|
checkpoint.failed_object_count = 0;
|
||||||
|
checkpoint.skipped_object_count = 0;
|
||||||
|
checkpoint.skipped_new_versions = 0;
|
||||||
|
checkpoint.skipped_ilm_expired = 0;
|
||||||
|
checkpoint.processed_bytes = 0;
|
||||||
|
checkpoint.total_objects = 0;
|
||||||
|
checkpoint.total_bytes = 0;
|
||||||
|
checkpoint.baseline_generation = None;
|
||||||
|
checkpoint.baseline_known = false;
|
||||||
|
checkpoint.counter_unknown = false;
|
||||||
checkpoint.current_bucket_index = 0;
|
checkpoint.current_bucket_index = 0;
|
||||||
checkpoint.current_object_index = 0;
|
checkpoint.current_object_index = 0;
|
||||||
checkpoint.schema_version = CURRENT_CHECKPOINT_SCHEMA;
|
checkpoint.schema_version = CURRENT_CHECKPOINT_SCHEMA;
|
||||||
@@ -225,7 +317,7 @@ impl CheckpointManager {
|
|||||||
self.save_checkpoint_throttled().await
|
self.save_checkpoint_throttled().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance past a completed page and prune the per-object sets, then persist.
|
/// Persist a completed page position while retaining its identities.
|
||||||
pub async fn complete_page(&self, bucket_index: usize, object_index: usize) -> Result<()> {
|
pub async fn complete_page(&self, bucket_index: usize, object_index: usize) -> Result<()> {
|
||||||
let mut checkpoint = self.checkpoint.write().await;
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
checkpoint.complete_page(bucket_index, object_index);
|
checkpoint.complete_page(bucket_index, object_index);
|
||||||
@@ -233,6 +325,35 @@ impl CheckpointManager {
|
|||||||
self.save_checkpoint_throttled().await
|
self.save_checkpoint_throttled().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the page position while retaining identities until the resume
|
||||||
|
/// cursor is durable.
|
||||||
|
pub async fn advance_page(&self, bucket_index: usize, object_index: usize) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.update_position(bucket_index, object_index);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the previous page's dedup identities only after its resume cursor
|
||||||
|
/// has been durably exposed.
|
||||||
|
pub async fn prune_completed_page(&self) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.processed_objects.clear();
|
||||||
|
checkpoint.skipped_objects.clear();
|
||||||
|
checkpoint.failed_objects.clear();
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance to the next bucket and clear the final page identities after the
|
||||||
|
/// resume state has durably recorded the completed bucket.
|
||||||
|
pub async fn complete_bucket(&self, next_bucket_index: usize) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.complete_page(next_bucket_index, 0);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset the checkpoint to the start of the scan for a retry, then persist.
|
/// Reset the checkpoint to the start of the scan for a retry, then persist.
|
||||||
pub async fn reset_for_retry(&self) -> Result<()> {
|
pub async fn reset_for_retry(&self) -> Result<()> {
|
||||||
let mut checkpoint = self.checkpoint.write().await;
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
@@ -267,6 +388,62 @@ impl CheckpointManager {
|
|||||||
self.save_checkpoint_if_due().await
|
self.save_checkpoint_if_due().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically persist an object's dedup identity with its aggregate result.
|
||||||
|
pub async fn record_object_outcome(
|
||||||
|
&self,
|
||||||
|
object: String,
|
||||||
|
outcome: CheckpointObjectOutcome,
|
||||||
|
successful: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
|
bytes: u64,
|
||||||
|
skipped_new_versions: u64,
|
||||||
|
skipped_ilm_expired: u64,
|
||||||
|
counter_unknown: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
match outcome {
|
||||||
|
CheckpointObjectOutcome::Processed => checkpoint.add_processed_object(object),
|
||||||
|
CheckpointObjectOutcome::Failed => checkpoint.add_failed_object(object),
|
||||||
|
CheckpointObjectOutcome::Skipped => checkpoint.add_skipped_object(object),
|
||||||
|
}
|
||||||
|
checkpoint.update_progress(successful, failed, skipped, bytes);
|
||||||
|
checkpoint.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired);
|
||||||
|
if counter_unknown {
|
||||||
|
checkpoint.mark_counter_unknown();
|
||||||
|
}
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_if_due().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_progress(&self, successful: u64, failed: u64, skipped: u64, bytes: u64) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.update_progress(successful, failed, skipped, bytes);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_if_due().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_progress_baseline(&self, total_objects: u64, total_bytes: u64, generation: Option<u64>) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.set_progress_baseline(total_objects, total_bytes, generation);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_counter_unknown(&self) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.mark_counter_unknown();
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_skipped_version_counts(&self, new_versions: u64, ilm_expired: u64) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.set_skipped_version_counts(new_versions, ilm_expired);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_checkpoint_if_due(&self) -> Result<()> {
|
async fn save_checkpoint_if_due(&self) -> Result<()> {
|
||||||
let should_save = self.throttle.lock().map(|mut throttle| throttle.record()).unwrap_or(true);
|
let should_save = self.throttle.lock().map(|mut throttle| throttle.record()).unwrap_or(true);
|
||||||
if !should_save {
|
if !should_save {
|
||||||
|
|||||||
@@ -1296,6 +1296,7 @@ async fn test_resume_state_progress() {
|
|||||||
assert_eq!(progress, 0.0); // total_objects is 0
|
assert_eq!(progress, 0.0); // total_objects is 0
|
||||||
|
|
||||||
state.total_objects = 100;
|
state.total_objects = 100;
|
||||||
|
state.baseline_known = true;
|
||||||
let progress = state.get_progress_percentage();
|
let progress = state.get_progress_percentage();
|
||||||
assert_eq!(progress, 10.0);
|
assert_eq!(progress, 10.0);
|
||||||
}
|
}
|
||||||
@@ -1475,6 +1476,40 @@ fn test_checkpoint_object_sets_dedupe_and_prune() {
|
|||||||
assert!(checkpoint.failed_objects.is_empty());
|
assert!(checkpoint.failed_objects.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkpoint_page_commit_keeps_ledger_until_cursor_is_durable() {
|
||||||
|
let (_temp_dir, disk) = schema_test_disk().await;
|
||||||
|
let task_id = ResumeUtils::generate_task_id();
|
||||||
|
let checkpoint = CheckpointManager::new(disk.clone(), task_id.clone()).await.unwrap();
|
||||||
|
|
||||||
|
checkpoint
|
||||||
|
.record_object_outcome(
|
||||||
|
"bucket/object:v1".to_string(),
|
||||||
|
CheckpointObjectOutcome::Processed,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
128,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
checkpoint.advance_page(0, 1).await.unwrap();
|
||||||
|
|
||||||
|
let reloaded = CheckpointManager::load_from_disk(disk.clone(), &task_id).await.unwrap();
|
||||||
|
let snapshot = reloaded.get_checkpoint().await;
|
||||||
|
assert_eq!(snapshot.current_object_index, 1);
|
||||||
|
assert_eq!(snapshot.successful_objects, 1);
|
||||||
|
assert_eq!(snapshot.processed_bytes, 128);
|
||||||
|
assert!(snapshot.processed_objects.contains("bucket/object:v1"));
|
||||||
|
|
||||||
|
checkpoint.prune_completed_page().await.unwrap();
|
||||||
|
let reloaded = CheckpointManager::load_from_disk(disk, &task_id).await.unwrap();
|
||||||
|
assert!(reloaded.get_checkpoint().await.processed_objects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_checkpoint_loads_legacy_vec_format() {
|
fn test_checkpoint_loads_legacy_vec_format() {
|
||||||
// Checkpoints written before the HashSet migration stored the object
|
// Checkpoints written before the HashSet migration stored the object
|
||||||
@@ -1639,6 +1674,120 @@ async fn current_normal_resume_schema_preserves_progress() {
|
|||||||
temp_dir.close().expect("remove schema test directory");
|
temp_dir.close().expect("remove schema test directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_checkpoint_restores_bytes_and_generation() {
|
||||||
|
let mut checkpoint = ResumeCheckpoint::new("progress-checkpoint".to_string());
|
||||||
|
checkpoint.set_progress_baseline(9, 4096, Some(77));
|
||||||
|
checkpoint.update_progress(4, 1, 2, 2048);
|
||||||
|
checkpoint.set_skipped_version_counts(3, 1);
|
||||||
|
checkpoint.mark_counter_unknown();
|
||||||
|
|
||||||
|
let restored: ResumeCheckpoint =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&checkpoint).expect("serialize checkpoint")).expect("deserialize checkpoint");
|
||||||
|
assert_eq!(restored.processed_bytes, 2048);
|
||||||
|
assert_eq!(restored.total_objects, 9);
|
||||||
|
assert_eq!(restored.total_bytes, 4096);
|
||||||
|
assert_eq!(restored.baseline_generation, Some(77));
|
||||||
|
assert!(restored.baseline_known);
|
||||||
|
assert_eq!(restored.skipped_new_versions, 3);
|
||||||
|
assert_eq!(restored.skipped_ilm_expired, 1);
|
||||||
|
assert!(restored.counter_unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_progress_schema_migrates_missing_fields_to_unknown() {
|
||||||
|
let state = ResumeState::new(
|
||||||
|
"legacy-progress".to_string(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
let mut value = serde_json::to_value(state).expect("serialize legacy-compatible state");
|
||||||
|
let object = value.as_object_mut().expect("state must be an object");
|
||||||
|
for field in [
|
||||||
|
"processed_bytes",
|
||||||
|
"total_bytes",
|
||||||
|
"baseline_generation",
|
||||||
|
"baseline_known",
|
||||||
|
"skipped_new_versions",
|
||||||
|
"skipped_ilm_expired",
|
||||||
|
] {
|
||||||
|
object.remove(field);
|
||||||
|
}
|
||||||
|
object.insert("total_objects".to_string(), serde_json::json!(10));
|
||||||
|
object.insert("processed_objects".to_string(), serde_json::json!(5));
|
||||||
|
let restored: ResumeState = serde_json::from_value(value).expect("deserialize old progress state");
|
||||||
|
assert_eq!(restored.processed_bytes, 0);
|
||||||
|
assert_eq!(restored.total_bytes, 0);
|
||||||
|
assert_eq!(restored.baseline_generation, None);
|
||||||
|
assert!(!restored.baseline_known, "missing baseline must remain unknown");
|
||||||
|
assert_eq!(restored.get_progress_percentage(), 0.0);
|
||||||
|
assert_eq!(restored.skipped_new_versions, 0);
|
||||||
|
assert_eq!(restored.skipped_ilm_expired, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_counter_unknown_survives_resume_round_trip() {
|
||||||
|
let mut state = ResumeState::new(
|
||||||
|
"overflow-progress".to_string(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
state.mark_counter_unknown();
|
||||||
|
|
||||||
|
let restored: ResumeState =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&state).expect("serialize resume state")).expect("deserialize resume state");
|
||||||
|
assert!(restored.counter_unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkpoint_progress_survives_a_torn_resume_summary_write() {
|
||||||
|
let (_temp_dir, disk) = schema_test_disk().await;
|
||||||
|
let task_id = ResumeUtils::generate_task_id();
|
||||||
|
let _resume = ResumeManager::new(
|
||||||
|
disk.clone(),
|
||||||
|
task_id.clone(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
vec!["bucket".to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("resume state should persist");
|
||||||
|
let checkpoint = CheckpointManager::new(disk.clone(), task_id.clone())
|
||||||
|
.await
|
||||||
|
.expect("checkpoint should persist");
|
||||||
|
|
||||||
|
// This is the ordering used by the erasure-set loop: the checkpoint is
|
||||||
|
// durable before the summary write. Stop here to model a crash in the
|
||||||
|
// inter-store window and verify that the recovery authority retains the
|
||||||
|
// telemetry fence and bytes.
|
||||||
|
checkpoint
|
||||||
|
.update_progress(3, 0, 0, 1024)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint progress should persist");
|
||||||
|
checkpoint.mark_counter_unknown().await.expect("unknown fence should persist");
|
||||||
|
checkpoint
|
||||||
|
.update_position(0, 3)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint position should persist");
|
||||||
|
|
||||||
|
let restored_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint should reload")
|
||||||
|
.get_checkpoint()
|
||||||
|
.await;
|
||||||
|
let restored_resume = ResumeManager::load_from_disk(disk, &task_id)
|
||||||
|
.await
|
||||||
|
.expect("resume summary should reload")
|
||||||
|
.get_state()
|
||||||
|
.await;
|
||||||
|
assert!(restored_checkpoint.counter_unknown);
|
||||||
|
assert_eq!(restored_checkpoint.processed_bytes, 1024);
|
||||||
|
assert_eq!(restored_checkpoint.current_object_index, 3);
|
||||||
|
assert!(!restored_resume.counter_unknown, "summary is intentionally the torn/older store");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn future_resume_and_checkpoint_schemas_are_rejected() {
|
async fn future_resume_and_checkpoint_schemas_are_rejected() {
|
||||||
let (temp_dir, disk) = schema_test_disk().await;
|
let (temp_dir, disk) = schema_test_disk().await;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|||||||
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
||||||
use rustfs_madmin::heal_commands::HealResultItem;
|
use rustfs_madmin::heal_commands::HealResultItem;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
@@ -34,6 +36,9 @@ pub use super::{HealObjectInfo, HealObjectOptions, HealPutObjReader};
|
|||||||
pub struct HealBucketUsageBaseline {
|
pub struct HealBucketUsageBaseline {
|
||||||
pub objects_count: u64,
|
pub objects_count: u64,
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
|
/// Stable identity of the validated usage snapshot and selected scope.
|
||||||
|
/// `None` is retained for test/legacy providers that cannot expose one.
|
||||||
|
pub generation: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HealLifecycleExpiryContext {
|
pub struct HealLifecycleExpiryContext {
|
||||||
@@ -785,11 +790,30 @@ impl HealStorageAPI for ECStoreHealStorage {
|
|||||||
let mut baseline = HealBucketUsageBaseline::default();
|
let mut baseline = HealBucketUsageBaseline::default();
|
||||||
for bucket in buckets {
|
for bucket in buckets {
|
||||||
if let Some(usage) = info.buckets_usage.get(bucket) {
|
if let Some(usage) = info.buckets_usage.get(bucket) {
|
||||||
baseline.objects_count = baseline.objects_count.saturating_add(usage.objects_count);
|
baseline.objects_count = match baseline.objects_count.checked_add(usage.objects_count) {
|
||||||
baseline.bytes = baseline.bytes.saturating_add(usage.size);
|
Some(total) => total,
|
||||||
|
// A corrupt/overflowing usage snapshot is not a usable
|
||||||
|
// denominator. Leave progress indeterminate instead of
|
||||||
|
// turning saturation into a plausible percentage.
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
baseline.bytes = match baseline.bytes.checked_add(usage.size) {
|
||||||
|
Some(total) => total,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let identity = info.snapshot_identity();
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
identity.last_update.hash(&mut hasher);
|
||||||
|
identity.scanner_cycle.hash(&mut hasher);
|
||||||
|
identity.scanner_epoch.hash(&mut hasher);
|
||||||
|
let mut scope = buckets.to_vec();
|
||||||
|
scope.sort_unstable();
|
||||||
|
scope.hash(&mut hasher);
|
||||||
|
baseline.generation = Some(hasher.finish());
|
||||||
|
|
||||||
Ok(Some(baseline))
|
Ok(Some(baseline))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ impl HealTask {
|
|||||||
|
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
||||||
progress.update_progress(0, 1, 0, 0);
|
progress.update_stage(1, 1);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +733,7 @@ impl HealTask {
|
|||||||
"Heal object skipped for data usage cache after transient error"
|
"Heal object skipped for data usage cache after transient error"
|
||||||
);
|
);
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,7 +757,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
||||||
progress.update_progress(4, 4, 0, 0);
|
progress.update_stage(4, 4);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +831,10 @@ impl HealTask {
|
|||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
// A stage can reach its final step before the durable resume
|
||||||
|
// ledger and cleanup fences commit. Publish terminal 100 only
|
||||||
|
// after the enclosing operation has returned success.
|
||||||
|
self.progress.write().await.mark_completed();
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
*status = HealTaskStatus::Completed;
|
*status = HealTaskStatus::Completed;
|
||||||
demote_to_debug_when!(self.heal_type.is_per_object(), info, target: "rustfs::heal::task", {
|
demote_to_debug_when!(self.heal_type.is_per_object(), info, target: "rustfs::heal::task", {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
/// bucket/cluster/prefix heal: the recursive bucket-objects sweep and the erasure-set usage baseline
|
/// bucket/cluster/prefix heal: the recursive bucket-objects sweep and the erasure-set usage baseline
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::heal::progress::{add_bytes, increment_counter};
|
||||||
|
|
||||||
impl HealTask {
|
impl HealTask {
|
||||||
pub(super) async fn heal_bucket(&self, bucket: &str) -> Result<()> {
|
pub(super) async fn heal_bucket(&self, bucket: &str) -> Result<()> {
|
||||||
@@ -32,7 +33,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("bucket: {bucket}")));
|
progress.set_current_object(Some(format!("bucket: {bucket}")));
|
||||||
progress.update_progress(0, 3, 0, 0);
|
progress.update_stage(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if bucket exists
|
// Step 1: Check if bucket exists
|
||||||
@@ -66,7 +67,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Perform bucket heal using ecstore
|
// Step 2: Perform bucket heal using ecstore
|
||||||
@@ -122,7 +123,7 @@ impl HealTask {
|
|||||||
|
|
||||||
if !self.options.recursive {
|
if !self.options.recursive {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Err(Error::TaskExecutionFailed {
|
Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal bucket {bucket}: {e}"),
|
message: format!("Failed to heal bucket {bucket}: {e}"),
|
||||||
@@ -245,6 +246,7 @@ impl HealTask {
|
|||||||
let mut scanned = 0u64;
|
let mut scanned = 0u64;
|
||||||
let mut healed = 0u64;
|
let mut healed = 0u64;
|
||||||
let mut failed = 0u64;
|
let mut failed = 0u64;
|
||||||
|
let mut skipped = 0u64;
|
||||||
let mut retryable_failed = 0u64;
|
let mut retryable_failed = 0u64;
|
||||||
let mut permanent_failed = 0u64;
|
let mut permanent_failed = 0u64;
|
||||||
let mut bytes = 0u64;
|
let mut bytes = 0u64;
|
||||||
@@ -286,16 +288,14 @@ impl HealTask {
|
|||||||
let mut retry = Vec::with_capacity(pending.len());
|
let mut retry = Vec::with_capacity(pending.len());
|
||||||
for item in pending {
|
for item in pending {
|
||||||
self.check_control_flags().await?;
|
self.check_control_flags().await?;
|
||||||
|
let mut telemetry_unknown = false;
|
||||||
let object = item.name.as_str();
|
let object = item.name.as_str();
|
||||||
if retry_attempt == 0 {
|
|
||||||
scanned = scanned.saturating_add(1);
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||||
progress.update_progress(scanned, healed, failed, bytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut terminal_outcome = true;
|
||||||
let error = match self
|
let error = match self
|
||||||
.await_with_control(
|
.await_with_control(
|
||||||
self.storage
|
self.storage
|
||||||
@@ -304,13 +304,13 @@ impl HealTask {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((result, None)) => {
|
Ok((result, None)) => {
|
||||||
healed = healed.saturating_add(1);
|
telemetry_unknown |= !increment_counter(&mut healed);
|
||||||
bytes = bytes.saturating_add(u64::try_from(result.object_size).unwrap_or_default());
|
telemetry_unknown |= !add_bytes(&mut bytes, u64::try_from(result.object_size).unwrap_or(u64::MAX));
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Ok((_, Some(err))) if is_missing_object_dir_heal_result(object, &err) => {
|
Ok((_, Some(err))) if is_missing_object_dir_heal_result(object, &err) => {
|
||||||
healed = healed.saturating_add(1);
|
telemetry_unknown |= !increment_counter(&mut healed);
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
@@ -329,6 +329,7 @@ impl HealTask {
|
|||||||
|
|
||||||
if let Some(err) = error {
|
if let Some(err) = error {
|
||||||
if Self::should_skip_data_usage_cache_heal_error(bucket, object, &err) {
|
if Self::should_skip_data_usage_cache_heal_error(bucket, object, &err) {
|
||||||
|
telemetry_unknown |= !increment_counter(&mut skipped);
|
||||||
warn!(
|
warn!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
@@ -342,6 +343,7 @@ impl HealTask {
|
|||||||
"Heal bucket object repair skipped due to transient metadata error"
|
"Heal bucket object repair skipped due to transient metadata error"
|
||||||
);
|
);
|
||||||
} else if err.is_recoverable_heal() && retry_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
} else if err.is_recoverable_heal() && retry_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
||||||
|
terminal_outcome = false;
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
@@ -357,7 +359,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
retry.push(item);
|
retry.push(item);
|
||||||
} else {
|
} else {
|
||||||
failed = failed.saturating_add(1);
|
telemetry_unknown |= !increment_counter(&mut failed);
|
||||||
if err.is_recoverable_heal() {
|
if err.is_recoverable_heal() {
|
||||||
retryable_failed = retryable_failed.saturating_add(1);
|
retryable_failed = retryable_failed.saturating_add(1);
|
||||||
} else {
|
} else {
|
||||||
@@ -383,8 +385,19 @@ impl HealTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if terminal_outcome {
|
||||||
|
telemetry_unknown |= !increment_counter(&mut scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !terminal_outcome {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(scanned, healed, failed, bytes);
|
progress.update_object_progress(scanned, healed, failed, skipped, bytes);
|
||||||
|
if telemetry_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pending = retry;
|
pending = retry;
|
||||||
retry_attempt = retry_attempt.saturating_add(1);
|
retry_attempt = retry_attempt.saturating_add(1);
|
||||||
@@ -431,7 +444,7 @@ impl HealTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn apply_erasure_set_usage_baseline(&self, buckets: &[String]) -> Result<()> {
|
pub(super) async fn apply_erasure_set_usage_baseline(&self, buckets: &[String], set_disk_id: &str) -> Result<()> {
|
||||||
let baseline = match self
|
let baseline = match self
|
||||||
.await_with_control(self.storage.erasure_set_usage_baseline(buckets))
|
.await_with_control(self.storage.erasure_set_usage_baseline(buckets))
|
||||||
.await
|
.await
|
||||||
@@ -442,9 +455,26 @@ impl HealTask {
|
|||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let HealBucketUsageBaseline { objects_count, bytes } = baseline;
|
let HealBucketUsageBaseline {
|
||||||
|
objects_count,
|
||||||
|
bytes,
|
||||||
|
generation,
|
||||||
|
} = baseline;
|
||||||
|
let generation = generation.map(|snapshot_generation| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
snapshot_generation.hash(&mut hasher);
|
||||||
|
set_disk_id.hash(&mut hasher);
|
||||||
|
self.options.pool_index.hash(&mut hasher);
|
||||||
|
self.options.set_index.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
});
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
|
if let Some(generation) = generation {
|
||||||
|
progress.set_total_baseline_with_generation(objects_count, bytes, generation);
|
||||||
|
} else {
|
||||||
progress.set_total_baseline(objects_count, bytes);
|
progress.set_total_baseline(objects_count, bytes);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("erasure_set: {} ({} buckets)", set_disk_id, buckets.len())));
|
progress.set_current_object(Some(format!("erasure_set: {} ({} buckets)", set_disk_id, buckets.len())));
|
||||||
progress.update_progress(0, 4, 0, 0);
|
progress.update_stage(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_auto_replacement = matches!(self.source, HealRequestSource::AutoHeal) && !self.heal_endpoints.is_empty();
|
let is_auto_replacement = matches!(self.source, HealRequestSource::AutoHeal) && !self.heal_endpoints.is_empty();
|
||||||
@@ -158,7 +158,7 @@ impl HealTask {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
self.apply_erasure_set_usage_baseline(&buckets).await?;
|
self.apply_erasure_set_usage_baseline(&buckets, &set_disk_id).await?;
|
||||||
|
|
||||||
let healing_marker = format!("{set_disk_id}:{}", self.id);
|
let healing_marker = format!("{set_disk_id}:{}", self.id);
|
||||||
if let Some((disk, resume_manager, _)) = replacement_resume.as_ref() {
|
if let Some((disk, resume_manager, _)) = replacement_resume.as_ref() {
|
||||||
@@ -231,10 +231,6 @@ impl HealTask {
|
|||||||
"Heal erasure set format repair skipped because no format heal was required"
|
"Heal erasure set format repair skipped because no format heal was required"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let error = e;
|
|
||||||
if error.is_recoverable_heal() {
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
error!(
|
error!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
||||||
@@ -243,15 +239,15 @@ impl HealTask {
|
|||||||
task_id = %self.id,
|
task_id = %self.id,
|
||||||
set_disk_id,
|
set_disk_id,
|
||||||
result = "format_failed",
|
result = "format_failed",
|
||||||
error = %error,
|
error = %e,
|
||||||
"Heal erasure set failed"
|
"Heal erasure set failed"
|
||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(4, 4, 0, 0);
|
progress.update_stage(4, 4);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal disk format for {set_disk_id}: {error}"),
|
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -288,9 +284,6 @@ impl HealTask {
|
|||||||
Err(Error::TaskCancelled) => return Err(Error::TaskCancelled),
|
Err(Error::TaskCancelled) => return Err(Error::TaskCancelled),
|
||||||
Err(Error::TaskTimeout) => return Err(Error::TaskTimeout),
|
Err(Error::TaskTimeout) => return Err(Error::TaskTimeout),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if e.is_recoverable_heal() {
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
error!(
|
error!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
event = EVENT_HEAL_ERASURE_SET_RESULT,
|
||||||
@@ -304,7 +297,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(4, 4, 0, 0);
|
progress.update_stage(4, 4);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
||||||
@@ -314,7 +307,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 4, 0, 0);
|
progress.update_stage(1, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rebuilt disks are formatted now: mark them as healing so
|
// The rebuilt disks are formatted now: mark them as healing so
|
||||||
@@ -343,7 +336,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(2, 4, 0, 0);
|
progress.update_stage(2, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Heal bucket structure
|
// Step 3: Heal bucket structure
|
||||||
@@ -427,7 +420,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 4, 0, 0);
|
progress.update_stage(3, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Execute erasure set heal with resume
|
// Step 4: Execute erasure set heal with resume
|
||||||
@@ -470,9 +463,7 @@ impl HealTask {
|
|||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
self.progress.write().await.update_stage(4, 4);
|
||||||
let bytes_processed = progress.bytes_processed;
|
|
||||||
progress.update_progress(4, 4, 0, bytes_processed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("metadata: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("metadata: {bucket}/{object}")));
|
||||||
progress.update_progress(0, 3, 0, 0);
|
progress.update_stage(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if object exists
|
// Step 1: Check if object exists
|
||||||
@@ -74,7 +74,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Perform metadata heal using ecstore
|
// Step 2: Perform metadata heal using ecstore
|
||||||
@@ -122,7 +122,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
||||||
@@ -145,7 +145,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,7 +167,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Err(Error::TaskExecutionFailed {
|
Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
||||||
@@ -194,7 +194,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("ec_decode: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("ec_decode: {bucket}/{object}")));
|
||||||
progress.update_progress(0, 3, 0, 0);
|
progress.update_stage(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if object exists
|
// Step 1: Check if object exists
|
||||||
@@ -236,7 +236,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Perform EC decode heal using ecstore
|
// Step 2: Perform EC decode heal using ecstore
|
||||||
@@ -284,7 +284,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
||||||
@@ -309,7 +309,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -331,7 +331,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Err(Error::TaskExecutionFailed {
|
Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||||
progress.update_progress(0, 4, 0, 0);
|
progress.update_stage(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if object exists and get metadata
|
// Step 1: Check if object exists and get metadata
|
||||||
@@ -132,7 +132,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: directly call ecstore to perform heal
|
// Step 2: directly call ecstore to perform heal
|
||||||
@@ -187,7 +187,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if Self::should_return_typed_heal_error(&e) {
|
if Self::should_return_typed_heal_error(&e) {
|
||||||
@@ -249,7 +249,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -275,7 +275,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -295,7 +295,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if Self::should_return_typed_heal_error(&e) {
|
if Self::should_return_typed_heal_error(&e) {
|
||||||
@@ -414,7 +414,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(4, 4, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -547,7 +547,6 @@ struct MockStorage {
|
|||||||
heal_object_outcome: Mutex<Option<MockHealObjectOutcome>>,
|
heal_object_outcome: Mutex<Option<MockHealObjectOutcome>>,
|
||||||
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
||||||
format_no_heal_required: Mutex<bool>,
|
format_no_heal_required: Mutex<bool>,
|
||||||
format_error: Mutex<Option<Error>>,
|
|
||||||
global_format_calls: Mutex<u32>,
|
global_format_calls: Mutex<u32>,
|
||||||
replacement_format_calls: Mutex<Vec<(usize, usize, Vec<String>)>>,
|
replacement_format_calls: Mutex<Vec<(usize, usize, Vec<String>)>>,
|
||||||
replacement_targets_ready: Mutex<bool>,
|
replacement_targets_ready: Mutex<bool>,
|
||||||
@@ -868,9 +867,6 @@ impl HealStorageAPI for MockStorage {
|
|||||||
|
|
||||||
async fn heal_format(&self, _dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
async fn heal_format(&self, _dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||||
*self.global_format_calls.lock().unwrap() += 1;
|
*self.global_format_calls.lock().unwrap() += 1;
|
||||||
if let Some(error) = self.format_error.lock().unwrap().take() {
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
let no_heal_required = *self.format_no_heal_required.lock().unwrap();
|
let no_heal_required = *self.format_no_heal_required.lock().unwrap();
|
||||||
if no_heal_required {
|
if no_heal_required {
|
||||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::NoHealRequired))))
|
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::NoHealRequired))))
|
||||||
@@ -2056,30 +2052,6 @@ async fn test_erasure_set_heal_continues_after_format_no_heal_required() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn erasure_set_format_slowdown_is_propagated() {
|
|
||||||
let storage = Arc::new(MockStorage {
|
|
||||||
format_error: Mutex::new(Some(Error::Storage(EcstoreError::SlowDown))),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
let request = HealRequest::new(
|
|
||||||
HealType::ErasureSet {
|
|
||||||
buckets: Vec::new(),
|
|
||||||
set_disk_id: "pool_0_set_0".to_string(),
|
|
||||||
},
|
|
||||||
HealOptions::default(),
|
|
||||||
HealPriority::Normal,
|
|
||||||
);
|
|
||||||
let task = HealTask::from_request(request, storage);
|
|
||||||
|
|
||||||
let error = task
|
|
||||||
.execute()
|
|
||||||
.await
|
|
||||||
.expect_err("format SlowDown must remain recoverable for the task manager");
|
|
||||||
|
|
||||||
assert!(matches!(error, Error::Storage(EcstoreError::SlowDown)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn erasure_set_bucket_prepass_failure_stops_before_object_heal() {
|
async fn erasure_set_bucket_prepass_failure_stops_before_object_heal() {
|
||||||
let temp = TempDir::new().expect("temporary directory should be created");
|
let temp = TempDir::new().expect("temporary directory should be created");
|
||||||
@@ -2124,6 +2096,7 @@ async fn erasure_set_heal_applies_usage_baseline_to_progress() {
|
|||||||
usage_baseline: Mutex::new(Some(HealBucketUsageBaseline {
|
usage_baseline: Mutex::new(Some(HealBucketUsageBaseline {
|
||||||
objects_count: 10,
|
objects_count: 10,
|
||||||
bytes: 8,
|
bytes: 8,
|
||||||
|
generation: Some(1),
|
||||||
})),
|
})),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -2147,6 +2120,8 @@ async fn erasure_set_heal_applies_usage_baseline_to_progress() {
|
|||||||
let progress = task.get_progress().await;
|
let progress = task.get_progress().await;
|
||||||
assert_eq!(progress.objects_total_count, 10);
|
assert_eq!(progress.objects_total_count, 10);
|
||||||
assert_eq!(progress.objects_total_size, 8);
|
assert_eq!(progress.objects_total_size, 8);
|
||||||
|
assert!(progress.baseline_generation.is_some());
|
||||||
|
assert!(progress.baseline_known);
|
||||||
assert_eq!(progress.bytes_processed, 2);
|
assert_eq!(progress.bytes_processed, 2);
|
||||||
assert!((progress.progress_percentage - 25.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 25.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,6 @@ pub const INTERNODE_STAGE_READ_VERSION_RESPONSE_JSON_ENCODE: &str = "read_versio
|
|||||||
pub const INTERNODE_STAGE_READ_VERSION_RESPONSE_MSGPACK_ENCODE: &str = "read_version_response_msgpack_encode";
|
pub const INTERNODE_STAGE_READ_VERSION_RESPONSE_MSGPACK_ENCODE: &str = "read_version_response_msgpack_encode";
|
||||||
pub const INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP: &str = "read_version_rpc_roundtrip";
|
pub const INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP: &str = "read_version_rpc_roundtrip";
|
||||||
pub const INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE: &str = "read_version_response_decode";
|
pub const INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE: &str = "read_version_response_decode";
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE: &str = "batch_read_version_request_encode";
|
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_DECODE: &str = "batch_read_version_request_decode";
|
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_DISK_READ: &str = "batch_read_version_disk_read";
|
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_JSON_ENCODE: &str = "batch_read_version_response_json_encode";
|
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_MSGPACK_ENCODE: &str = "batch_read_version_response_msgpack_encode";
|
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP: &str = "batch_read_version_rpc_roundtrip";
|
|
||||||
pub const INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_DECODE: &str = "batch_read_version_response_decode";
|
|
||||||
|
|
||||||
const OPERATION_LABEL: &str = "operation";
|
const OPERATION_LABEL: &str = "operation";
|
||||||
const BACKEND_LABEL: &str = "backend";
|
const BACKEND_LABEL: &str = "backend";
|
||||||
|
|||||||
@@ -689,14 +689,6 @@ pub struct ScannerMetrics {
|
|||||||
pub cycle_max_objects: u64,
|
pub cycle_max_objects: u64,
|
||||||
#[serde(rename = "cycle_max_directories", default)]
|
#[serde(rename = "cycle_max_directories", default)]
|
||||||
pub cycle_max_directories: u64,
|
pub cycle_max_directories: u64,
|
||||||
#[serde(rename = "cycle_timeout_total", default)]
|
|
||||||
pub cycle_timeout_total: u64,
|
|
||||||
#[serde(rename = "cycle_recovery_required_total", default)]
|
|
||||||
pub cycle_recovery_required_total: u64,
|
|
||||||
#[serde(rename = "cycle_last_progress_age", default)]
|
|
||||||
pub cycle_last_progress_age: u64,
|
|
||||||
#[serde(rename = "leader_lease_without_progress", default)]
|
|
||||||
pub leader_lease_without_progress: bool,
|
|
||||||
#[serde(rename = "bitrot_cycle_enabled", default)]
|
#[serde(rename = "bitrot_cycle_enabled", default)]
|
||||||
pub bitrot_cycle_enabled: bool,
|
pub bitrot_cycle_enabled: bool,
|
||||||
#[serde(rename = "bitrot_cycle_seconds", default)]
|
#[serde(rename = "bitrot_cycle_seconds", default)]
|
||||||
@@ -772,8 +764,6 @@ impl ScannerMetrics {
|
|||||||
self.cycle_max_duration_seconds = other.cycle_max_duration_seconds;
|
self.cycle_max_duration_seconds = other.cycle_max_duration_seconds;
|
||||||
self.cycle_max_objects = other.cycle_max_objects;
|
self.cycle_max_objects = other.cycle_max_objects;
|
||||||
self.cycle_max_directories = other.cycle_max_directories;
|
self.cycle_max_directories = other.cycle_max_directories;
|
||||||
self.cycle_last_progress_age = other.cycle_last_progress_age;
|
|
||||||
self.leader_lease_without_progress = other.leader_lease_without_progress;
|
|
||||||
self.bitrot_cycle_enabled = other.bitrot_cycle_enabled;
|
self.bitrot_cycle_enabled = other.bitrot_cycle_enabled;
|
||||||
self.bitrot_cycle_seconds = other.bitrot_cycle_seconds;
|
self.bitrot_cycle_seconds = other.bitrot_cycle_seconds;
|
||||||
}
|
}
|
||||||
@@ -867,12 +857,6 @@ impl ScannerMetrics {
|
|||||||
.saturating_add(other.last_cycle_replication_checks);
|
.saturating_add(other.last_cycle_replication_checks);
|
||||||
self.last_cycle_usage_saves = self.last_cycle_usage_saves.saturating_add(other.last_cycle_usage_saves);
|
self.last_cycle_usage_saves = self.last_cycle_usage_saves.saturating_add(other.last_cycle_usage_saves);
|
||||||
self.failed_cycles = self.failed_cycles.saturating_add(other.failed_cycles);
|
self.failed_cycles = self.failed_cycles.saturating_add(other.failed_cycles);
|
||||||
self.cycle_timeout_total = self.cycle_timeout_total.saturating_add(other.cycle_timeout_total);
|
|
||||||
self.cycle_recovery_required_total = self
|
|
||||||
.cycle_recovery_required_total
|
|
||||||
.saturating_add(other.cycle_recovery_required_total);
|
|
||||||
self.cycle_last_progress_age = self.cycle_last_progress_age.max(other.cycle_last_progress_age);
|
|
||||||
self.leader_lease_without_progress |= other.leader_lease_without_progress;
|
|
||||||
self.superseded_cycles = self.superseded_cycles.saturating_add(other.superseded_cycles);
|
self.superseded_cycles = self.superseded_cycles.saturating_add(other.superseded_cycles);
|
||||||
self.partial_cycles_unknown = self.partial_cycles_unknown.saturating_add(other.partial_cycles_unknown);
|
self.partial_cycles_unknown = self.partial_cycles_unknown.saturating_add(other.partial_cycles_unknown);
|
||||||
self.partial_cycles_runtime = self.partial_cycles_runtime.saturating_add(other.partial_cycles_runtime);
|
self.partial_cycles_runtime = self.partial_cycles_runtime.saturating_add(other.partial_cycles_runtime);
|
||||||
|
|||||||
@@ -195,8 +195,7 @@ pub struct BucketPolicyArgs<'a> {
|
|||||||
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BucketPolicy {
|
pub struct BucketPolicy {
|
||||||
// RUSTFS_COMPAT_TODO(rustfs-6339): accept bucket policies persisted with the legacy "ID" key. Remove after migration tooling rewrites every retained legacy bucket policy.
|
#[serde(default, rename = "Id", skip_serializing_if = "ID::is_empty")]
|
||||||
#[serde(default, rename = "Id", alias = "ID", skip_serializing_if = "ID::is_empty")]
|
|
||||||
pub id: ID,
|
pub id: ID,
|
||||||
#[serde(rename = "Version")]
|
#[serde(rename = "Version")]
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@@ -2787,7 +2786,7 @@ mod test {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse");
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse");
|
||||||
|
|
||||||
// Verify empty fields are omitted
|
// Verify empty fields are omitted
|
||||||
assert!(parsed.get("Id").is_none(), "Empty ID should be omitted");
|
assert!(!parsed.as_object().unwrap().contains_key("ID"), "Empty ID should be omitted");
|
||||||
|
|
||||||
let statement = &parsed["Statement"][0];
|
let statement = &parsed["Statement"][0];
|
||||||
assert!(!statement.as_object().unwrap().contains_key("Sid"), "Empty Sid should be omitted");
|
assert!(!statement.as_object().unwrap().contains_key("Sid"), "Empty Sid should be omitted");
|
||||||
@@ -2810,43 +2809,6 @@ mod test {
|
|||||||
assert_eq!(statement["Principal"]["AWS"], "*");
|
assert_eq!(statement["Principal"]["AWS"], "*");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_bucket_policy_deserializes_legacy_id() {
|
|
||||||
let legacy_policy = br#"{"ID":"","Version":"2012-10-17","Statement":[{"Sid":"","Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"NotAction":[],"Resource":["arn:aws:s3:::bucket/*"],"NotResource":[],"Condition":{}}]}"#;
|
|
||||||
|
|
||||||
let policy: BucketPolicy =
|
|
||||||
serde_json::from_slice(legacy_policy).expect("bucket policy with legacy ID should deserialize");
|
|
||||||
assert!(policy.id.is_empty());
|
|
||||||
policy.is_valid().expect("legacy bucket policy should remain valid");
|
|
||||||
|
|
||||||
let policy: BucketPolicy = serde_json::from_str(r#"{"ID":"legacy-policy","Version":"2012-10-17","Statement":[]}"#)
|
|
||||||
.expect("non-empty legacy ID should deserialize");
|
|
||||||
assert_eq!(policy.id.0, "legacy-policy");
|
|
||||||
|
|
||||||
let serialized = serde_json::to_value(&policy).expect("bucket policy should serialize");
|
|
||||||
assert_eq!(serialized["Id"], "legacy-policy");
|
|
||||||
assert!(serialized.get("ID").is_none(), "legacy ID spelling should not be serialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_bucket_policy_legacy_id_alias_remains_strict() {
|
|
||||||
let unknown_field = r#"{"Version":"2012-10-17","Statement":[],"Unexpected":true}"#;
|
|
||||||
let error =
|
|
||||||
serde_json::from_str::<BucketPolicy>(unknown_field).expect_err("unrelated unknown fields should remain rejected");
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field `Unexpected`"),
|
|
||||||
"unexpected deserialization error: {error}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let duplicate_id = r#"{"Id":"current-policy","ID":"legacy-policy","Version":"2012-10-17","Statement":[]}"#;
|
|
||||||
let error = serde_json::from_str::<BucketPolicy>(duplicate_id)
|
|
||||||
.expect_err("canonical and legacy ID fields should not be accepted together");
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("duplicate field `Id`"),
|
|
||||||
"unexpected deserialization error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_existing_object_tag_condition_helpers() {
|
fn test_existing_object_tag_condition_helpers() {
|
||||||
let identity_policy = Policy::parse_config(
|
let identity_policy = Policy::parse_config(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use super::persistence::DataUsageCacheLoadAttempt;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::storage_api::scanner_io::{HTTPRangeSpec, ObjectIO};
|
use crate::storage_api::scanner_io::{HTTPRangeSpec, ObjectIO};
|
||||||
use crate::{ScannerGetObjectReader, ScannerPutObjReader};
|
use crate::{ScannerGetObjectReader, ScannerPutObjReader};
|
||||||
use rustfs_data_usage::{ReplicationAllStats, ReplicationTargetUsage};
|
use rustfs_data_usage::{ReplicationAllStats, ReplicationStats};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -1636,7 +1636,7 @@ fn size_recursive_prunes_empty_and_preserves_threshold_replication_stats() {
|
|||||||
replication_stats: Some(ReplicationAllStats {
|
replication_stats: Some(ReplicationAllStats {
|
||||||
targets: HashMap::from([(
|
targets: HashMap::from([(
|
||||||
"arn:test:threshold".to_string(),
|
"arn:test:threshold".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
after_threshold_count: 1,
|
after_threshold_count: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,10 +125,7 @@ impl Default for ScannerRuntimeConfig {
|
|||||||
cycle_interval_source: ScannerRuntimeConfigSource::Default,
|
cycle_interval_source: ScannerRuntimeConfigSource::Default,
|
||||||
bitrot_cycle: Some(Duration::from_secs(DEFAULT_HEAL_BITROT_CYCLE_SECS)),
|
bitrot_cycle: Some(Duration::from_secs(DEFAULT_HEAL_BITROT_CYCLE_SECS)),
|
||||||
bitrot_cycle_source: ScannerRuntimeConfigSource::Default,
|
bitrot_cycle_source: ScannerRuntimeConfigSource::Default,
|
||||||
cycle_budget: ScannerCycleBudgetConfig {
|
cycle_budget: ScannerCycleBudgetConfig::default(),
|
||||||
max_duration: Some(Duration::from_secs(DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
cycle_max_duration_source: ScannerRuntimeConfigSource::Default,
|
cycle_max_duration_source: ScannerRuntimeConfigSource::Default,
|
||||||
cycle_max_objects_source: ScannerRuntimeConfigSource::Default,
|
cycle_max_objects_source: ScannerRuntimeConfigSource::Default,
|
||||||
cycle_max_directories_source: ScannerRuntimeConfigSource::Default,
|
cycle_max_directories_source: ScannerRuntimeConfigSource::Default,
|
||||||
@@ -377,10 +374,7 @@ fn validate_persisted_scanner_runtime_config(config: &ServerConfig) -> Result<()
|
|||||||
}
|
}
|
||||||
validate_optional_config_u64(scanner_kvs, SCANNER_START_DELAY, "")?;
|
validate_optional_config_u64(scanner_kvs, SCANNER_START_DELAY, "")?;
|
||||||
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE, "")?;
|
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE, "")?;
|
||||||
if let Some(value) = config_value(scanner_kvs, SCANNER_CYCLE_MAX_DURATION, DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS) {
|
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE_MAX_DURATION, DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS)?;
|
||||||
let secs = parse_config_u64(SCANNER_CYCLE_MAX_DURATION, value)?;
|
|
||||||
cycle_duration_from_secs(SCANNER_CYCLE_MAX_DURATION, secs)?;
|
|
||||||
}
|
|
||||||
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS)?;
|
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS)?;
|
||||||
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE_MAX_DIRECTORIES, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES)?;
|
validate_optional_config_u64(scanner_kvs, SCANNER_CYCLE_MAX_DIRECTORIES, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES)?;
|
||||||
if let Some(value) = config_value(heal_kvs, HEAL_BITROT_CYCLE, DEFAULT_HEAL_BITROT_CYCLE_SECS) {
|
if let Some(value) = config_value(heal_kvs, HEAL_BITROT_CYCLE, DEFAULT_HEAL_BITROT_CYCLE_SECS) {
|
||||||
@@ -442,46 +436,19 @@ fn lookup_max_wait(
|
|||||||
Ok((speed.max_sleep(), speed_source))
|
Ok((speed.max_sleep(), speed_source))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lookup_cycle_duration(kvs: Option<&KVS>) -> Result<(Option<Duration>, ScannerRuntimeConfigSource), ScannerRuntimeConfigError> {
|
fn lookup_optional_seconds(
|
||||||
match rustfs_utils::get_env_parse_outcome::<u64>(ENV_SCANNER_CYCLE_MAX_DURATION_SECS) {
|
kvs: Option<&KVS>,
|
||||||
rustfs_utils::EnvParseOutcome::Parsed(secs) => {
|
key: &'static str,
|
||||||
return cycle_duration_from_secs(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, secs)
|
env_key: &'static str,
|
||||||
.map(|duration| (duration, ScannerRuntimeConfigSource::Env));
|
default: u64,
|
||||||
|
) -> Result<(Option<Duration>, ScannerRuntimeConfigSource), ScannerRuntimeConfigError> {
|
||||||
|
if let Some(secs) = rustfs_utils::get_env_opt_u64(env_key) {
|
||||||
|
return Ok((Some(Duration::from_secs(secs)), ScannerRuntimeConfigSource::Env));
|
||||||
}
|
}
|
||||||
rustfs_utils::EnvParseOutcome::Invalid => {
|
if let Some(value) = config_value(kvs, key, default) {
|
||||||
// Do not include the raw environment value in the typed error:
|
return parse_config_u64(key, value).map(|secs| (Some(Duration::from_secs(secs)), ScannerRuntimeConfigSource::Config));
|
||||||
// deployments occasionally put sensitive material in inherited
|
|
||||||
// environment snapshots. The key still identifies the control.
|
|
||||||
return Err(invalid_value(
|
|
||||||
ENV_SCANNER_CYCLE_MAX_DURATION_SECS,
|
|
||||||
"<invalid>",
|
|
||||||
"expected unsigned integer seconds",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
rustfs_utils::EnvParseOutcome::Absent => {}
|
Ok((None, ScannerRuntimeConfigSource::Default))
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(value) = config_value(kvs, SCANNER_CYCLE_MAX_DURATION, DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS) {
|
|
||||||
let secs = parse_config_u64(SCANNER_CYCLE_MAX_DURATION, value)?;
|
|
||||||
return cycle_duration_from_secs(SCANNER_CYCLE_MAX_DURATION, secs)
|
|
||||||
.map(|duration| (duration, ScannerRuntimeConfigSource::Config));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
Some(Duration::from_secs(DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS)),
|
|
||||||
ScannerRuntimeConfigSource::Default,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cycle_duration_from_secs(key: &'static str, secs: u64) -> Result<Option<Duration>, ScannerRuntimeConfigError> {
|
|
||||||
if secs == 0 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let duration = Duration::from_secs(secs);
|
|
||||||
if std::time::Instant::now().checked_add(duration).is_none() {
|
|
||||||
return Err(invalid_value(key, "<overflow>", "duration exceeds the timer range"));
|
|
||||||
}
|
|
||||||
Ok(Some(duration))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lookup_start_delay(kvs: Option<&KVS>) -> Result<(Option<Duration>, ScannerRuntimeConfigSource), ScannerRuntimeConfigError> {
|
fn lookup_start_delay(kvs: Option<&KVS>) -> Result<(Option<Duration>, ScannerRuntimeConfigSource), ScannerRuntimeConfigError> {
|
||||||
@@ -586,7 +553,12 @@ pub(crate) fn lookup_scanner_runtime_config(
|
|||||||
(speed.cycle_interval(), speed_source)
|
(speed.cycle_interval(), speed_source)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (cycle_max_duration, cycle_max_duration_source) = lookup_cycle_duration(scanner_kvs)?;
|
let (cycle_max_duration, cycle_max_duration_source) = lookup_optional_seconds(
|
||||||
|
scanner_kvs,
|
||||||
|
SCANNER_CYCLE_MAX_DURATION,
|
||||||
|
ENV_SCANNER_CYCLE_MAX_DURATION_SECS,
|
||||||
|
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS,
|
||||||
|
)?;
|
||||||
let (cycle_max_objects, cycle_max_objects_source) = lookup_count_budget(
|
let (cycle_max_objects, cycle_max_objects_source) = lookup_count_budget(
|
||||||
scanner_kvs,
|
scanner_kvs,
|
||||||
SCANNER_CYCLE_MAX_OBJECTS,
|
SCANNER_CYCLE_MAX_OBJECTS,
|
||||||
@@ -891,10 +863,10 @@ mod tests {
|
|||||||
use rustfs_config::server_config::{Config as ServerConfig, KVS};
|
use rustfs_config::server_config::{Config as ServerConfig, KVS};
|
||||||
use rustfs_config::{
|
use rustfs_config::{
|
||||||
DEFAULT_DELIMITER, DEFAULT_HEAL_BITROT_CYCLE_SECS, ENV_SCANNER_BITROT_CYCLE_SECS, ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS,
|
DEFAULT_DELIMITER, DEFAULT_HEAL_BITROT_CYCLE_SECS, ENV_SCANNER_BITROT_CYCLE_SECS, ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS,
|
||||||
ENV_SCANNER_CYCLE, ENV_SCANNER_CYCLE_MAX_DURATION_SECS, ENV_SCANNER_CYCLE_MAX_OBJECTS, ENV_SCANNER_DELAY,
|
ENV_SCANNER_CYCLE, ENV_SCANNER_CYCLE_MAX_OBJECTS, ENV_SCANNER_DELAY, ENV_SCANNER_MAX_WAIT_SECS, ENV_SCANNER_SPEED,
|
||||||
ENV_SCANNER_MAX_WAIT_SECS, ENV_SCANNER_SPEED, HEAL_BITROT_CYCLE, HEAL_SUB_SYS, SCANNER_BITROT_CYCLE,
|
HEAL_BITROT_CYCLE, HEAL_SUB_SYS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
|
||||||
SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION,
|
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY, SCANNER_IDLE_MODE,
|
||||||
SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY, SCANNER_IDLE_MODE, SCANNER_SPEED, SCANNER_SUB_SYS, ScannerSpeed,
|
SCANNER_SPEED, SCANNER_SUB_SYS, ScannerSpeed,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -969,50 +941,6 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scanner_unset_budget_uses_safe_default_but_explicit_zero_is_unbounded() {
|
|
||||||
let config = server_config_with_scanner(&[]);
|
|
||||||
with_var_unset(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, || {
|
|
||||||
let resolved = lookup_scanner_runtime_config(Some(&config)).expect("scanner runtime config");
|
|
||||||
assert_eq!(resolved.cycle_budget.max_duration, Some(Duration::from_secs(1800)));
|
|
||||||
assert_eq!(resolved.cycle_max_duration_source, ScannerRuntimeConfigSource::Default);
|
|
||||||
});
|
|
||||||
|
|
||||||
let config = server_config_with_scanner(&[(SCANNER_CYCLE_MAX_DURATION, "0")]);
|
|
||||||
with_var_unset(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, || {
|
|
||||||
let resolved = lookup_scanner_runtime_config(Some(&config)).expect("scanner runtime config");
|
|
||||||
assert_eq!(resolved.cycle_budget.max_duration, None);
|
|
||||||
assert_eq!(resolved.cycle_max_duration_source, ScannerRuntimeConfigSource::Config);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_budget_invalid_or_overflow_config_is_rejected() {
|
|
||||||
with_var(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, Some("invalid"), || {
|
|
||||||
let error = lookup_scanner_runtime_config(None).expect_err("invalid duration env must be rejected");
|
|
||||||
assert!(error.to_string().contains(ENV_SCANNER_CYCLE_MAX_DURATION_SECS));
|
|
||||||
assert!(error.to_string().contains("<invalid>"));
|
|
||||||
assert!(!error.to_string().contains(": invalid ("));
|
|
||||||
});
|
|
||||||
with_var(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, Some("18446744073709551616"), || {
|
|
||||||
assert!(lookup_scanner_runtime_config(None).is_err());
|
|
||||||
});
|
|
||||||
with_var(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, Some("18446744073709551615"), || {
|
|
||||||
assert!(lookup_scanner_runtime_config(None).is_err());
|
|
||||||
});
|
|
||||||
let config = server_config_with_scanner(&[(SCANNER_CYCLE_MAX_DURATION, "not-a-duration")]);
|
|
||||||
assert!(lookup_scanner_runtime_config(Some(&config)).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scanner_runtime_config_validation_rejects_overflow_persisted_duration() {
|
|
||||||
let config = server_config_with_scanner(&[(SCANNER_CYCLE_MAX_DURATION, "18446744073709551615")]);
|
|
||||||
|
|
||||||
let error = validate_scanner_runtime_config(&config)
|
|
||||||
.expect_err("persisted duration that exceeds the timer range must be rejected");
|
|
||||||
assert!(error.to_string().contains(SCANNER_CYCLE_MAX_DURATION));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_runtime_config_normalizes_persisted_default_speed() {
|
fn scanner_runtime_config_normalizes_persisted_default_speed() {
|
||||||
let config = server_config_with_scanner(&[(SCANNER_SPEED, "default")]);
|
let config = server_config_with_scanner(&[(SCANNER_SPEED, "default")]);
|
||||||
|
|||||||
+20
-197
@@ -52,7 +52,6 @@ use rustfs_config::{
|
|||||||
};
|
};
|
||||||
use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS};
|
use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS};
|
||||||
use rustfs_data_usage::observed_data_usage_is_newer;
|
use rustfs_data_usage::observed_data_usage_is_newer;
|
||||||
use rustfs_lock::NamespaceLockGuard;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
use tokio::sync::{Notify, mpsc};
|
use tokio::sync::{Notify, mpsc};
|
||||||
@@ -1038,116 +1037,20 @@ fn data_usage_persist_timeout() -> Duration {
|
|||||||
DataUsageCache::persistence_timeout()
|
DataUsageCache::persistence_timeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
const SCANNER_CYCLE_EPOCH_FENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
#[cfg(test)]
|
|
||||||
const SCANNER_CYCLE_EPOCH_FENCE_TIMEOUT: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
async fn fence_scanner_epoch_after_cycle_timeout<Store, LockLost>(
|
|
||||||
ctx: &CancellationToken,
|
|
||||||
storeapi: Arc<Store>,
|
|
||||||
cycle_info: &mut CurrentCycle,
|
|
||||||
cycle_revision: &mut DataUsageCacheRevision,
|
|
||||||
leader_epoch: &mut u64,
|
|
||||||
lock_lost: LockLost,
|
|
||||||
) -> bool
|
|
||||||
where
|
|
||||||
Store: ScannerObjectIO,
|
|
||||||
LockLost: Future<Output = ()>,
|
|
||||||
{
|
|
||||||
let fence_ctx = ctx.child_token();
|
|
||||||
let claim = claim_scanner_leadership(&fence_ctx, storeapi, cycle_info, cycle_revision, leader_epoch);
|
|
||||||
tokio::pin!(claim);
|
|
||||||
tokio::pin!(lock_lost);
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = &mut lock_lost => {
|
|
||||||
fence_ctx.cancel();
|
|
||||||
false
|
|
||||||
}
|
|
||||||
result = tokio::time::timeout(SCANNER_CYCLE_EPOCH_FENCE_TIMEOUT, &mut claim) => {
|
|
||||||
result.unwrap_or(false) && !fence_ctx.is_cancelled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScannerCycleDeadlineState<'a> {
|
|
||||||
cycle_info: &'a mut CurrentCycle,
|
|
||||||
cycle_revision: &'a mut DataUsageCacheRevision,
|
|
||||||
leader_epoch: &'a mut u64,
|
|
||||||
cycle_budget: &'a ScannerCycleBudget,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cycle_timeout_requires_recovery(worker_stopped: bool, cycle_state_persisted: bool, generation_fenced: bool) -> bool {
|
|
||||||
!worker_stopped || !cycle_state_persisted || !generation_fenced
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_scanner_cycle_deadline<Store>(
|
|
||||||
ctx: &CancellationToken,
|
|
||||||
storeapi: Arc<Store>,
|
|
||||||
state: ScannerCycleDeadlineState<'_>,
|
|
||||||
worker_stopped: bool,
|
|
||||||
guard: &mut NamespaceLockGuard,
|
|
||||||
) where
|
|
||||||
Store: ScannerObjectIO,
|
|
||||||
{
|
|
||||||
let fenced = fence_scanner_epoch_after_cycle_timeout(
|
|
||||||
ctx,
|
|
||||||
storeapi,
|
|
||||||
state.cycle_info,
|
|
||||||
state.cycle_revision,
|
|
||||||
state.leader_epoch,
|
|
||||||
guard.lock_lost_notified(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let cycle_state_persisted = state.cycle_budget.cycle_state_persisted();
|
|
||||||
let recovery_required = cycle_timeout_requires_recovery(worker_stopped, cycle_state_persisted, fenced);
|
|
||||||
warn!(
|
|
||||||
target: "rustfs::scanner",
|
|
||||||
event = EVENT_SCANNER_CYCLE_STATE,
|
|
||||||
component = LOG_COMPONENT_SCANNER,
|
|
||||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
|
||||||
state = "cycle_timeout",
|
|
||||||
worker_stopped,
|
|
||||||
cycle_state_persisted,
|
|
||||||
generation_fenced = fenced,
|
|
||||||
recovery_required,
|
|
||||||
"Scanner cycle deadline expired; durable cursor/generation fencing completed when possible"
|
|
||||||
);
|
|
||||||
global_metrics().record_scanner_cycle_timeout(recovery_required, state.cycle_budget.progress_age());
|
|
||||||
// Stop renewing before releasing the lease. A new leader can then claim the
|
|
||||||
// higher persisted generation instead of inheriting the expired worker.
|
|
||||||
guard.release();
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_scan_cycle_idle(cycle_info: &mut CurrentCycle, cycle_metrics_guard: &mut ScannerCycleMetricsGuard) {
|
async fn mark_scan_cycle_idle(cycle_info: &mut CurrentCycle, cycle_metrics_guard: &mut ScannerCycleMetricsGuard) {
|
||||||
cycle_info.current = 0;
|
cycle_info.current = 0;
|
||||||
global_metrics().clear_current_scan_mode();
|
global_metrics().clear_current_scan_mode();
|
||||||
cycle_metrics_guard.finish(cycle_info.clone()).await;
|
cycle_metrics_guard.finish(cycle_info.clone()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[instrument(skip_all)]
|
||||||
|
#[hotpath::measure]
|
||||||
async fn run_data_scanner_cycle(
|
async fn run_data_scanner_cycle(
|
||||||
ctx: &CancellationToken,
|
ctx: &CancellationToken,
|
||||||
storeapi: &Arc<ECStore>,
|
storeapi: &Arc<ECStore>,
|
||||||
cycle_info: &mut CurrentCycle,
|
cycle_info: &mut CurrentCycle,
|
||||||
cycle_revision: &mut DataUsageCacheRevision,
|
cycle_revision: &mut DataUsageCacheRevision,
|
||||||
leader_epoch: u64,
|
leader_epoch: u64,
|
||||||
) -> ScannerCycleOutcome {
|
|
||||||
let cycle_budget = ScannerCycleBudget::new(ctx, scanner_cycle_budget_config());
|
|
||||||
run_data_scanner_cycle_with_budget(ctx, storeapi, cycle_info, cycle_revision, leader_epoch, cycle_budget).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
|
||||||
#[hotpath::measure]
|
|
||||||
async fn run_data_scanner_cycle_with_budget(
|
|
||||||
ctx: &CancellationToken,
|
|
||||||
storeapi: &Arc<ECStore>,
|
|
||||||
cycle_info: &mut CurrentCycle,
|
|
||||||
cycle_revision: &mut DataUsageCacheRevision,
|
|
||||||
leader_epoch: u64,
|
|
||||||
cycle_budget: Arc<ScannerCycleBudget>,
|
|
||||||
) -> ScannerCycleOutcome {
|
) -> ScannerCycleOutcome {
|
||||||
let _activity_guard = ScannerActivityGuard::new();
|
let _activity_guard = ScannerActivityGuard::new();
|
||||||
if let Err(err) = refresh_scanner_runtime_config_from_global() {
|
if let Err(err) = refresh_scanner_runtime_config_from_global() {
|
||||||
@@ -1163,11 +1066,7 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
}
|
}
|
||||||
let configured_cycle_interval = scanner_cycle_interval();
|
let configured_cycle_interval = scanner_cycle_interval();
|
||||||
let configured_bitrot_cycle = scanner_bitrot_cycle();
|
let configured_bitrot_cycle = scanner_bitrot_cycle();
|
||||||
let cycle_budget_config = ScannerCycleBudgetConfig {
|
let cycle_budget_config = scanner_cycle_budget_config();
|
||||||
max_duration: cycle_budget.max_duration(),
|
|
||||||
max_objects: cycle_budget.max_objects(),
|
|
||||||
max_directories: cycle_budget.max_directories(),
|
|
||||||
};
|
|
||||||
let usage_persist_timeout = data_usage_persist_timeout();
|
let usage_persist_timeout = data_usage_persist_timeout();
|
||||||
global_metrics().record_scanner_cycle_config(
|
global_metrics().record_scanner_cycle_config(
|
||||||
configured_cycle_interval,
|
configured_cycle_interval,
|
||||||
@@ -1238,6 +1137,7 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
let (sender, receiver) = mpsc::channel::<DataUsageInfo>(1);
|
let (sender, receiver) = mpsc::channel::<DataUsageInfo>(1);
|
||||||
|
|
||||||
let done_cycle = Metrics::time(Metric::ScanCycle);
|
let done_cycle = Metrics::time(Metric::ScanCycle);
|
||||||
|
let cycle_budget = ScannerCycleBudget::new(ctx, cycle_budget_config);
|
||||||
let scan_result = storeapi
|
let scan_result = storeapi
|
||||||
.clone()
|
.clone()
|
||||||
.nsscanner_with_status(
|
.nsscanner_with_status(
|
||||||
@@ -1377,7 +1277,7 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
"Scanner cycle is recovering to a newer durable cache generation"
|
"Scanner cycle is recovering to a newer durable cache generation"
|
||||||
);
|
);
|
||||||
emit_scan_cycle_partial_with_source(cycle_start.elapsed(), ScanCyclePartialReason::Unknown, None);
|
emit_scan_cycle_partial_with_source(cycle_start.elapsed(), ScanCyclePartialReason::Unknown, None);
|
||||||
let persisted = persist_required_scanner_cycle_floor(
|
return if persist_required_scanner_cycle_floor(
|
||||||
ctx,
|
ctx,
|
||||||
storeapi.clone(),
|
storeapi.clone(),
|
||||||
cycle_info,
|
cycle_info,
|
||||||
@@ -1386,9 +1286,8 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
required_cycle,
|
required_cycle,
|
||||||
&mut cycle_metrics_guard,
|
&mut cycle_metrics_guard,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
return if persisted {
|
{
|
||||||
cycle_budget.mark_cycle_state_persisted();
|
|
||||||
ScannerCycleOutcome::Partial
|
ScannerCycleOutcome::Partial
|
||||||
} else {
|
} else {
|
||||||
ScannerCycleOutcome::Failed
|
ScannerCycleOutcome::Failed
|
||||||
@@ -1446,7 +1345,7 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
scan_cycle_partial_reason(budget_reason),
|
scan_cycle_partial_reason(budget_reason),
|
||||||
scan_cycle_partial_source(budget_reason),
|
scan_cycle_partial_source(budget_reason),
|
||||||
);
|
);
|
||||||
let persisted = finalize_partial_scan_cycle(
|
return if finalize_partial_scan_cycle(
|
||||||
ctx,
|
ctx,
|
||||||
storeapi.clone(),
|
storeapi.clone(),
|
||||||
cycle_info,
|
cycle_info,
|
||||||
@@ -1454,9 +1353,8 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
leader_epoch,
|
leader_epoch,
|
||||||
&mut cycle_metrics_guard,
|
&mut cycle_metrics_guard,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
return if persisted {
|
{
|
||||||
cycle_budget.mark_cycle_state_persisted();
|
|
||||||
ScannerCycleOutcome::Partial
|
ScannerCycleOutcome::Partial
|
||||||
} else {
|
} else {
|
||||||
ScannerCycleOutcome::Failed
|
ScannerCycleOutcome::Failed
|
||||||
@@ -1531,7 +1429,7 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
emit_scan_cycle_partial_with_source(cycle_start.elapsed(), ScanCyclePartialReason::Unknown, None);
|
emit_scan_cycle_partial_with_source(cycle_start.elapsed(), ScanCyclePartialReason::Unknown, None);
|
||||||
let persisted = finalize_partial_scan_cycle(
|
return if finalize_partial_scan_cycle(
|
||||||
ctx,
|
ctx,
|
||||||
storeapi.clone(),
|
storeapi.clone(),
|
||||||
cycle_info,
|
cycle_info,
|
||||||
@@ -1539,9 +1437,8 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
leader_epoch,
|
leader_epoch,
|
||||||
&mut cycle_metrics_guard,
|
&mut cycle_metrics_guard,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
return if persisted {
|
{
|
||||||
cycle_budget.mark_cycle_state_persisted();
|
|
||||||
ScannerCycleOutcome::Partial
|
ScannerCycleOutcome::Partial
|
||||||
} else {
|
} else {
|
||||||
ScannerCycleOutcome::Failed
|
ScannerCycleOutcome::Failed
|
||||||
@@ -1582,7 +1479,6 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
cycle_budget.mark_cycle_state_persisted();
|
|
||||||
emit_scan_cycle_superseded(cycle_start.elapsed());
|
emit_scan_cycle_superseded(cycle_start.elapsed());
|
||||||
return ScannerCycleOutcome::Superseded;
|
return ScannerCycleOutcome::Superseded;
|
||||||
}
|
}
|
||||||
@@ -1615,7 +1511,6 @@ async fn run_data_scanner_cycle_with_budget(
|
|||||||
emit_scan_cycle_complete(false, cycle_start.elapsed());
|
emit_scan_cycle_complete(false, cycle_start.elapsed());
|
||||||
return ScannerCycleOutcome::Failed;
|
return ScannerCycleOutcome::Failed;
|
||||||
}
|
}
|
||||||
cycle_budget.mark_cycle_state_persisted();
|
|
||||||
|
|
||||||
done_cycle();
|
done_cycle();
|
||||||
emit_scan_cycle_complete(true, cycle_start.elapsed());
|
emit_scan_cycle_complete(true, cycle_start.elapsed());
|
||||||
@@ -1680,7 +1575,7 @@ async fn run_data_scanner_with_maintenance_state(
|
|||||||
) -> Result<(), ScannerError> {
|
) -> Result<(), ScannerError> {
|
||||||
reset_scanner_cycle_schedule();
|
reset_scanner_cycle_schedule();
|
||||||
// Acquire leader lock (write lock) to ensure only one scanner runs
|
// Acquire leader lock (write lock) to ensure only one scanner runs
|
||||||
let mut guard = match storeapi.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock").await {
|
let guard = match storeapi.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock").await {
|
||||||
Ok(ns_lock) => match ns_lock.get_write_lock_quiet(get_lock_acquire_timeout()).await {
|
Ok(ns_lock) => match ns_lock.get_write_lock_quiet(get_lock_acquire_timeout()).await {
|
||||||
Ok(guard) => {
|
Ok(guard) => {
|
||||||
record_scanner_leader_lock_state("acquired");
|
record_scanner_leader_lock_state("acquired");
|
||||||
@@ -1845,49 +1740,13 @@ async fn run_data_scanner_with_maintenance_state(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let cycle_ctx = ctx.child_token();
|
let cycle_ctx = ctx.child_token();
|
||||||
let cycle_budget = ScannerCycleBudget::new_with_runtime_progress_tracking(&cycle_ctx, scanner_cycle_budget_config());
|
let initial_outcome = await_scanner_cycle_with_lock_fence(
|
||||||
let initial_outcome = match await_scanner_cycle_with_budget_fence(
|
|
||||||
&cycle_ctx,
|
&cycle_ctx,
|
||||||
&cycle_budget,
|
run_data_scanner_cycle(&cycle_ctx, &storeapi, &mut cycle_info, &mut cycle_revision, leader_epoch),
|
||||||
run_data_scanner_cycle_with_budget(
|
|
||||||
&cycle_ctx,
|
|
||||||
&storeapi,
|
|
||||||
&mut cycle_info,
|
|
||||||
&mut cycle_revision,
|
|
||||||
leader_epoch,
|
|
||||||
cycle_budget.clone(),
|
|
||||||
),
|
|
||||||
guard.lock_lost_notified(),
|
guard.lock_lost_notified(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
.unwrap_or(ScannerCycleOutcome::Failed);
|
||||||
ScannerCycleWaitOutcome::Completed(outcome) => outcome,
|
|
||||||
ScannerCycleWaitOutcome::LockLost => {
|
|
||||||
record_scanner_leader_lock_lost("Scanner leader lock lost during the initial cycle").await;
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
ScannerCycleWaitOutcome::Cancelled => {
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
ScannerCycleWaitOutcome::Deadline { worker_stopped } => {
|
|
||||||
handle_scanner_cycle_deadline(
|
|
||||||
&ctx,
|
|
||||||
storeapi.clone(),
|
|
||||||
ScannerCycleDeadlineState {
|
|
||||||
cycle_info: &mut cycle_info,
|
|
||||||
cycle_revision: &mut cycle_revision,
|
|
||||||
leader_epoch: &mut leader_epoch,
|
|
||||||
cycle_budget: &cycle_budget,
|
|
||||||
},
|
|
||||||
worker_stopped,
|
|
||||||
&mut guard,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
superseded_backoff.record_retryable_cycle(initial_outcome == ScannerCycleOutcome::Superseded);
|
superseded_backoff.record_retryable_cycle(initial_outcome == ScannerCycleOutcome::Superseded);
|
||||||
deferred_backoff.record_retryable_cycle(matches!(initial_outcome, ScannerCycleOutcome::Deferred(_)));
|
deferred_backoff.record_retryable_cycle(matches!(initial_outcome, ScannerCycleOutcome::Deferred(_)));
|
||||||
dirty_usage_generation_seen = dirty_generation_before_cycle;
|
dirty_usage_generation_seen = dirty_generation_before_cycle;
|
||||||
@@ -2093,49 +1952,13 @@ async fn run_data_scanner_with_maintenance_state(
|
|||||||
}
|
}
|
||||||
let dirty_generation_before_cycle = dirty_usage_generation();
|
let dirty_generation_before_cycle = dirty_usage_generation();
|
||||||
let cycle_ctx = ctx.child_token();
|
let cycle_ctx = ctx.child_token();
|
||||||
let cycle_budget = ScannerCycleBudget::new_with_runtime_progress_tracking(&cycle_ctx, scanner_cycle_budget_config());
|
let outcome = await_scanner_cycle_with_lock_fence(
|
||||||
let outcome = match await_scanner_cycle_with_budget_fence(
|
|
||||||
&cycle_ctx,
|
&cycle_ctx,
|
||||||
&cycle_budget,
|
run_data_scanner_cycle(&cycle_ctx, &storeapi, &mut cycle_info, &mut cycle_revision, leader_epoch),
|
||||||
run_data_scanner_cycle_with_budget(
|
|
||||||
&cycle_ctx,
|
|
||||||
&storeapi,
|
|
||||||
&mut cycle_info,
|
|
||||||
&mut cycle_revision,
|
|
||||||
leader_epoch,
|
|
||||||
cycle_budget.clone(),
|
|
||||||
),
|
|
||||||
guard.lock_lost_notified(),
|
guard.lock_lost_notified(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
.unwrap_or(ScannerCycleOutcome::Failed);
|
||||||
ScannerCycleWaitOutcome::Completed(outcome) => outcome,
|
|
||||||
ScannerCycleWaitOutcome::LockLost => {
|
|
||||||
record_scanner_leader_lock_lost("Scanner leader lock lost during a scanner cycle").await;
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
ScannerCycleWaitOutcome::Cancelled => {
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
ScannerCycleWaitOutcome::Deadline { worker_stopped } => {
|
|
||||||
handle_scanner_cycle_deadline(
|
|
||||||
&ctx,
|
|
||||||
storeapi.clone(),
|
|
||||||
ScannerCycleDeadlineState {
|
|
||||||
cycle_info: &mut cycle_info,
|
|
||||||
cycle_revision: &mut cycle_revision,
|
|
||||||
leader_epoch: &mut leader_epoch,
|
|
||||||
cycle_budget: &cycle_budget,
|
|
||||||
},
|
|
||||||
worker_stopped,
|
|
||||||
&mut guard,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
superseded_backoff.record_retryable_cycle(outcome == ScannerCycleOutcome::Superseded);
|
superseded_backoff.record_retryable_cycle(outcome == ScannerCycleOutcome::Superseded);
|
||||||
deferred_backoff.record_retryable_cycle(matches!(outcome, ScannerCycleOutcome::Deferred(_)));
|
deferred_backoff.record_retryable_cycle(matches!(outcome, ScannerCycleOutcome::Deferred(_)));
|
||||||
dirty_usage_generation_seen = dirty_generation_before_cycle;
|
dirty_usage_generation_seen = dirty_generation_before_cycle;
|
||||||
|
|||||||
@@ -1581,63 +1581,3 @@ where
|
|||||||
output = &mut cycle => Some(output),
|
output = &mut cycle => Some(output),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub(super) enum ScannerCycleWaitOutcome<T> {
|
|
||||||
Completed(T),
|
|
||||||
LockLost,
|
|
||||||
Cancelled,
|
|
||||||
Deadline { worker_stopped: bool },
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn await_scanner_cycle_with_budget_fence<Cycle, LockLost>(
|
|
||||||
cycle_ctx: &CancellationToken,
|
|
||||||
budget: &ScannerCycleBudget,
|
|
||||||
cycle: Cycle,
|
|
||||||
lock_lost: LockLost,
|
|
||||||
) -> ScannerCycleWaitOutcome<Cycle::Output>
|
|
||||||
where
|
|
||||||
Cycle: Future,
|
|
||||||
LockLost: Future<Output = ()>,
|
|
||||||
{
|
|
||||||
tokio::pin!(cycle);
|
|
||||||
tokio::pin!(lock_lost);
|
|
||||||
let deadline = async {
|
|
||||||
if let Some(deadline) = budget.deadline() {
|
|
||||||
tokio::time::sleep_until(deadline).await;
|
|
||||||
} else {
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::pin!(deadline);
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = &mut lock_lost => {
|
|
||||||
cycle_ctx.cancel();
|
|
||||||
let _ = tokio::time::timeout(SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT, &mut cycle).await;
|
|
||||||
ScannerCycleWaitOutcome::LockLost
|
|
||||||
}
|
|
||||||
_ = &mut deadline => {
|
|
||||||
budget.cancel_for_runtime();
|
|
||||||
// Let the budget cancellation reach the scanner first so it can
|
|
||||||
// persist a partial cursor. Only an uncooperative worker gets the
|
|
||||||
// parent cancellation, and it is dropped after the bounded window;
|
|
||||||
// the caller fences its epoch next.
|
|
||||||
let worker_stopped = if tokio::time::timeout(SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT, &mut cycle)
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
cycle_ctx.cancel();
|
|
||||||
false
|
|
||||||
};
|
|
||||||
ScannerCycleWaitOutcome::Deadline { worker_stopped }
|
|
||||||
}
|
|
||||||
_ = cycle_ctx.cancelled() => {
|
|
||||||
let _ = tokio::time::timeout(SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT, &mut cycle).await;
|
|
||||||
ScannerCycleWaitOutcome::Cancelled
|
|
||||||
}
|
|
||||||
output = &mut cycle => ScannerCycleWaitOutcome::Completed(output),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ use std::task::Poll;
|
|||||||
use temp_env::{with_var, with_var_unset};
|
use temp_env::{with_var, with_var_unset};
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::time::{Duration, advance};
|
|
||||||
|
|
||||||
const TEST_DEFAULT_SCANNER_CYCLE_SECS: u64 = 24 * 60 * 60;
|
const TEST_DEFAULT_SCANNER_CYCLE_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
@@ -119,178 +118,6 @@ async fn scanner_cycle_lock_fence_bounds_uncooperative_shutdown() {
|
|||||||
assert!(cycle_ctx.is_cancelled());
|
assert!(cycle_ctx.is_cancelled());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn cycle_budget_fences_late_writer_after_timeout() {
|
|
||||||
let cycle_ctx = CancellationToken::new();
|
|
||||||
let budget = ScannerCycleBudget::new(
|
|
||||||
&cycle_ctx,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_duration: Some(Duration::from_secs(5)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let outcome = {
|
|
||||||
let cycle = std::future::pending::<()>();
|
|
||||||
let lock_lost = std::future::pending::<()>();
|
|
||||||
let waiter = await_scanner_cycle_with_budget_fence(&cycle_ctx, &budget, cycle, lock_lost);
|
|
||||||
tokio::pin!(waiter);
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
advance(Duration::from_secs(5)).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
advance(SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT).await;
|
|
||||||
waiter.await
|
|
||||||
};
|
|
||||||
assert_eq!(outcome, ScannerCycleWaitOutcome::Deadline { worker_stopped: false });
|
|
||||||
assert!(cycle_ctx.is_cancelled());
|
|
||||||
assert_eq!(budget.reason(), Some(ScannerCycleBudgetReason::Runtime));
|
|
||||||
|
|
||||||
// A newer leadership epoch is the durable fence that rejects a late
|
|
||||||
// writer after the timed-out future has been dropped.
|
|
||||||
let store = Arc::new(MemoryConfigStore::default());
|
|
||||||
let mut revision = DataUsageCacheRevision::Missing;
|
|
||||||
let mut cycle = CurrentCycle {
|
|
||||||
current: 0,
|
|
||||||
next: 12,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let persist_ctx = CancellationToken::new();
|
|
||||||
assert!(persist_scanner_cycle_state(&persist_ctx, store.clone(), &mut cycle, &mut revision, 1).await);
|
|
||||||
let newer = encode_scanner_cycle_state(&cycle, 2).expect("new epoch fence should encode");
|
|
||||||
let key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
|
||||||
store.interleaving_puts.lock().await.insert(key, (2, newer));
|
|
||||||
let mut late_cycle = CurrentCycle { next: 13, ..cycle };
|
|
||||||
assert!(!persist_scanner_cycle_state(&persist_ctx, store, &mut late_cycle, &mut revision, 1).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn cycle_budget_parent_cancellation_is_not_reported_as_timeout() {
|
|
||||||
let cycle_ctx = CancellationToken::new();
|
|
||||||
let budget = ScannerCycleBudget::new(
|
|
||||||
&cycle_ctx,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_duration: Some(Duration::from_secs(5)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let waiter = await_scanner_cycle_with_budget_fence(&cycle_ctx, &budget, std::future::pending::<()>(), std::future::pending());
|
|
||||||
tokio::pin!(waiter);
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
cycle_ctx.cancel();
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
advance(SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT).await;
|
|
||||||
assert_eq!(waiter.await, ScannerCycleWaitOutcome::Cancelled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn cycle_budget_deadline_wins_same_tick_as_parent_cancellation() {
|
|
||||||
let cycle_ctx = CancellationToken::new();
|
|
||||||
let budget = ScannerCycleBudget::new(
|
|
||||||
&cycle_ctx,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_duration: Some(Duration::from_secs(5)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let waiter = await_scanner_cycle_with_budget_fence(&cycle_ctx, &budget, std::future::pending::<()>(), std::future::pending());
|
|
||||||
tokio::pin!(waiter);
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
advance(Duration::from_secs(5)).await;
|
|
||||||
cycle_ctx.cancel();
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
advance(SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT).await;
|
|
||||||
|
|
||||||
assert_eq!(waiter.await, ScannerCycleWaitOutcome::Deadline { worker_stopped: false });
|
|
||||||
assert_eq!(budget.reason(), Some(ScannerCycleBudgetReason::Runtime));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cycle_budget_persist_cursor_failure_is_recovery_required() {
|
|
||||||
let store = Arc::new(MemoryConfigStore::default());
|
|
||||||
let key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
|
||||||
store.fail_put_number.lock().await.insert(key, 1);
|
|
||||||
|
|
||||||
let ctx = CancellationToken::new();
|
|
||||||
let mut revision = DataUsageCacheRevision::Missing;
|
|
||||||
let mut cycle = CurrentCycle {
|
|
||||||
current: 12,
|
|
||||||
next: 12,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut leader_epoch = 1;
|
|
||||||
let fenced = fence_scanner_epoch_after_cycle_timeout(
|
|
||||||
&ctx,
|
|
||||||
store,
|
|
||||||
&mut cycle,
|
|
||||||
&mut revision,
|
|
||||||
&mut leader_epoch,
|
|
||||||
std::future::pending(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(!fenced, "a failed cursor/generation write must require recovery");
|
|
||||||
let budget = ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default());
|
|
||||||
assert!(cycle_timeout_requires_recovery(true, budget.cycle_state_persisted(), fenced));
|
|
||||||
|
|
||||||
let metrics = Metrics::new();
|
|
||||||
metrics.record_scanner_cycle_timeout(!fenced, Duration::from_secs(17));
|
|
||||||
let report = metrics.report().await;
|
|
||||||
assert_eq!(report.cycle_timeout_total, 1);
|
|
||||||
assert_eq!(report.cycle_recovery_required_total, 1);
|
|
||||||
assert_eq!(report.cycle_last_progress_age, 17);
|
|
||||||
assert!(report.leader_lease_without_progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cycle_budget_deadline_handler_fences_and_releases_guard() {
|
|
||||||
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
|
||||||
let lock = store
|
|
||||||
.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock")
|
|
||||||
.await
|
|
||||||
.expect("scanner leader lock should be created");
|
|
||||||
let mut guard = lock
|
|
||||||
.get_write_lock(Duration::from_secs(1))
|
|
||||||
.await
|
|
||||||
.expect("scanner leader lock should be acquired");
|
|
||||||
|
|
||||||
let ctx = CancellationToken::new();
|
|
||||||
let mut cycle_info = CurrentCycle {
|
|
||||||
current: 12,
|
|
||||||
next: 12,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut cycle_revision = DataUsageCacheRevision::Missing;
|
|
||||||
let mut leader_epoch = 1;
|
|
||||||
let budget = ScannerCycleBudget::new(
|
|
||||||
&ctx,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_duration: Some(Duration::from_secs(60)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
budget.mark_cycle_state_persisted();
|
|
||||||
|
|
||||||
handle_scanner_cycle_deadline(
|
|
||||||
&ctx,
|
|
||||||
store.clone(),
|
|
||||||
ScannerCycleDeadlineState {
|
|
||||||
cycle_info: &mut cycle_info,
|
|
||||||
cycle_revision: &mut cycle_revision,
|
|
||||||
leader_epoch: &mut leader_epoch,
|
|
||||||
cycle_budget: &budget,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
&mut guard,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(guard.is_released());
|
|
||||||
let persisted = read_config(store, &DATA_USAGE_BLOOM_NAME_PATH)
|
|
||||||
.await
|
|
||||||
.expect("deadline handler should persist a fenced cursor");
|
|
||||||
let (_, persisted_epoch) = decode_scanner_cycle_state(&persisted).expect("fenced cursor should decode");
|
|
||||||
assert_eq!(persisted_epoch, 2);
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scanner_cycle_recovery_wake_survives_wait_registration_race() {
|
async fn scanner_cycle_recovery_wake_survives_wait_registration_race() {
|
||||||
notify_scanner_cycle_recovery_wake();
|
notify_scanner_cycle_recovery_wake();
|
||||||
@@ -601,6 +428,13 @@ fn test_scanner_cycle_max_duration_uses_env() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_cycle_max_duration_default_is_disabled() {
|
||||||
|
with_var_unset(ENV_SCANNER_CYCLE_MAX_DURATION_SECS, || {
|
||||||
|
assert_eq!(scanner_cycle_max_duration(), None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_scanner_cycle_budget_cancels_after_duration() {
|
async fn test_scanner_cycle_budget_cancels_after_duration() {
|
||||||
let parent = CancellationToken::new();
|
let parent = CancellationToken::new();
|
||||||
@@ -2408,7 +2242,7 @@ async fn test_leadership_claim_usage_fence_rejects_old_inflight_writer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cycle_budget_lease_takeover_rejects_old_generation() {
|
async fn test_successful_old_epoch_commit_is_fenced_after_cancellation() {
|
||||||
let store = Arc::new(MemoryConfigStore::default());
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
let ctx = CancellationToken::new();
|
let ctx = CancellationToken::new();
|
||||||
let mut revision = DataUsageCacheRevision::Missing;
|
let mut revision = DataUsageCacheRevision::Missing;
|
||||||
@@ -2453,17 +2287,12 @@ async fn cycle_budget_lease_takeover_rejects_old_generation() {
|
|||||||
.await
|
.await
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = read_config(store.clone(), &DATA_USAGE_BLOOM_NAME_PATH)
|
let state = read_config(store, &DATA_USAGE_BLOOM_NAME_PATH)
|
||||||
.await
|
.await
|
||||||
.expect("replacement leadership claim should persist");
|
.expect("replacement leadership claim should persist");
|
||||||
let (claimed_cycle, claimed_epoch) = decode_scanner_cycle_state(&state).expect("replacement cycle state should decode");
|
let (claimed_cycle, claimed_epoch) = decode_scanner_cycle_state(&state).expect("replacement cycle state should decode");
|
||||||
assert_eq!(claimed_cycle.next, 14);
|
assert_eq!(claimed_cycle.next, 14);
|
||||||
assert_eq!(claimed_epoch, 2);
|
assert_eq!(claimed_epoch, 2);
|
||||||
|
|
||||||
let mut stale_cycle = CurrentCycle { next: 15, ..cycle };
|
|
||||||
let mut stale_revision = DataUsageCacheRevision::Etag("memory-2".to_string());
|
|
||||||
let stale_ctx = CancellationToken::new();
|
|
||||||
assert!(!persist_scanner_cycle_state(&stale_ctx, store, &mut stale_cycle, &mut stale_revision, 1,).await);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -14,16 +14,17 @@
|
|||||||
|
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
|
atomic::{AtomicU8, AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
use tokio::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use tokio::time::Duration;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
const BUDGET_REASON_NONE: u8 = 0;
|
const BUDGET_REASON_NONE: u8 = 0;
|
||||||
const BUDGET_REASON_RUNTIME: u8 = 1;
|
const BUDGET_REASON_RUNTIME: u8 = 1;
|
||||||
const BUDGET_REASON_OBJECTS: u8 = 2;
|
const BUDGET_REASON_OBJECTS: u8 = 2;
|
||||||
const BUDGET_REASON_DIRECTORIES: u8 = 3;
|
const BUDGET_REASON_DIRECTORIES: u8 = 3;
|
||||||
const PROGRESS_CLOCK_SAMPLE_INTERVAL: u64 = 128;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct ScannerCycleBudgetConfig {
|
pub(crate) struct ScannerCycleBudgetConfig {
|
||||||
@@ -62,51 +63,29 @@ pub struct ScannerCycleBudget {
|
|||||||
token: CancellationToken,
|
token: CancellationToken,
|
||||||
reason: Arc<AtomicU8>,
|
reason: Arc<AtomicU8>,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
deadline: Option<Instant>,
|
|
||||||
max_duration: Option<Duration>,
|
max_duration: Option<Duration>,
|
||||||
max_objects: Option<u64>,
|
max_objects: Option<u64>,
|
||||||
max_directories: Option<u64>,
|
max_directories: Option<u64>,
|
||||||
track_progress: bool,
|
track_progress: bool,
|
||||||
track_unbounded_counts: bool,
|
|
||||||
objects_scanned: AtomicU64,
|
objects_scanned: AtomicU64,
|
||||||
directories_started: AtomicU64,
|
directories_started: AtomicU64,
|
||||||
entries_visited: AtomicU64,
|
entries_visited: AtomicU64,
|
||||||
last_progress_millis: AtomicU64,
|
|
||||||
cycle_state_persisted: AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScannerCycleBudget {
|
impl ScannerCycleBudget {
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn new(parent: &CancellationToken, config: ScannerCycleBudgetConfig) -> Arc<Self> {
|
pub(crate) fn new(parent: &CancellationToken, config: ScannerCycleBudgetConfig) -> Arc<Self> {
|
||||||
Self::new_inner(parent, config, false, false)
|
Self::new_inner(parent, config, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_with_progress_tracking(parent: &CancellationToken, config: ScannerCycleBudgetConfig) -> Arc<Self> {
|
pub(crate) fn new_with_progress_tracking(parent: &CancellationToken, config: ScannerCycleBudgetConfig) -> Arc<Self> {
|
||||||
Self::new_inner(parent, config, true, true)
|
Self::new_inner(parent, config, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_with_runtime_progress_tracking(parent: &CancellationToken, config: ScannerCycleBudgetConfig) -> Arc<Self> {
|
fn new_inner(parent: &CancellationToken, config: ScannerCycleBudgetConfig, track_progress: bool) -> Arc<Self> {
|
||||||
let track_progress = config.max_duration.is_some();
|
|
||||||
Self::new_inner(parent, config, track_progress, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_inner(
|
|
||||||
parent: &CancellationToken,
|
|
||||||
config: ScannerCycleBudgetConfig,
|
|
||||||
track_progress: bool,
|
|
||||||
track_unbounded_counts: bool,
|
|
||||||
) -> Arc<Self> {
|
|
||||||
let token = parent.child_token();
|
let token = parent.child_token();
|
||||||
let reason = Arc::new(AtomicU8::new(BUDGET_REASON_NONE));
|
let reason = Arc::new(AtomicU8::new(BUDGET_REASON_NONE));
|
||||||
let started_at = Instant::now();
|
|
||||||
let deadline = config.max_duration.map(|duration| match started_at.checked_add(duration) {
|
|
||||||
Some(deadline) => deadline,
|
|
||||||
// Runtime config rejects this range, but keep programmatic callers
|
|
||||||
// fail-closed instead of panicking or silently disabling the wall clock.
|
|
||||||
None => started_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(deadline) = deadline {
|
if let Some(duration) = config.max_duration {
|
||||||
let parent = parent.clone();
|
let parent = parent.clone();
|
||||||
let token_wait = token.clone();
|
let token_wait = token.clone();
|
||||||
let token_cancel = token.clone();
|
let token_cancel = token.clone();
|
||||||
@@ -115,7 +94,7 @@ impl ScannerCycleBudget {
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = parent.cancelled() => {}
|
_ = parent.cancelled() => {}
|
||||||
_ = token_wait.cancelled() => {}
|
_ = token_wait.cancelled() => {}
|
||||||
_ = tokio::time::sleep_until(deadline) => {
|
_ = tokio::time::sleep(duration) => {
|
||||||
Self::cancel_for_reason(&reason, &token_cancel, ScannerCycleBudgetReason::Runtime);
|
Self::cancel_for_reason(&reason, &token_cancel, ScannerCycleBudgetReason::Runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,18 +104,14 @@ impl ScannerCycleBudget {
|
|||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
token,
|
token,
|
||||||
reason,
|
reason,
|
||||||
started_at,
|
started_at: Instant::now(),
|
||||||
deadline,
|
|
||||||
max_duration: config.max_duration,
|
max_duration: config.max_duration,
|
||||||
max_objects: config.max_objects,
|
max_objects: config.max_objects,
|
||||||
max_directories: config.max_directories,
|
max_directories: config.max_directories,
|
||||||
track_progress,
|
track_progress,
|
||||||
track_unbounded_counts,
|
|
||||||
objects_scanned: AtomicU64::new(0),
|
objects_scanned: AtomicU64::new(0),
|
||||||
directories_started: AtomicU64::new(0),
|
directories_started: AtomicU64::new(0),
|
||||||
entries_visited: AtomicU64::new(0),
|
entries_visited: AtomicU64::new(0),
|
||||||
last_progress_millis: AtomicU64::new(0),
|
|
||||||
cycle_state_persisted: AtomicBool::new(false),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,14 +131,6 @@ impl ScannerCycleBudget {
|
|||||||
self.max_duration
|
self.max_duration
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn deadline(&self) -> Option<Instant> {
|
|
||||||
self.deadline
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn cancel_for_runtime(&self) {
|
|
||||||
self.cancel_for(ScannerCycleBudgetReason::Runtime);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn max_objects(&self) -> Option<u64> {
|
pub(crate) fn max_objects(&self) -> Option<u64> {
|
||||||
self.max_objects
|
self.max_objects
|
||||||
}
|
}
|
||||||
@@ -206,43 +173,15 @@ impl ScannerCycleBudget {
|
|||||||
self.entries_visited.load(Ordering::Relaxed)
|
self.entries_visited.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_cycle_state_persisted(&self) {
|
|
||||||
self.cycle_state_persisted.store(true, Ordering::Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn cycle_state_persisted(&self) -> bool {
|
|
||||||
self.cycle_state_persisted.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn progress_age(&self) -> Duration {
|
|
||||||
let elapsed_millis = u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
|
||||||
let last_progress = self.last_progress_millis.load(Ordering::Relaxed);
|
|
||||||
Duration::from_millis(elapsed_millis.saturating_sub(last_progress))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_progress_sample(&self, event: u64) {
|
|
||||||
// Clock reads are sampled at batch/count boundaries; the scanner's
|
|
||||||
// per-object path does not add a second progress atomic.
|
|
||||||
if event == 0 || (event != 1 && !event.is_multiple_of(PROGRESS_CLOCK_SAMPLE_INTERVAL)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let elapsed_millis = u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
|
||||||
self.last_progress_millis.store(elapsed_millis, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn record_entries_visited(&self, entries_visited: u64) {
|
pub(crate) fn record_entries_visited(&self, entries_visited: u64) {
|
||||||
if self.track_progress {
|
if self.track_progress {
|
||||||
let entries = saturating_fetch_add(&self.entries_visited, entries_visited);
|
saturating_fetch_add(&self.entries_visited, entries_visited);
|
||||||
self.record_progress_sample(entries);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn record_remote_progress(&self, objects_scanned: u64, directories_started: u64) {
|
pub(crate) fn record_remote_progress(&self, objects_scanned: u64, directories_started: u64) {
|
||||||
if self.track_progress || self.max_objects.is_some() {
|
if self.track_progress || self.max_objects.is_some() {
|
||||||
let objects = saturating_fetch_add(&self.objects_scanned, objects_scanned);
|
let objects = saturating_fetch_add(&self.objects_scanned, objects_scanned);
|
||||||
if self.track_progress {
|
|
||||||
self.record_progress_sample(objects);
|
|
||||||
}
|
|
||||||
if self.max_objects.is_some_and(|max_objects| objects >= max_objects) {
|
if self.max_objects.is_some_and(|max_objects| objects >= max_objects) {
|
||||||
self.cancel_for(ScannerCycleBudgetReason::Objects);
|
self.cancel_for(ScannerCycleBudgetReason::Objects);
|
||||||
}
|
}
|
||||||
@@ -250,12 +189,9 @@ impl ScannerCycleBudget {
|
|||||||
|
|
||||||
if self.track_progress || self.max_directories.is_some() {
|
if self.track_progress || self.max_directories.is_some() {
|
||||||
let directories = saturating_fetch_add(&self.directories_started, directories_started);
|
let directories = saturating_fetch_add(&self.directories_started, directories_started);
|
||||||
if self.track_progress {
|
|
||||||
self.record_progress_sample(directories);
|
|
||||||
}
|
|
||||||
if self
|
if self
|
||||||
.max_directories
|
.max_directories
|
||||||
.is_some_and(|max_directories| directory_budget_exhausted(directories, max_directories))
|
.is_some_and(|max_directories| directories > max_directories)
|
||||||
{
|
{
|
||||||
self.cancel_for(ScannerCycleBudgetReason::Directories);
|
self.cancel_for(ScannerCycleBudgetReason::Directories);
|
||||||
}
|
}
|
||||||
@@ -271,17 +207,14 @@ impl ScannerCycleBudget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn try_start_directory(&self) -> bool {
|
pub(crate) fn try_start_directory(&self) -> bool {
|
||||||
if self.max_directories.is_none() && !self.track_unbounded_counts {
|
if !self.track_progress && self.max_directories.is_none() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let directories = saturating_fetch_add(&self.directories_started, 1);
|
let directories = saturating_fetch_add(&self.directories_started, 1);
|
||||||
if self.track_progress {
|
|
||||||
self.record_progress_sample(directories);
|
|
||||||
}
|
|
||||||
if self
|
if self
|
||||||
.max_directories
|
.max_directories
|
||||||
.is_some_and(|max_directories| directory_budget_exhausted(directories, max_directories))
|
.is_some_and(|max_directories| directories > max_directories)
|
||||||
{
|
{
|
||||||
self.cancel_for(ScannerCycleBudgetReason::Directories);
|
self.cancel_for(ScannerCycleBudgetReason::Directories);
|
||||||
return false;
|
return false;
|
||||||
@@ -291,14 +224,11 @@ impl ScannerCycleBudget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn record_object_scanned(&self) {
|
pub(crate) fn record_object_scanned(&self) {
|
||||||
if self.max_objects.is_none() && !self.track_unbounded_counts {
|
if !self.track_progress && self.max_objects.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let objects = saturating_fetch_add(&self.objects_scanned, 1);
|
let objects = saturating_fetch_add(&self.objects_scanned, 1);
|
||||||
if self.track_progress {
|
|
||||||
self.record_progress_sample(objects);
|
|
||||||
}
|
|
||||||
if self.max_objects.is_some_and(|max_objects| objects >= max_objects) {
|
if self.max_objects.is_some_and(|max_objects| objects >= max_objects) {
|
||||||
self.cancel_for(ScannerCycleBudgetReason::Objects);
|
self.cancel_for(ScannerCycleBudgetReason::Objects);
|
||||||
}
|
}
|
||||||
@@ -329,13 +259,6 @@ fn saturating_fetch_add(value: &AtomicU64, delta: u64) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn directory_budget_exhausted(directories: u64, max_directories: u64) -> bool {
|
|
||||||
// Saturation hides a remote max+1 update when the configured limit is the
|
|
||||||
// largest representable counter. Treat that boundary as exhausted rather
|
|
||||||
// than allowing work to continue indefinitely.
|
|
||||||
directories > max_directories || (directories == u64::MAX && max_directories == u64::MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for ScannerCycleBudget {
|
impl Drop for ScannerCycleBudget {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.token.cancel();
|
self.token.cancel();
|
||||||
@@ -478,35 +401,6 @@ mod tests {
|
|||||||
assert_eq!(directory_budget.reason(), Some(ScannerCycleBudgetReason::Directories));
|
assert_eq!(directory_budget.reason(), Some(ScannerCycleBudgetReason::Directories));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn directory_budget_fails_closed_when_progress_saturates() {
|
|
||||||
let parent = CancellationToken::new();
|
|
||||||
let budget = ScannerCycleBudget::new(
|
|
||||||
&parent,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_directories: Some(u64::MAX),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
budget.record_remote_progress(0, u64::MAX);
|
|
||||||
|
|
||||||
assert_eq!(budget.reason(), Some(ScannerCycleBudgetReason::Directories));
|
|
||||||
assert!(budget.token().is_cancelled());
|
|
||||||
|
|
||||||
let local_budget = ScannerCycleBudget::new(
|
|
||||||
&parent,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_directories: Some(u64::MAX),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
local_budget.record_remote_progress(0, u64::MAX - 1);
|
|
||||||
assert!(!local_budget.budget_elapsed());
|
|
||||||
assert!(!local_budget.try_start_directory());
|
|
||||||
assert_eq!(local_budget.reason(), Some(ScannerCycleBudgetReason::Directories));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_progress_tracking_counts_unbounded_remote_work_without_cancelling() {
|
fn explicit_progress_tracking_counts_unbounded_remote_work_without_cancelling() {
|
||||||
let parent = CancellationToken::new();
|
let parent = CancellationToken::new();
|
||||||
@@ -567,29 +461,4 @@ mod tests {
|
|||||||
assert!(object_limited.requires_serial_progress_accounting());
|
assert!(object_limited.requires_serial_progress_accounting());
|
||||||
assert!(directory_limited.requires_serial_progress_accounting());
|
assert!(directory_limited.requires_serial_progress_accounting());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn progress_age_uses_virtual_time_and_sampled_progress() {
|
|
||||||
let parent = CancellationToken::new();
|
|
||||||
let budget = ScannerCycleBudget::new_with_runtime_progress_tracking(
|
|
||||||
&parent,
|
|
||||||
ScannerCycleBudgetConfig {
|
|
||||||
max_duration: Some(Duration::from_secs(60)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
tokio::time::advance(Duration::from_secs(5)).await;
|
|
||||||
assert_eq!(budget.progress_age(), Duration::from_secs(5));
|
|
||||||
budget.record_entries_visited(1);
|
|
||||||
assert_eq!(budget.progress_age(), Duration::ZERO);
|
|
||||||
|
|
||||||
tokio::time::advance(Duration::from_secs(2)).await;
|
|
||||||
for _ in 0..126 {
|
|
||||||
budget.record_entries_visited(1);
|
|
||||||
}
|
|
||||||
assert_eq!(budget.progress_age(), Duration::from_secs(2));
|
|
||||||
budget.record_entries_visited(1);
|
|
||||||
assert_eq!(budget.progress_age(), Duration::ZERO);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ use time::OffsetDateTime;
|
|||||||
use tokio::sync::{Mutex, Notify, Semaphore, mpsc};
|
use tokio::sync::{Mutex, Notify, Semaphore, mpsc};
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::AbortOnDropHandle;
|
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
use crate::ScannerObjectInfo as ObjectInfo;
|
use crate::ScannerObjectInfo as ObjectInfo;
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ impl ScannerIOCache for SetDisks {
|
|||||||
let ctx_clone = ctx.clone();
|
let ctx_clone = ctx.clone();
|
||||||
let completed_bucket_count = Arc::new(AtomicUsize::new(0));
|
let completed_bucket_count = Arc::new(AtomicUsize::new(0));
|
||||||
let completed_bucket_count_clone = completed_bucket_count.clone();
|
let completed_bucket_count_clone = completed_bucket_count.clone();
|
||||||
let collect_bucket_results_fut = AbortOnDropHandle::new(tokio::spawn(async move {
|
let collect_bucket_results_fut = tokio::spawn(async move {
|
||||||
let mut cancelled = false;
|
let mut cancelled = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -333,7 +333,7 @@ impl ScannerIOCache for SetDisks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
});
|
||||||
|
|
||||||
let mut futs = Vec::new();
|
let mut futs = Vec::new();
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ impl ScannerIOCache for SetDisks {
|
|||||||
NamespaceScannerWorkerMode::RemoteV4(server_epoch) => Some(server_epoch),
|
NamespaceScannerWorkerMode::RemoteV4(server_epoch) => Some(server_epoch),
|
||||||
NamespaceScannerWorkerMode::Coordinator => None,
|
NamespaceScannerWorkerMode::Coordinator => None,
|
||||||
};
|
};
|
||||||
futs.push(AbortOnDropHandle::new(tokio::spawn(async move {
|
futs.push(tokio::spawn(async move {
|
||||||
let remote_session_id = uuid::Uuid::new_v4();
|
let remote_session_id = uuid::Uuid::new_v4();
|
||||||
let mut remote_session_sequence = 0_u64;
|
let mut remote_session_sequence = 0_u64;
|
||||||
loop {
|
loop {
|
||||||
@@ -1038,7 +1038,7 @@ impl ScannerIOCache for SetDisks {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})));
|
}));
|
||||||
}
|
}
|
||||||
drop(bucket_tx);
|
drop(bucket_tx);
|
||||||
drop(bucket_result_tx);
|
drop(bucket_result_tx);
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ impl ScannerIOCycle for ECStore {
|
|||||||
results[results_index_clone] = result;
|
results[results_index_clone] = result;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
wait_futs.push(AbortOnDropHandle::new(receiver_fut));
|
wait_futs.push(receiver_fut);
|
||||||
|
|
||||||
let scan_plan = ScannerBucketScanPlan {
|
let scan_plan = ScannerBucketScanPlan {
|
||||||
buckets: set_buckets,
|
buckets: set_buckets,
|
||||||
@@ -318,7 +318,7 @@ impl ScannerIOCycle for ECStore {
|
|||||||
record_set_scan_failure(&mut first_err, e);
|
record_set_scan_failure(&mut first_err, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
wait_futs.push(AbortOnDropHandle::new(scanner_fut));
|
wait_futs.push(scanner_fut);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use rustfs_data_usage::{ReplicationAllStats, ReplicationTargetUsage};
|
use rustfs_data_usage::{ReplicationAllStats, ReplicationStats};
|
||||||
|
|
||||||
const TEST_PLAN_DIGEST: DataUsageScanPlanDigest = DataUsageScanPlanDigest([7; 32]);
|
const TEST_PLAN_DIGEST: DataUsageScanPlanDigest = DataUsageScanPlanDigest([7; 32]);
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ fn completed_data_usage_info_flattens_nested_bucket_entries() {
|
|||||||
replication_stats: Some(ReplicationAllStats {
|
replication_stats: Some(ReplicationAllStats {
|
||||||
targets: HashMap::from([(
|
targets: HashMap::from([(
|
||||||
"arn:target".to_string(),
|
"arn:target".to_string(),
|
||||||
ReplicationTargetUsage {
|
ReplicationStats {
|
||||||
replicated_size: 2048,
|
replicated_size: 2048,
|
||||||
replicated_count: 2,
|
replicated_count: 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -245,18 +245,6 @@ impl TestECStoreEnvBuilder {
|
|||||||
.await
|
.await
|
||||||
.expect("build test ECStore");
|
.expect("build test ECStore");
|
||||||
|
|
||||||
// The production bootstrap only persists pool.bin from the elected
|
|
||||||
// first cluster node. Test stores intentionally have no cluster
|
|
||||||
// election, but heal-format still requires that durable fence before
|
|
||||||
// it can write any disk format. Materialize the validated topology
|
|
||||||
// here so the shared fixture models a ready single-node store.
|
|
||||||
let mut pool_meta = ecstore.pool_meta.read().await.clone();
|
|
||||||
pool_meta.dont_save = false;
|
|
||||||
pool_meta
|
|
||||||
.save(ecstore.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("persist test pool metadata");
|
|
||||||
|
|
||||||
if self.init_bucket_metadata {
|
if self.init_bucket_metadata {
|
||||||
let buckets_list = ecstore
|
let buckets_list = ecstore
|
||||||
.list_bucket(&BucketOptions {
|
.list_bucket(&BucketOptions {
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ where
|
|||||||
.parse::<T>()
|
.parse::<T>()
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
log_once(&format!("env_invalid_value:{used_key}"), || {
|
log_once(&format!("env_invalid_value:{used_key}"), || {
|
||||||
format!("Invalid {} value for {used_key}. Treating as unset.", type_name::<T>())
|
format!("Invalid {} value for {used_key}: {value}. Treating as unset.", type_name::<T>())
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
@@ -570,7 +570,7 @@ where
|
|||||||
Ok(parsed) => EnvParseOutcome::Parsed(parsed),
|
Ok(parsed) => EnvParseOutcome::Parsed(parsed),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
log_once(&format!("env_invalid_value:{used_key}"), || {
|
log_once(&format!("env_invalid_value:{used_key}"), || {
|
||||||
format!("Invalid {} value for {used_key}. Treating as unset.", type_name::<T>())
|
format!("Invalid {} value for {used_key}: {value}. Treating as unset.", type_name::<T>())
|
||||||
});
|
});
|
||||||
EnvParseOutcome::Invalid
|
EnvParseOutcome::Invalid
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ for later deletion.
|
|||||||
|
|
||||||
## Open Items
|
## Open Items
|
||||||
|
|
||||||
- `rustfs-6339` legacy bucket policy ID casing: earlier RustFS releases persisted the top-level policy identifier as "ID", while current writes use the S3-compatible "Id" spelling. Readers accept both spellings so retained bucket metadata remains usable after upgrade. Remove the legacy alias after migration tooling has rewritten every retained bucket policy using "ID".
|
|
||||||
- `table-publication-fence-v1` table publication fencing: nodes that predate table and table-bucket publication fences can mutate live files while a new node is publishing a catalog pointer. New nodes retain exact object guards until the operator confirms that every serving node uses the new fences. Fleet confirmation also requires non-overlapping active warehouse prefixes and lifecycle workers that exclude table buckets. Remove the exact live-file fallback and the fleet-confirmation gate after the minimum supported RustFS release acquires table fences for registered-table mutations and table-bucket fences for unresolved-prefix mutations.
|
- `table-publication-fence-v1` table publication fencing: nodes that predate table and table-bucket publication fences can mutate live files while a new node is publishing a catalog pointer. New nodes retain exact object guards until the operator confirms that every serving node uses the new fences. Fleet confirmation also requires non-overlapping active warehouse prefixes and lifecycle workers that exclude table buckets. Remove the exact live-file fallback and the fleet-confirmation gate after the minimum supported RustFS release acquires table fences for registered-table mutations and table-bucket fences for unresolved-prefix mutations.
|
||||||
- `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2.
|
- `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2.
|
||||||
- `table-catalog-migration-fence-v1` durable strong migration fence compatibility: version 1 "PREPARING" fences did not distinguish a known-absent global strong snapshot from an unknown baseline, so retries read them but fail closed if the global snapshot is missing. Version 2 preserves the same JSON shape and records the pre-migration global snapshot ETag in the existing target_snapshot_etag field while the fence is "PREPARING". Remove version 1 reads after every supported direct-upgrade source writes version 2 fences and operators have completed or cancelled every older in-progress backing migration.
|
- `table-catalog-migration-fence-v1` durable strong migration fence compatibility: version 1 "PREPARING" fences did not distinguish a known-absent global strong snapshot from an unknown baseline, so retries read them but fail closed if the global snapshot is missing. Version 2 preserves the same JSON shape and records the pre-migration global snapshot ETag in the existing target_snapshot_etag field while the fence is "PREPARING". Remove version 1 reads after every supported direct-upgrade source writes version 2 fences and operators have completed or cancelled every older in-progress backing migration.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The `/v3/scanner/status` response reports each effective runtime value with a
|
|||||||
| `scanner.max_wait` | `RUSTFS_SCANNER_MAX_WAIT_SECS` | seconds | preset-derived | Caps one scanner sleep. |
|
| `scanner.max_wait` | `RUSTFS_SCANNER_MAX_WAIT_SECS` | seconds | preset-derived | Caps one scanner sleep. |
|
||||||
| `scanner.cycle` | `RUSTFS_SCANNER_CYCLE` | seconds | preset-derived | Sets the interval between scanner cycles. |
|
| `scanner.cycle` | `RUSTFS_SCANNER_CYCLE` | seconds | preset-derived | Sets the interval between scanner cycles. |
|
||||||
| `scanner.start_delay` | `RUSTFS_SCANNER_START_DELAY_SECS` | seconds | unset | Sets startup delay and, for compatibility, the cycle interval when `scanner.cycle` is unset. |
|
| `scanner.start_delay` | `RUSTFS_SCANNER_START_DELAY_SECS` | seconds | unset | Sets startup delay and, for compatibility, the cycle interval when `scanner.cycle` is unset. |
|
||||||
| `scanner.cycle_max_duration` | `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` | seconds | `1800` | Caps one cycle's runtime. An explicit `0` disables this budget. |
|
| `scanner.cycle_max_duration` | `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` | seconds | `0` | Caps one cycle's runtime. `0` disables this budget. |
|
||||||
| `scanner.cycle_max_objects` | `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` | objects | `0` | Caps objects processed by one cycle. `0` disables this budget. |
|
| `scanner.cycle_max_objects` | `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` | objects | `0` | Caps objects processed by one cycle. `0` disables this budget. |
|
||||||
| `scanner.cycle_max_directories` | `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` | directories | `0` | Caps directories entered by one cycle. `0` disables this budget. |
|
| `scanner.cycle_max_directories` | `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` | directories | `0` | Caps directories entered by one cycle. `0` disables this budget. |
|
||||||
| `heal.bitrot_cycle` | `RUSTFS_SCANNER_BITROT_CYCLE_SECS` | seconds | `2592000` | Controls periodic deep bitrot scans. `false`, `off`, `no`, or `disabled` disables periodic deep scans; `0`, `true`, `on`, or `yes` runs deep mode every scanner cycle. |
|
| `heal.bitrot_cycle` | `RUSTFS_SCANNER_BITROT_CYCLE_SECS` | seconds | `2592000` | Controls periodic deep bitrot scans. `false`, `off`, `no`, or `disabled` disables periodic deep scans; `0`, `true`, `on`, or `yes` runs deep mode every scanner cycle. |
|
||||||
@@ -70,21 +70,6 @@ sleep multiplier, maximum wait, and cycle interval. Use `scanner.delay`,
|
|||||||
`scanner.max_wait`, and `scanner.cycle` when the preset is close but one axis
|
`scanner.max_wait`, and `scanner.cycle` when the preset is close but one axis
|
||||||
needs a precise override.
|
needs a precise override.
|
||||||
|
|
||||||
When the cycle duration control is unset, RustFS uses a finite 1800-second
|
|
||||||
(30-minute) default, matching the scanner benchmark guidance. An explicit `0`
|
|
||||||
preserves the compatibility behavior of an unbounded cycle; object and
|
|
||||||
directory budgets likewise remain unbounded when explicitly set to `0`. Invalid
|
|
||||||
or overflowing duration environment values are configuration errors rather than
|
|
||||||
silent fallback values.
|
|
||||||
|
|
||||||
When a finite deadline expires, RustFS cancels cooperative scanner work and
|
|
||||||
waits only for the existing bounded shutdown window. A non-yielding I/O future
|
|
||||||
is dropped after that window. RustFS then attempts a higher leadership epoch so
|
|
||||||
late cycle, usage, cache, and remote writes from the old generation fail closed.
|
|
||||||
If the worker cannot stop cooperatively, the cycle state was not confirmed
|
|
||||||
durable, or that epoch fence cannot be durably persisted, the scanner reports
|
|
||||||
`recovery-required`; it does not claim an uncooperative cursor was saved.
|
|
||||||
|
|
||||||
An explicit `scanner.cycle` or `RUSTFS_SCANNER_CYCLE` is a minimum inter-cycle
|
An explicit `scanner.cycle` or `RUSTFS_SCANNER_CYCLE` is a minimum inter-cycle
|
||||||
cadence: dirty-usage notifications do not bypass that configured interval.
|
cadence: dirty-usage notifications do not bypass that configured interval.
|
||||||
The default adaptive policy continues to use dirty-usage notifications to wake
|
The default adaptive policy continues to use dirty-usage notifications to wake
|
||||||
@@ -159,10 +144,6 @@ metrics.maintenance_control.primary_control
|
|||||||
metrics.source_work
|
metrics.source_work
|
||||||
metrics.replication_repair
|
metrics.replication_repair
|
||||||
metrics.scan_checkpoint
|
metrics.scan_checkpoint
|
||||||
metrics.cycle_timeout_total
|
|
||||||
metrics.cycle_last_progress_age
|
|
||||||
metrics.leader_lease_without_progress
|
|
||||||
metrics.cycle_recovery_required_total
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reading Pacing Pressure
|
## Reading Pacing Pressure
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
| heal_erasure_disk_rebuild_test | 4 | 🌙 |
|
| heal_erasure_disk_rebuild_test | 4 | 🌙 |
|
||||||
| inline_fast_path_cluster_test | 16 | |
|
| inline_fast_path_cluster_test | 16 | |
|
||||||
| internode_rpc_signature_e2e_test | 5 | |
|
| internode_rpc_signature_e2e_test | 5 | |
|
||||||
| kms | 46 | |
|
| kms | 48 | |
|
||||||
| leading_slash_key_test | 2 | ✅ |
|
| leading_slash_key_test | 2 | ✅ |
|
||||||
| lifecycle_regression_test | 4 | |
|
| lifecycle_regression_test | 4 | |
|
||||||
| list_buckets_auth_test | 1 | ✅ |
|
| list_buckets_auth_test | 1 | ✅ |
|
||||||
@@ -99,4 +99,4 @@
|
|||||||
| tls_hot_reload_test | 1 | ✅ |
|
| tls_hot_reload_test | 1 | ✅ |
|
||||||
| version_id_regression_test | 10 | ✅ |
|
| version_id_regression_test | 10 | ✅ |
|
||||||
|
|
||||||
**Total listed: 575 tests across 82 modules · PR smoke: 163 tests / 36 modules · merge/main full: 453 tests / 73 modules · nightly replication: 55 tests · nightly cluster faults: 28 tests / 7 modules · nightly protocols: 16 tests** · updated 2026-08-23.
|
**Total listed: 577 tests across 82 modules · PR smoke: 163 tests / 36 modules · merge/main full: 455 tests / 73 modules · nightly replication: 55 tests · nightly cluster faults: 28 tests / 7 modules · nightly protocols: 16 tests** · updated 2026-08-21.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "heartbeat",
|
"name": "heartbeat",
|
||||||
"status": "populated",
|
"status": "reserved",
|
||||||
"purpose": "Heartbeat payloads, Connect receive time, and freshness window behavior."
|
"purpose": "Heartbeat payloads, Connect receive time, and freshness window behavior."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
975c1ca53eefeef6766a6fc0b3d3281f7408255342b0686e5e2aee5ad055414c duplicate.json
|
|
||||||
963529a38a02849c6c2acc6d72668dca9f63218b49c89fae41a451b584850411 overflow.json
|
|
||||||
e3adeee1c8a19aa17e70894896fb79c072e3785bea3611b93c11e79f039ed5af stale.json
|
|
||||||
35b9cebd8525389a701e8fe69fbe96407bcb31aa28392fe95babf4a4886985ad unknown.json
|
|
||||||
37941735dbd6ad3d238258a7b2cae6f0b3aa0ecaae1d8817817c3d718d11d633 valid.json
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user