mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 21:25:59 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a8e476f7 | |||
| 8fc1f0c41d |
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=845feb5859c4063c38307ada8f263f4039ebaf54c510bbc9177c4ab0dba2d8a9
|
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
|
||||||
sha256-linux=22320a04e541ef27cf1d0df3670ab3fafb62c57beef9a57eaff895e84a1e8380
|
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=364f2329a7b72eb9f1608dbe1a3af37af4095354014f3cbe23ca448492d89961
|
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
||||||
sha256-linux=60983f1ebe7068cf660d473c5f76c76a650410ccc99d71934ddca7fd67607987
|
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=0e338d305260229e17ccfb2adc48a6212dbdfea36a9ebfb5a4e0d38658e6cc45
|
sha256=0fe8408874ccec3620262a9812d67920ddd72dc9edf0e36e0d0aed3f8bad026e
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ jobs:
|
|||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
argv = ["python3", "scripts/e2e_binary.py", "build", "--bins", "--features", "e2e-test-hooks"]
|
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
|
||||||
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
||||||
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
||||||
if not clean_before:
|
if not clean_before:
|
||||||
@@ -634,7 +634,6 @@ jobs:
|
|||||||
name: rustfs-debug-binary
|
name: rustfs-debug-binary
|
||||||
path: |
|
path: |
|
||||||
target/debug/rustfs
|
target/debug/rustfs
|
||||||
target/debug/rustfs.e2e.json
|
|
||||||
target/debug/rustfs.e2e-startup-cas-build.json
|
target/debug/rustfs.e2e-startup-cas-build.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
@@ -667,15 +666,13 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary with rio-v2
|
- name: Build debug binary with rio-v2
|
||||||
run: python3 scripts/e2e_binary.py build --bins --features rio-v2,e2e-test-hooks
|
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-debug-binary-rio-v2
|
name: rustfs-debug-binary-rio-v2
|
||||||
path: |
|
path: target/debug/rustfs
|
||||||
target/debug/rustfs
|
|
||||||
target/debug/rustfs.e2e.json
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -824,7 +821,7 @@ jobs:
|
|||||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||||
--status-level all --final-status-level all --failure-output final
|
--status-level all --final-status-level all --failure-output final
|
||||||
|
|
||||||
- name: Upload e2e smoke diagnostics
|
- name: Upload e2e smoke diagnostics
|
||||||
@@ -860,7 +857,7 @@ jobs:
|
|||||||
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||||
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
||||||
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -988,7 +985,6 @@ jobs:
|
|||||||
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
||||||
raise SystemExit("downloaded hooks binary identity mismatch")
|
raise SystemExit("downloaded hooks binary identity mismatch")
|
||||||
shutil.copy2(manifest_path, target / manifest_path.name)
|
shutil.copy2(manifest_path, target / manifest_path.name)
|
||||||
shutil.copy2(source.with_name("rustfs.e2e.json"), target / "rustfs.e2e.json")
|
|
||||||
binary.chmod(0o755)
|
binary.chmod(0o755)
|
||||||
PYINPUT
|
PYINPUT
|
||||||
|
|
||||||
@@ -1008,7 +1004,7 @@ jobs:
|
|||||||
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||||
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||||
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||||
run: python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||||
|
|
||||||
- name: Upload junit
|
- name: Upload junit
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1080,7 +1076,7 @@ jobs:
|
|||||||
- name: Run end-to-end tests
|
- name: Run end-to-end tests
|
||||||
run: |
|
run: |
|
||||||
s3s-e2e --version
|
s3s-e2e --version
|
||||||
python3 scripts/e2e_binary.py run --features rio-v2,e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -1123,7 +1119,7 @@ jobs:
|
|||||||
S3_PORT="${S3_PORT}" \
|
S3_PORT="${S3_PORT}" \
|
||||||
DATA_ROOT="${RUN_ROOT}" \
|
DATA_ROOT="${RUN_ROOT}" \
|
||||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Upload s3 test artifacts
|
- name: Upload s3 test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1205,7 +1201,7 @@ jobs:
|
|||||||
S3_PORT="${S3_PORT}" \
|
S3_PORT="${S3_PORT}" \
|
||||||
DATA_ROOT="${RUN_ROOT}" \
|
DATA_ROOT="${RUN_ROOT}" \
|
||||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Upload s3 test artifacts
|
- name: Upload s3 test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -151,7 +151,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py build --bins
|
cargo build -p rustfs --bins
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Verify distributed e2e membership
|
- name: Verify distributed e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -167,9 +168,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -n "${FILTER}" ]; then
|
if [ -n "${FILTER}" ]; then
|
||||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||||
else
|
else
|
||||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload distributed e2e diagnostics
|
- name: Upload distributed e2e diagnostics
|
||||||
|
|||||||
@@ -89,10 +89,14 @@ jobs:
|
|||||||
- name: Verify awscurl
|
- name: Verify awscurl
|
||||||
run: test -x "$AWSCURL_PATH"
|
run: test -x "$AWSCURL_PATH"
|
||||||
|
|
||||||
# Build once and carry its source/binary identity into the test invocation.
|
# Build the rustfs binary once up front. The e2e tests spawn it as a
|
||||||
|
# child process (crates/e2e_test/src/common.rs) and will build it on
|
||||||
|
# demand otherwise, but a single explicit build avoids several parallel
|
||||||
|
# nextest test processes racing to build it at once.
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py build --bins
|
cargo build -p rustfs --bins
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Verify replication e2e membership
|
- name: Verify replication e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -104,7 +108,7 @@ jobs:
|
|||||||
- name: Run replication e2e nightly suite
|
- name: Run replication e2e nightly suite
|
||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload nextest junit report
|
- name: Upload nextest junit report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -140,7 +144,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Verify cluster fault e2e membership
|
- name: Verify cluster fault e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -152,7 +157,7 @@ jobs:
|
|||||||
- name: Run cluster fault e2e nightly suite
|
- name: Run cluster fault e2e nightly suite
|
||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||||
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
run: cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload cluster fault diagnostics
|
- name: Upload cluster fault diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
@@ -193,9 +198,6 @@ jobs:
|
|||||||
sudo apt-get install -y -qq iproute2
|
sudo apt-get install -y -qq iproute2
|
||||||
ss -tn state CLOSE-WAIT >/dev/null
|
ss -tn state CLOSE-WAIT >/dev/null
|
||||||
|
|
||||||
- name: Build protocol server
|
|
||||||
run: python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"
|
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -208,7 +210,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||||
run: >-
|
run: >-
|
||||||
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||||
|
|
||||||
- name: Upload protocol diagnostics
|
- name: Upload protocol diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -125,11 +125,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Build current RustFS binary
|
- name: Build current RustFS binary
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py build
|
cargo build --locked -p rustfs --bin rustfs
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Run upgrade compatibility test
|
- name: Run upgrade compatibility test
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
cargo test --locked -p e2e_test \
|
||||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||||
-- --ignored --exact --nocapture
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||||
|
|
||||||
- name: Build the RustFS binary under test
|
- name: Build the RustFS binary under test
|
||||||
run: python3 scripts/e2e_binary.py build --bins
|
run: cargo build --locked -p rustfs --bins
|
||||||
|
|
||||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
- name: Run the interop cases against MinIO
|
- name: Run the interop cases against MinIO
|
||||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||||
|
|
||||||
- name: Build the MinIO interop report
|
- name: Build the MinIO interop report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -251,7 +251,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the RustFS binary under test
|
- name: Build the RustFS binary under test
|
||||||
if: steps.credentials.outputs.present == 'true'
|
if: steps.credentials.outputs.present == 'true'
|
||||||
run: python3 scripts/e2e_binary.py build --bins
|
run: cargo build --locked -p rustfs --bins
|
||||||
|
|
||||||
# A filterset that matches nothing is valid, so the count is asserted
|
# A filterset that matches nothing is valid, so the count is asserted
|
||||||
# rather than inferred from a green run.
|
# rather than inferred from a green run.
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
- name: Run the three-case minimum
|
- name: Run the three-case minimum
|
||||||
if: steps.credentials.outputs.present == 'true'
|
if: steps.credentials.outputs.present == 'true'
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||||
|
|
||||||
- name: Build the ${{ matrix.provider }} interop report
|
- name: Build the ${{ matrix.provider }} interop report
|
||||||
|
|||||||
@@ -82,12 +82,6 @@ jobs:
|
|||||||
performance-test:
|
performance-test:
|
||||||
runs-on: pf-testing
|
runs-on: pf-testing
|
||||||
timeout-minutes: 900
|
timeout-minutes: 900
|
||||||
env:
|
|
||||||
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
|
|
||||||
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
|
|
||||||
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
|
|
||||||
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
|
|
||||||
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
|
|
||||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||||
# Skipped when nightly failed.
|
# Skipped when nightly failed.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
@@ -164,15 +158,19 @@ jobs:
|
|||||||
- name: Run benchmark (GET/PUT/MIXED)
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
id: benchmark
|
id: benchmark
|
||||||
run: |
|
run: |
|
||||||
|
# Empty on automatic (workflow_run) runs -> full 30 rounds.
|
||||||
|
# Manual dispatch can restrict method(s)/size(s).
|
||||||
|
export WARP_METHODS="${{ inputs.test_method }}"
|
||||||
|
export WARP_SIZES="${{ inputs.object_size }}"
|
||||||
./auto-testing/rustfs_performance_test.sh \
|
./auto-testing/rustfs_performance_test.sh \
|
||||||
--step 5 -y \
|
--step 5 -y \
|
||||||
|
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||||
|
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||||
--log-file "${LOG_FILE}"
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Analyze results
|
- name: Analyze results
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
run: |
|
run: |
|
||||||
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
|
|
||||||
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
|
|
||||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Collect RustFS version info
|
- name: Collect RustFS version info
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ docs
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
!docs/
|
!docs/
|
||||||
docs/*
|
docs/*
|
||||||
!docs/README.md
|
|
||||||
!docs/architecture/
|
!docs/architecture/
|
||||||
!docs/architecture/**
|
!docs/architecture/**
|
||||||
!docs/operations/
|
!docs/operations/
|
||||||
|
|||||||
@@ -7,22 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Replication
|
|
||||||
|
|
||||||
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
|
|
||||||
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
|
|
||||||
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
|
|
||||||
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
|
||||||
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
|
||||||
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
|
||||||
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
|
|
||||||
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
|
|
||||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||||
|
|||||||
@@ -609,36 +609,6 @@ pub const ENV_OBJECT_LOCK_RPC_TIMEOUT_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_TIMEOUT
|
|||||||
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
||||||
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
||||||
|
|
||||||
/// Environment variable for the minimum interval between evictions of the
|
|
||||||
/// cached lock RPC channel to one peer, in milliseconds.
|
|
||||||
///
|
|
||||||
/// A lock RPC that fails on transport, or that times out while the peer has
|
|
||||||
/// not completed any lock RPC for two deadlines, evicts the shared HTTP/2
|
|
||||||
/// channel so the next request re-dials. Evictions are rate limited per peer
|
|
||||||
/// so one slow lock endpoint cannot drive a reset/GOAWAY/reconnect loop
|
|
||||||
/// (issue #7363). `0` disables the cooldown.
|
|
||||||
///
|
|
||||||
/// Default: 5000 milliseconds.
|
|
||||||
pub const ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS";
|
|
||||||
|
|
||||||
/// Default minimum interval between lock RPC channel evictions per peer: 5000 milliseconds.
|
|
||||||
pub const DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: u64 = 5000;
|
|
||||||
|
|
||||||
/// Environment variable for how many timed-out lock RPCs per peer may keep
|
|
||||||
/// running in the background instead of being cancelled.
|
|
||||||
///
|
|
||||||
/// Cancelling a timed-out stream sends `RST_STREAM`; enough of them make the
|
|
||||||
/// peer answer `GOAWAY too_many_resets` and drop every stream on the
|
|
||||||
/// connection. A detached RPC ends on its own within the internode RPC
|
|
||||||
/// timeout, and a lock it acquires after its caller gave up is released
|
|
||||||
/// immediately. Beyond this budget timed-out RPCs are cancelled as before.
|
|
||||||
///
|
|
||||||
/// Default: 256.
|
|
||||||
pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT";
|
|
||||||
|
|
||||||
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
|
|
||||||
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
|
|
||||||
|
|
||||||
/// Environment variable to enable object namespace lock diagnostics.
|
/// Environment variable to enable object namespace lock diagnostics.
|
||||||
///
|
///
|
||||||
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
||||||
|
|||||||
+47
-42
@@ -1,7 +1,7 @@
|
|||||||
# e2e_test
|
# e2e_test
|
||||||
|
|
||||||
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
||||||
(built and identified before the test invocation) and drives it over the network with the
|
(built on demand from the workspace) and drives it over the network with the
|
||||||
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
||||||
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
||||||
end-to-end behavior lives here, unit behavior stays in the source crates
|
end-to-end behavior lives here, unit behavior stays in the source crates
|
||||||
@@ -32,28 +32,32 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
All commands assume repo root and Python 3.9 or newer on Linux or macOS. Build the server once through the provenance entry point, then run the test command through the same script:
|
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
||||||
|
`rustfs` binary from [`src/common.rs`](src/common.rs) (`rustfs_binary_path`) on
|
||||||
|
first use — the first invocation is slow, later ones reuse the binary.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
# Whole crate (default = ignored tests skipped)
|
||||||
|
cargo nextest run -p e2e_test
|
||||||
# Whole crate (ignored tests remain skipped)
|
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test
|
|
||||||
|
|
||||||
# One module
|
# One module
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||||
|
|
||||||
|
# PR smoke subset (see "CI smoke subset" below)
|
||||||
|
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
|
|
||||||
|
# ILM serial lane — ignored lifecycle tests, single-threaded (mirrors CI)
|
||||||
|
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||||
|
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||||
|
|
||||||
# PR smoke subset
|
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`build` records the source contents, HEAD, resolved Cargo features, profile, toolchain, and binary SHA-256 beside the executable in `rustfs.e2e.json`. `run` validates that identity before and after the command, preserves command failures, and removes its temporary run receipt on completion. The Rust harness checks that receipt before starting each server; it never compiles a server inside a test process. Source or binary changes during a run invalidate the result, even when the test command succeeds. Use an isolated worktree and keep it unchanged until the command finishes.
|
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
||||||
|
single-worker execution, feature-gated scheduling) documented in
|
||||||
The additional `--features` arguments must match between `build` and `run`; Cargo defaults remain enabled. The wrapper supplies `RUSTFS_BUILD_FEATURES` from Cargo's resolved feature list, including features enabled by `full`. Protocol helpers require a subset of that list. `CARGO_TARGET_DIR` and `--profile release` are supported. An in-workspace target directory must be Git-ignored; tracked files are always included in the source identity. `build --bins` preserves CI lanes that compile all RustFS binary targets. For a downloaded artifact, copy both the executable and its sidecar, then use `run`; do not generate a new identity for an arbitrary prebuilt binary. `CARGO_BIN_EXE_rustfs` cannot override the verified executable.
|
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
||||||
|
selects which features the spawned binary is built with; leave it unset to run
|
||||||
Each build/run holds an exclusive `rustfs.e2e.lock` marker beside the binary; concurrent wrappers fail immediately. Use a private target directory and do not run ordinary Cargo builds against it while tests are active: Cargo does not honor this marker. Interrupted runs fail and terminate their command group. After an uncatchable kill, inspect the PID recorded in a leftover marker and remove it only after confirming its owner has stopped. Embedded file symlinks are hashed through their target; embedded directory symlinks are rejected because their contents cannot be enumerated safely by this entry point.
|
every protocol entry. Use the exact profile command under
|
||||||
|
[Troubleshooting](#troubleshooting) for CI-equivalent execution.
|
||||||
The protocols suite has its own fixed-port and single-worker contract in [`src/protocols/README.md`](src/protocols/README.md). Use its command under [Troubleshooting](#troubleshooting).
|
|
||||||
|
|
||||||
### `#[ignore]` semantics
|
### `#[ignore]` semantics
|
||||||
|
|
||||||
@@ -119,7 +123,7 @@ via `create_s3_client(idx)` / `create_all_clients()`. See
|
|||||||
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
||||||
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
||||||
| `find_available_port` | Random free port (isolation primitive) |
|
| `find_available_port` | Random free port (isolation primitive) |
|
||||||
| `rustfs_binary_path` / `_with_features` | Verify this run's binary receipt and required feature subset |
|
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
|
||||||
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
||||||
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
||||||
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
||||||
@@ -185,33 +189,35 @@ the wiring source of truth. Committed test-ID digests under
|
|||||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Smoke, full, and cluster lanes share a server with fault-test hooks.
|
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
||||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
# Full single-node merge/main lane
|
||||||
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
cargo nextest run --profile e2e-full -p e2e_test
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
# Cluster fault nightly lane
|
||||||
|
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
# Distributed 4-node 4-disk lane uses the default server.
|
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
|
||||||
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
|
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
||||||
python3 scripts/e2e_binary.py build
|
cargo nextest run --profile e2e-distributed -p e2e_test
|
||||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
|
# Replication nightly lane; awscurl is required for STS paths
|
||||||
|
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# Replication nightly uses the default server; awscurl is required for STS.
|
# Fixed-port protocol nightly lane
|
||||||
python3 scripts/e2e_binary.py build
|
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
||||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||||
|
# ILM serial lane
|
||||||
# Protocol nightly owns fixed ports.
|
|
||||||
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
|
||||||
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
|
||||||
|
|
||||||
# The ILM serial lane does not use this server harness.
|
|
||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||||
|
# s3s-e2e black box
|
||||||
|
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs-e2e-data
|
||||||
```
|
```
|
||||||
|
|
||||||
The full lane also requires the startup-CAS build manifest generated by the `Build debug binary` step in `.github/workflows/ci.yml`. Preserve that binary and both sidecars as its `Preserve startup CAS binary input` step does, and use the same `RUSTFS_E2E_STARTUP_CAS_*` environment as `Run e2e full suite`. A generic local build alone does not supply that fixture evidence.
|
**Stale binary.** Tests build the `rustfs` binary once and reuse it. To avoid
|
||||||
|
rebuilding while iterating on tests, `common.rs` reuses an existing binary when
|
||||||
**Stale or unverified binary.** Re-run the matching `build` command after changing source or features, then invoke tests through `run`. A missing receipt, copied old executable, or mismatched build identity is a prerequisite failure. Bare Cargo invocations that start a server deliberately fail; unit tests that do not start a server can still run directly.
|
running *inside* the e2e test process even if sources changed
|
||||||
|
(`can_reuse_inside_e2e`, [`src/common.rs`](src/common.rs) line 98). Downside: if
|
||||||
|
you changed **server** code, force a rebuild with
|
||||||
|
`cargo build -p rustfs` (or `touch` a source file outside the reuse window)
|
||||||
|
before re-running, or CI's freshly built artifact will diverge from your local
|
||||||
|
one.
|
||||||
|
|
||||||
**Port already in use / orphan processes.** A hard-killed run can leak a
|
**Port already in use / orphan processes.** A hard-killed run can leak a
|
||||||
`rustfs` child holding its port. Find and kill it:
|
`rustfs` child holding its port. Find and kill it:
|
||||||
@@ -243,8 +249,7 @@ spawn error. Install the pinned CI version before running their profiles.
|
|||||||
A subset of this crate runs on every PR via the `e2e-tests` job:
|
A subset of this crate runs on every PR via the `e2e-tests` job:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The selection lives in `.config/nextest.toml` under `[profile.e2e-smoke]`
|
The selection lives in `.config/nextest.toml` under `[profile.e2e-smoke]`
|
||||||
|
|||||||
+147
-121
@@ -31,6 +31,7 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
use std::ffi::OsStr;
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -43,6 +44,7 @@ use tokio::net::TcpStream;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
// Common constants for all E2E tests
|
// Common constants for all E2E tests
|
||||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||||
@@ -426,75 +428,59 @@ fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&P
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the server verified by `scripts/e2e_binary.py run` for this test invocation.
|
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
||||||
/// Requested features are a required subset of the server's resolved Cargo features.
|
|
||||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||||
|
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
||||||
|
return PathBuf::from(path);
|
||||||
|
}
|
||||||
|
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
||||||
|
|
||||||
let workspace = workspace_root();
|
let workspace = workspace_root();
|
||||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||||
let binary_path = std::env::var_os("CARGO_BIN_EXE_rustfs")
|
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref()));
|
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
||||||
let receipt_path = std::env::var_os("RUSTFS_E2E_BINARY_RECEIPT").map(PathBuf::from);
|
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
||||||
receipt_path
|
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
|
||||||
.ok_or_else(|| std::io::Error::new(ErrorKind::NotFound, "missing E2E run receipt"))
|
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
|
||||||
.and_then(|receipt| verify_e2e_binary_receipt(&receipt, &workspace, &binary_path, requested_features))
|
if source_is_newer {
|
||||||
.unwrap_or_else(|error| {
|
warn!(
|
||||||
panic!(
|
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
|
||||||
"E2E server prerequisite failed: {error}. Build with `python3 scripts/e2e_binary.py build --features <features>` and run tests with `python3 scripts/e2e_binary.py run --features <features> -- cargo nextest run ...`"
|
binary_path
|
||||||
)
|
);
|
||||||
})
|
}
|
||||||
|
info!("Using existing RustFS binary at {:?}", binary_path);
|
||||||
|
return binary_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Building RustFS binary to ensure it's up to date...");
|
||||||
|
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
||||||
|
|
||||||
|
info!("Using RustFS binary at {:?}", binary_path);
|
||||||
|
binary_path
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
|
||||||
#[serde(deny_unknown_fields)]
|
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
|
||||||
struct E2eBinaryReceipt {
|
return true;
|
||||||
schema: u32,
|
};
|
||||||
workspace: PathBuf,
|
let Ok(binary_modified) = binary_meta.modified() else {
|
||||||
binary: PathBuf,
|
return true;
|
||||||
size: u64,
|
};
|
||||||
modified_ns: u128,
|
|
||||||
features: Vec<String>,
|
let workspace = workspace_root();
|
||||||
|
let watch_roots = [
|
||||||
|
workspace.join("Cargo.toml"),
|
||||||
|
workspace.join("Cargo.lock"),
|
||||||
|
workspace.join("rustfs"),
|
||||||
|
workspace.join("crates"),
|
||||||
|
];
|
||||||
|
|
||||||
|
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_e2e_binary_receipt(
|
fn running_inside_e2e_test_binary() -> bool {
|
||||||
receipt_path: &Path,
|
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
|
||||||
workspace: &Path,
|
|
||||||
binary_path: &Path,
|
|
||||||
requested_features: Option<&str>,
|
|
||||||
) -> std::io::Result<PathBuf> {
|
|
||||||
let receipt: E2eBinaryReceipt = serde_json::from_slice(&stdfs::read(receipt_path)?)?;
|
|
||||||
let binary = binary_path.canonicalize()?;
|
|
||||||
let metadata = binary.metadata()?;
|
|
||||||
let modified_ns = metadata
|
|
||||||
.modified()?
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map_err(std::io::Error::other)?
|
|
||||||
.as_nanos();
|
|
||||||
// The runner hashes source and binary before/after the entire suite. Each
|
|
||||||
// nextest process checks only this invocation's path, features, and file stat.
|
|
||||||
if receipt.schema != 1
|
|
||||||
|| receipt.workspace != workspace.canonicalize()?
|
|
||||||
|| receipt.binary != binary
|
|
||||||
|| !metadata.is_file()
|
|
||||||
|| receipt.size != metadata.len()
|
|
||||||
|| receipt.modified_ns != modified_ns
|
|
||||||
{
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
"E2E server differs from this run's verified binary",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(requested) = requested_features.and_then(normalize_rustfs_build_features)
|
|
||||||
&& requested
|
|
||||||
.split(',')
|
|
||||||
.any(|feature| !receipt.features.iter().any(|actual| actual == feature))
|
|
||||||
{
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
ErrorKind::InvalidInput,
|
|
||||||
"E2E server is missing a requested build feature",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(binary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn requested_rustfs_build_features() -> Option<String> {
|
pub fn requested_rustfs_build_features() -> Option<String> {
|
||||||
@@ -524,6 +510,96 @@ pub fn rustfs_build_feature_enabled(requested_features: Option<&str>, required_f
|
|||||||
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
|
||||||
|
binary_path.with_extension("features")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
|
||||||
|
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||||
|
let recorded = stdfs::read_to_string(stamp_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| normalize_rustfs_build_features(&value));
|
||||||
|
let requested = requested_features.and_then(normalize_rustfs_build_features);
|
||||||
|
|
||||||
|
match requested.as_deref() {
|
||||||
|
Some(features) => recorded.as_deref() == Some(features),
|
||||||
|
None => recorded.is_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
|
||||||
|
if path.is_file() {
|
||||||
|
return std::fs::metadata(path)
|
||||||
|
.and_then(|meta| meta.modified())
|
||||||
|
.map(|modified| modified > binary_modified)
|
||||||
|
.unwrap_or(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WalkDir::new(path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|entry| {
|
||||||
|
let name = entry.file_name();
|
||||||
|
name != OsStr::new("target") && name != OsStr::new(".git")
|
||||||
|
})
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|entry| entry.file_type().is_file())
|
||||||
|
.any(|entry| {
|
||||||
|
std::fs::metadata(entry.path())
|
||||||
|
.and_then(|meta| meta.modified())
|
||||||
|
.map(|modified| modified > binary_modified)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the RustFS binary using cargo
|
||||||
|
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
||||||
|
let workspace = workspace_root();
|
||||||
|
info!("Building RustFS binary from workspace: {:?}", workspace);
|
||||||
|
|
||||||
|
let _profile = if cfg!(debug_assertions) {
|
||||||
|
info!("Building in debug mode");
|
||||||
|
"dev"
|
||||||
|
} else {
|
||||||
|
info!("Building in release mode");
|
||||||
|
"release"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cmd = Command::new("cargo");
|
||||||
|
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
|
||||||
|
|
||||||
|
if let Some(features) = requested_features {
|
||||||
|
cmd.arg("--features").arg(features);
|
||||||
|
info!("Building with features: {}", features);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg!(debug_assertions) {
|
||||||
|
cmd.arg("--release");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Executing: cargo build --bin rustfs {}",
|
||||||
|
if cfg!(debug_assertions) { "" } else { "--release" }
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = cmd.output().expect("Failed to execute cargo build command");
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
panic!("Failed to build RustFS binary. Error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||||
|
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
||||||
|
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("✅ RustFS binary built successfully");
|
||||||
|
}
|
||||||
|
|
||||||
fn awscurl_binary_path() -> PathBuf {
|
fn awscurl_binary_path() -> PathBuf {
|
||||||
std::env::var_os("AWSCURL_PATH")
|
std::env::var_os("AWSCURL_PATH")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -2153,66 +2229,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_binary_without_run_receipt_is_rejected() {
|
fn binary_feature_stamp_matching_uses_normalized_features() {
|
||||||
const CHILD_ENV: &str = "RUSTFS_E2E_RECEIPT_TEST_CHILD";
|
let binary_path = std::env::temp_dir().join(format!("rustfs-feature-stamp-test-{}", Uuid::new_v4()));
|
||||||
if std::env::var_os(CHILD_ENV).is_some() {
|
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
||||||
rustfs_binary_path_with_features(None);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let executable = std::env::current_exe().expect("locate isolated test process");
|
|
||||||
let output = Command::new(&executable)
|
|
||||||
.args([
|
|
||||||
"--exact",
|
|
||||||
"common::tests::explicit_binary_without_run_receipt_is_rejected",
|
|
||||||
"--nocapture",
|
|
||||||
])
|
|
||||||
.env(CHILD_ENV, "1")
|
|
||||||
.env("CARGO_BIN_EXE_rustfs", &executable)
|
|
||||||
.env_remove("RUSTFS_E2E_BINARY_RECEIPT")
|
|
||||||
.output()
|
|
||||||
.expect("run the missing-receipt scenario with isolated environment variables");
|
|
||||||
assert!(!output.status.success(), "an explicit binary must not bypass run verification");
|
|
||||||
assert!(String::from_utf8_lossy(&output.stderr).contains("missing E2E run receipt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
stdfs::write(&stamp_path, " SFTP, ftps ").expect("write feature stamp");
|
||||||
fn e2e_run_receipt_rejects_replaced_binary_and_missing_features() {
|
assert!(binary_features_match(&binary_path, Some("sftp,ftps")));
|
||||||
let directory = std::env::temp_dir().join(format!("rustfs-e2e-receipt-test-{}", Uuid::new_v4()));
|
assert!(binary_features_match(&binary_path, Some(" SFTP, FTPS ")));
|
||||||
stdfs::create_dir(&directory).expect("create receipt fixture");
|
assert!(!binary_features_match(&binary_path, Some("sftp")));
|
||||||
let binary = directory.join("rustfs");
|
|
||||||
let receipt = directory.join("receipt.json");
|
stdfs::remove_file(stamp_path).ok();
|
||||||
stdfs::write(&binary, "server").expect("write fixture binary");
|
|
||||||
let metadata = binary.metadata().expect("stat fixture binary");
|
|
||||||
let record = serde_json::json!({
|
|
||||||
"schema": 1,
|
|
||||||
"workspace": directory.canonicalize().expect("canonical workspace"),
|
|
||||||
"binary": binary.canonicalize().expect("canonical binary"),
|
|
||||||
"size": metadata.len(),
|
|
||||||
"modified_ns": metadata.modified().expect("modified time").duration_since(std::time::UNIX_EPOCH).expect("positive timestamp").as_nanos(),
|
|
||||||
"features": ["default", "full", "ftps", "webdav", "sftp"]
|
|
||||||
});
|
|
||||||
stdfs::write(&receipt, serde_json::to_vec(&record).expect("serialize receipt")).expect("write receipt");
|
|
||||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("sftp,webdav")).expect("resolved feature subset");
|
|
||||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("full")).expect("full was actually requested");
|
|
||||||
assert_eq!(
|
|
||||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("rio-v2"))
|
|
||||||
.expect_err("full does not enable rio-v2")
|
|
||||||
.kind(),
|
|
||||||
ErrorKind::InvalidInput
|
|
||||||
);
|
|
||||||
let other = directory.join("old-server");
|
|
||||||
stdfs::write(&other, "server").expect("write alternate binary");
|
|
||||||
assert!(verify_e2e_binary_receipt(&receipt, &directory, &other, None).is_err());
|
|
||||||
stdfs::write(&binary, "different server").expect("replace fixture binary");
|
|
||||||
assert!(verify_e2e_binary_receipt(&receipt, &directory, &binary, None).is_err());
|
|
||||||
stdfs::remove_file(&receipt).expect("remove expired receipt");
|
|
||||||
assert_eq!(
|
|
||||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, None)
|
|
||||||
.expect_err("expired receipt")
|
|
||||||
.kind(),
|
|
||||||
ErrorKind::NotFound
|
|
||||||
);
|
|
||||||
stdfs::remove_dir_all(directory).expect("remove receipt fixture");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
||||||
|
|||||||
@@ -2234,6 +2234,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
||||||
let prefix = "transition/distributed-admission/";
|
let prefix = "transition/distributed-admission/";
|
||||||
hot_client.create_bucket().bucket(&bucket).send().await?;
|
hot_client.create_bucket().bucket(&bucket).send().await?;
|
||||||
|
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
||||||
for index in 0u8..64 {
|
for index in 0u8..64 {
|
||||||
let key = format!("{prefix}object-{index:02}.bin");
|
let key = format!("{prefix}object-{index:02}.bin");
|
||||||
hot_client
|
hot_client
|
||||||
@@ -2244,7 +2245,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
|
||||||
|
|
||||||
let (node0, node1) = tokio::join!(
|
let (node0, node1) = tokio::join!(
|
||||||
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ Use the canonical CI-equivalent protocol command in the parent
|
|||||||
For targeted debugging of the core suite only:
|
For targeted debugging of the core suite only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||||
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This targeted command does not cover the full `e2e-protocols` profile.
|
This targeted command does not cover the full `e2e-protocols` profile.
|
||||||
|
|
||||||
`e2e_binary.py` supplies `RUSTFS_BUILD_FEATURES` from the verified server's resolved Cargo features. The protocol runner schedules only entries present in that feature list; helpers check that their required features are available without rebuilding the server.
|
`RUSTFS_BUILD_FEATURES` controls which features the test rustfs binary is
|
||||||
|
built with. When this variable is set, the protocol test runner schedules
|
||||||
|
only entries whose protocol is present in the requested feature list. Leave
|
||||||
|
it unset to run every protocol entry.
|
||||||
`--test-threads=1` is required because every entry spawns a rustfs server
|
`--test-threads=1` is required because every entry spawns a rustfs server
|
||||||
on fixed bind ports.
|
on fixed bind ports.
|
||||||
|
|
||||||
|
|||||||
@@ -4235,16 +4235,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
</Rule>
|
</Rule>
|
||||||
<Rule>
|
|
||||||
<ID>matrix-and-tags</ID>
|
|
||||||
<Priority>135</Priority>
|
|
||||||
<Status>Enabled</Status>
|
|
||||||
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
|
|
||||||
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
|
||||||
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
|
||||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
|
||||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
|
||||||
</Rule>
|
|
||||||
<Rule>
|
<Rule>
|
||||||
<ID>matrix-disabled</ID>
|
<ID>matrix-disabled</ID>
|
||||||
<Priority>140</Priority>
|
<Priority>140</Priority>
|
||||||
@@ -4299,7 +4289,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
"matrix-prefix",
|
"matrix-prefix",
|
||||||
"matrix-tag",
|
"matrix-tag",
|
||||||
"matrix-disabled",
|
"matrix-disabled",
|
||||||
"matrix-and-tags",
|
|
||||||
"matrix-priority-high",
|
"matrix-priority-high",
|
||||||
"Priority>200",
|
"Priority>200",
|
||||||
"<Status>Disabled</Status>",
|
"<Status>Disabled</Status>",
|
||||||
@@ -4420,30 +4409,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||||
|
|
||||||
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
|
|
||||||
// the required tags is not admitted. Matching any single tag would push
|
|
||||||
// data to a destination the rule never selected (backlog#2366 P1-1), and
|
|
||||||
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
|
|
||||||
// writes, so a single-tag rule passing is not evidence for this.
|
|
||||||
source_client
|
|
||||||
.put_object()
|
|
||||||
.bucket(source_bucket)
|
|
||||||
.key("and-tags/partial.txt")
|
|
||||||
.tagging("env=prod")
|
|
||||||
.body(ByteStream::from_static(b"one of two tags"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
|
|
||||||
source_client
|
|
||||||
.put_object()
|
|
||||||
.bucket(source_bucket)
|
|
||||||
.key("and-tags/full.txt")
|
|
||||||
.tagging("env=prod&tier=gold")
|
|
||||||
.body(ByteStream::from_static(b"both tags"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
|
|
||||||
|
|
||||||
source_client
|
source_client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(source_bucket)
|
.bucket(source_bucket)
|
||||||
|
|||||||
@@ -4036,10 +4036,6 @@ impl ManualTransitionRunReport {
|
|||||||
|| self.skipped_queue_timeout > 0
|
|| self.skipped_queue_timeout > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_enqueue_backpressure(&self) -> bool {
|
|
||||||
self.skipped_queue_full > 0 || self.skipped_queue_closed > 0 || self.skipped_queue_timeout > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn was_truncated(&self) -> bool {
|
pub fn was_truncated(&self) -> bool {
|
||||||
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
||||||
}
|
}
|
||||||
@@ -4255,7 +4251,7 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
}
|
}
|
||||||
report.scanned = report.scanned.saturating_add(1);
|
report.scanned = report.scanned.saturating_add(1);
|
||||||
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
||||||
if report.has_enqueue_backpressure() {
|
if report.has_partial_enqueue() {
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
report.continuation_token =
|
report.continuation_token =
|
||||||
@@ -9954,18 +9950,6 @@ mod tests {
|
|||||||
assert_eq!(report.skipped_queue_closed, 0);
|
assert_eq!(report.skipped_queue_closed, 0);
|
||||||
assert_eq!(report.skipped_queue_timeout, 0);
|
assert_eq!(report.skipped_queue_timeout, 0);
|
||||||
assert!(report.has_partial_enqueue());
|
assert!(report.has_partial_enqueue());
|
||||||
assert!(report.has_enqueue_backpressure());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manual_transition_in_flight_skip_does_not_stop_the_scan() {
|
|
||||||
let options = ManualTransitionRunOptions::default();
|
|
||||||
let mut report = ManualTransitionRunReport::new("bucket", &options);
|
|
||||||
|
|
||||||
report.record_enqueue_outcome(TransitionEnqueueOutcome::AlreadyInFlight);
|
|
||||||
|
|
||||||
assert!(report.has_partial_enqueue());
|
|
||||||
assert!(!report.has_enqueue_backpressure());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,152 +22,21 @@ use rustfs_lock::{
|
|||||||
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
||||||
types::{LockId, LockMetadata, LockPriority},
|
types::{LockId, LockMetadata, LockPriority},
|
||||||
};
|
};
|
||||||
use rustfs_protos::proto_gen::node_service::{
|
use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, GenerallyLockRequest, PingRequest};
|
||||||
BatchGenerallyLockRequest, BatchGenerallyLockResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult,
|
|
||||||
PingRequest,
|
|
||||||
};
|
|
||||||
use rustfs_protos::{
|
use rustfs_protos::{
|
||||||
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
||||||
proto_gen::node_service::node_service_client::NodeServiceClient,
|
proto_gen::node_service::node_service_client::NodeServiceClient,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::{sync::OnceLock, time::Duration};
|
||||||
use std::future::Future;
|
use tokio::time::timeout;
|
||||||
use std::pin::Pin;
|
use tonic::Request;
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tokio::time::{Instant, timeout};
|
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
use tonic::{Request, Response};
|
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
||||||
set_tonic_rolling_mutation_body_digest(request)
|
set_tonic_rolling_mutation_body_digest(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Work to run if an RPC that already timed out for its caller completes later.
|
|
||||||
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
|
|
||||||
|
|
||||||
/// The liveness window is this many RPC deadlines: a peer that completed a
|
|
||||||
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
|
|
||||||
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
|
|
||||||
|
|
||||||
/// Recent history of the shared lock channel to one peer (issue #7363).
|
|
||||||
///
|
|
||||||
/// A single request deadline says nothing about the HTTP/2 connection it ran
|
|
||||||
/// on: a peer whose lock service is merely slow keeps answering other streams.
|
|
||||||
/// Evicting the cached channel on every timeout turned that slowness into a
|
|
||||||
/// `RST_STREAM`/`GOAWAY too_many_resets`/re-dial loop across the cluster, so
|
|
||||||
/// eviction now requires the peer to have gone quiet and is rate limited.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct LockPeerChannelHealth {
|
|
||||||
last_success: Option<Instant>,
|
|
||||||
last_eviction: Option<Instant>,
|
|
||||||
consecutive_timeouts: u32,
|
|
||||||
/// Timed-out RPCs still running in the background for this peer.
|
|
||||||
detached_rpcs: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lock_peer_channel_health() -> &'static Mutex<HashMap<String, LockPeerChannelHealth>> {
|
|
||||||
static HEALTH: OnceLock<Mutex<HashMap<String, LockPeerChannelHealth>>> = OnceLock::new();
|
|
||||||
HEALTH.get_or_init(Mutex::default)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannelHealth) -> R) -> R {
|
|
||||||
let mut peers = lock_peer_channel_health()
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
update(peers.entry(addr.to_string()).or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn lock_peer_health_for_test(addr: &str) -> LockPeerChannelHealth {
|
|
||||||
lock_peer_channel_health()
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.get(addr)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn reset_lock_peer_health_for_test(addr: &str) {
|
|
||||||
lock_peer_channel_health()
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.remove(addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum EvictionTrigger {
|
|
||||||
/// The caller's deadline expired while the stream was still open.
|
|
||||||
Timeout,
|
|
||||||
/// The transport itself reported the failure (refused, reset, GOAWAY, ...).
|
|
||||||
Transport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EvictionTrigger {
|
|
||||||
fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Timeout => "timeout",
|
|
||||||
Self::Transport => "transport",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum EvictionVerdict {
|
|
||||||
Evict,
|
|
||||||
/// The peer completed a lock RPC within the liveness window: slow, not gone.
|
|
||||||
PeerRecentlyServed,
|
|
||||||
/// The channel was re-dialed within the cooldown; let it prove itself first.
|
|
||||||
CoolingDown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EvictionVerdict {
|
|
||||||
fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Evict => "evict",
|
|
||||||
Self::PeerRecentlyServed => "peer_recently_served",
|
|
||||||
Self::CoolingDown => "cooling_down",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decide whether a failed lock RPC may evict the shared channel to its peer.
|
|
||||||
fn eviction_verdict(
|
|
||||||
health: &LockPeerChannelHealth,
|
|
||||||
now: Instant,
|
|
||||||
trigger: EvictionTrigger,
|
|
||||||
liveness_window: Duration,
|
|
||||||
cooldown: Duration,
|
|
||||||
) -> EvictionVerdict {
|
|
||||||
if trigger == EvictionTrigger::Timeout
|
|
||||||
&& health
|
|
||||||
.last_success
|
|
||||||
.is_some_and(|at| now.saturating_duration_since(at) < liveness_window)
|
|
||||||
{
|
|
||||||
return EvictionVerdict::PeerRecentlyServed;
|
|
||||||
}
|
|
||||||
if health
|
|
||||||
.last_eviction
|
|
||||||
.is_some_and(|at| now.saturating_duration_since(at) < cooldown)
|
|
||||||
{
|
|
||||||
return EvictionVerdict::CoolingDown;
|
|
||||||
}
|
|
||||||
EvictionVerdict::Evict
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lock ids whose batch entry the server reports as granted.
|
|
||||||
fn acquired_lock_ids(lock_ids: &[LockId], results: &[GenerallyLockResult]) -> Vec<LockId> {
|
|
||||||
results
|
|
||||||
.iter()
|
|
||||||
.zip(lock_ids)
|
|
||||||
.filter(|(result, _)| result.success)
|
|
||||||
.map(|(_, lock_id)| lock_id.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remote lock client implementation
|
/// Remote lock client implementation
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoteClient {
|
pub struct RemoteClient {
|
||||||
@@ -329,202 +198,14 @@ impl RemoteClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eviction_cooldown() -> Duration {
|
async fn execute_rpc<T, F>(&self, op: &'static str, resource_summary: &str, future: F) -> std::result::Result<T, LockError>
|
||||||
Duration::from_millis(rustfs_utils::get_env_u64(
|
|
||||||
rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
|
||||||
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detached_rpc_limit() -> usize {
|
|
||||||
rustfs_utils::get_env_usize(
|
|
||||||
rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
|
||||||
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn liveness_window(deadline: Duration) -> Duration {
|
|
||||||
deadline.saturating_mul(LOCK_RPC_LIVENESS_WINDOW_DEADLINES)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_rpc_success(&self) {
|
|
||||||
with_lock_peer_health(&self.addr, |health| {
|
|
||||||
health.last_success = Some(Instant::now());
|
|
||||||
health.consecutive_timeouts = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply the per-peer eviction policy after a failed RPC.
|
|
||||||
async fn maybe_evict_connection(
|
|
||||||
&self,
|
|
||||||
op: &'static str,
|
|
||||||
reason: &str,
|
|
||||||
resource_summary: &str,
|
|
||||||
trigger: EvictionTrigger,
|
|
||||||
deadline: Duration,
|
|
||||||
) {
|
|
||||||
let now = Instant::now();
|
|
||||||
let cooldown = Self::eviction_cooldown();
|
|
||||||
let liveness_window = Self::liveness_window(deadline);
|
|
||||||
let (verdict, consecutive_timeouts) = with_lock_peer_health(&self.addr, |health| {
|
|
||||||
if trigger == EvictionTrigger::Timeout {
|
|
||||||
health.consecutive_timeouts = health.consecutive_timeouts.saturating_add(1);
|
|
||||||
}
|
|
||||||
let verdict = eviction_verdict(health, now, trigger, liveness_window, cooldown);
|
|
||||||
if verdict == EvictionVerdict::Evict {
|
|
||||||
health.last_eviction = Some(now);
|
|
||||||
}
|
|
||||||
(verdict, health.consecutive_timeouts)
|
|
||||||
});
|
|
||||||
if verdict == EvictionVerdict::Evict {
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction(&self.addr, trigger.as_str());
|
|
||||||
self.evict_connection(op, reason, resource_summary).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction_suppressed(&self.addr, verdict.as_str());
|
|
||||||
debug!(
|
|
||||||
addr = %self.addr,
|
|
||||||
op,
|
|
||||||
resource_summary,
|
|
||||||
trigger = trigger.as_str(),
|
|
||||||
verdict = verdict.as_str(),
|
|
||||||
consecutive_timeouts,
|
|
||||||
"Keeping cached remote lock connection after RPC failure"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep a timed-out RPC running instead of cancelling its stream.
|
|
||||||
///
|
|
||||||
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
|
|
||||||
/// in the server's pending-accept queue until it answers `GOAWAY
|
|
||||||
/// too_many_resets` and kills every stream on the connection. A detached
|
|
||||||
/// stream ends on its own within the internode RPC timeout, the number per
|
|
||||||
/// peer is bounded, and a lock granted after its caller gave up is released.
|
|
||||||
fn detach_timed_out_rpc<T: Send + 'static>(
|
|
||||||
&self,
|
|
||||||
op: &'static str,
|
|
||||||
resource_summary: &str,
|
|
||||||
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
|
|
||||||
late: LateCompletion<T>,
|
|
||||||
) {
|
|
||||||
let limit = Self::detached_rpc_limit();
|
|
||||||
let admitted = with_lock_peer_health(&self.addr, |health| {
|
|
||||||
if health.detached_rpcs >= limit {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
health.detached_rpcs += 1;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if !admitted {
|
|
||||||
handle.abort();
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
|
|
||||||
debug!(
|
|
||||||
addr = %self.addr,
|
|
||||||
op,
|
|
||||||
resource_summary,
|
|
||||||
limit,
|
|
||||||
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
|
|
||||||
let addr = self.addr.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let outcome = handle.await;
|
|
||||||
with_lock_peer_health(&addr, |health| health.detached_rpcs = health.detached_rpcs.saturating_sub(1));
|
|
||||||
match outcome {
|
|
||||||
Ok(Ok(response)) => {
|
|
||||||
with_lock_peer_health(&addr, |health| {
|
|
||||||
health.last_success = Some(Instant::now());
|
|
||||||
health.consecutive_timeouts = 0;
|
|
||||||
});
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
|
|
||||||
if let Some(late) = late {
|
|
||||||
late(response).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Err(status)) => {
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
|
|
||||||
debug!(
|
|
||||||
addr = %addr,
|
|
||||||
op,
|
|
||||||
tonic_code = ?status.code(),
|
|
||||||
tonic_message = status.message(),
|
|
||||||
"Detached remote lock RPC failed after its caller timed out"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(join_error) => {
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
|
|
||||||
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn late_release_hook(&self, lock_id: LockId) -> LateCompletion<Response<GenerallyLockResponse>> {
|
|
||||||
let client = self.clone();
|
|
||||||
Some(Box::new(move |response: Response<GenerallyLockResponse>| {
|
|
||||||
Box::pin(async move {
|
|
||||||
if response.get_ref().success {
|
|
||||||
client.release_late_acquisitions(vec![lock_id]).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn late_release_batch_hook(&self, lock_ids: Vec<LockId>) -> LateCompletion<Response<BatchGenerallyLockResponse>> {
|
|
||||||
let client = self.clone();
|
|
||||||
Some(Box::new(move |response: Response<BatchGenerallyLockResponse>| {
|
|
||||||
Box::pin(async move {
|
|
||||||
let acquired = acquired_lock_ids(&lock_ids, &response.get_ref().results);
|
|
||||||
if !acquired.is_empty() {
|
|
||||||
client.release_late_acquisitions(acquired).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A lock granted after its caller stopped waiting is an orphan until its
|
|
||||||
/// lease expires; hand it back right away, best effort.
|
|
||||||
async fn release_late_acquisitions(&self, lock_ids: Vec<LockId>) {
|
|
||||||
let outcome = match self.release_locks_batch(&lock_ids).await {
|
|
||||||
Ok(released) if released.iter().all(|released| *released) => "released",
|
|
||||||
Ok(_) => "partial",
|
|
||||||
Err(_) => "failed",
|
|
||||||
};
|
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
|
|
||||||
if outcome == "released" {
|
|
||||||
debug!(addr = %self.addr, count = lock_ids.len(), "Released remote locks granted after their caller timed out");
|
|
||||||
} else {
|
|
||||||
warn!(
|
|
||||||
addr = %self.addr,
|
|
||||||
count = lock_ids.len(),
|
|
||||||
outcome,
|
|
||||||
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_rpc<T, Fut>(
|
|
||||||
&self,
|
|
||||||
op: &'static str,
|
|
||||||
resource_summary: &str,
|
|
||||||
deadline: Duration,
|
|
||||||
future: Fut,
|
|
||||||
late: LateCompletion<T>,
|
|
||||||
) -> std::result::Result<T, LockError>
|
|
||||||
where
|
where
|
||||||
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
|
F: std::future::Future<Output = std::result::Result<T, tonic::Status>>,
|
||||||
T: Send + 'static,
|
|
||||||
{
|
{
|
||||||
let mut handle = tokio::spawn(future);
|
let lock_timeout = Self::rpc_timeout();
|
||||||
match timeout(deadline, &mut handle).await {
|
match timeout(lock_timeout, future).await {
|
||||||
Ok(Ok(Ok(response))) => {
|
Ok(Ok(response)) => Ok(response),
|
||||||
self.record_rpc_success();
|
Ok(Err(err)) => {
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
Ok(Ok(Err(err))) => {
|
|
||||||
let reason = err.to_string();
|
let reason = err.to_string();
|
||||||
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
||||||
// transport problem. A server-produced application status (auth denied, peer
|
// transport problem. A server-produced application status (auth denied, peer
|
||||||
@@ -536,7 +217,7 @@ impl RemoteClient {
|
|||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = deadline.as_millis(),
|
timeout_ms = lock_timeout.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
tonic_code = ?err.code(),
|
tonic_code = ?err.code(),
|
||||||
tonic_message = err.message(),
|
tonic_message = err.message(),
|
||||||
@@ -547,7 +228,7 @@ impl RemoteClient {
|
|||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = deadline.as_millis(),
|
timeout_ms = lock_timeout.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
tonic_code = ?err.code(),
|
tonic_code = ?err.code(),
|
||||||
tonic_message = err.message(),
|
tonic_message = err.message(),
|
||||||
@@ -556,29 +237,17 @@ impl RemoteClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if transport_failure {
|
if transport_failure {
|
||||||
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
|
self.evict_connection(op, &reason, resource_summary).await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
||||||
}
|
}
|
||||||
Ok(Err(join_error)) => {
|
|
||||||
warn!(
|
|
||||||
addr = %self.addr,
|
|
||||||
op,
|
|
||||||
resource_summary,
|
|
||||||
error = %join_error,
|
|
||||||
"Remote lock RPC task ended abnormally"
|
|
||||||
);
|
|
||||||
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
|
|
||||||
}
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let reason = format!("RPC timed out after {deadline:?}");
|
let reason = format!("RPC timed out after {:?}", lock_timeout);
|
||||||
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
|
|
||||||
if Self::is_scanner_leader_lock(resource_summary) {
|
if Self::is_scanner_leader_lock(resource_summary) {
|
||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = deadline.as_millis(),
|
timeout_ms = lock_timeout.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
"Remote lock RPC timed out for scanner leader lock"
|
"Remote lock RPC timed out for scanner leader lock"
|
||||||
);
|
);
|
||||||
@@ -586,15 +255,13 @@ impl RemoteClient {
|
|||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = deadline.as_millis(),
|
timeout_ms = lock_timeout.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
"Remote lock RPC timed out"
|
"Remote lock RPC timed out"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
|
self.evict_connection(op, &reason, resource_summary).await;
|
||||||
.await;
|
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), lock_timeout))
|
||||||
self.detach_timed_out_rpc(op, resource_summary, handle, late);
|
|
||||||
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,18 +354,8 @@ impl LockClient for RemoteClient {
|
|||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let late = self.late_release_hook(request.lock_id.clone());
|
|
||||||
|
|
||||||
let resp = match self
|
let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
|
||||||
.execute_rpc(
|
|
||||||
"lock",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.lock(req).await },
|
|
||||||
late,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => resp.into_inner(),
|
Ok(resp) => resp.into_inner(),
|
||||||
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
||||||
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
||||||
@@ -736,16 +393,9 @@ impl LockClient for RemoteClient {
|
|||||||
.collect::<Result<Vec<_>>>()?,
|
.collect::<Result<Vec<_>>>()?,
|
||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
|
|
||||||
|
|
||||||
let resp = match self
|
let resp = match self
|
||||||
.execute_rpc(
|
.execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
|
||||||
"lock_batch",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.lock_batch(req).await },
|
|
||||||
late,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp.into_inner(),
|
Ok(resp) => resp.into_inner(),
|
||||||
@@ -786,13 +436,7 @@ impl LockClient for RemoteClient {
|
|||||||
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc(
|
.execute_rpc("release", &resource_summary, client.un_lock(req))
|
||||||
"release",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.un_lock(req).await },
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -820,13 +464,7 @@ impl LockClient for RemoteClient {
|
|||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc(
|
.execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
|
||||||
"release_batch",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.un_lock_batch(req).await },
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
||||||
@@ -848,13 +486,7 @@ impl LockClient for RemoteClient {
|
|||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc(
|
.execute_rpc("refresh", &resource_summary, client.refresh(req))
|
||||||
"refresh",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.refresh(req).await },
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -874,13 +506,7 @@ impl LockClient for RemoteClient {
|
|||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc(
|
.execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
|
||||||
"force_release",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.force_un_lock(req).await },
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -897,26 +523,16 @@ impl LockClient for RemoteClient {
|
|||||||
let status_request = Self::create_unlock_request(lock_id);
|
let status_request = Self::create_unlock_request(lock_id);
|
||||||
let resource_summary = status_request.resource.to_string();
|
let resource_summary = status_request.resource.to_string();
|
||||||
let mut client = self.get_client().await?;
|
let mut client = self.get_client().await?;
|
||||||
let args = serde_json::to_string(&status_request)
|
|
||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
|
||||||
|
|
||||||
// Try to acquire a very short-lived lock to test availability
|
// Try to acquire a very short-lived lock to test availability
|
||||||
let mut req = Request::new(GenerallyLockRequest { args: args.clone() });
|
let mut req = Request::new(GenerallyLockRequest {
|
||||||
|
args: serde_json::to_string(&status_request)
|
||||||
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||||
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
// A probe lock granted after the deadline must not linger on the peer.
|
|
||||||
let late = self.late_release_hook(lock_id.clone());
|
|
||||||
|
|
||||||
// Try exclusive lock first with very short timeout
|
// Try exclusive lock first with very short timeout
|
||||||
let resp = match self
|
let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
|
||||||
.execute_rpc(
|
|
||||||
"check_status",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.lock(req).await },
|
|
||||||
late,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => response.into_inner(),
|
Ok(response) => response.into_inner(),
|
||||||
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
||||||
};
|
};
|
||||||
@@ -924,19 +540,14 @@ impl LockClient for RemoteClient {
|
|||||||
if resp.success {
|
if resp.success {
|
||||||
// If we successfully acquired the lock, the resource was free.
|
// If we successfully acquired the lock, the resource was free.
|
||||||
// Immediately release it on a best-effort basis.
|
// Immediately release it on a best-effort basis.
|
||||||
let mut release_req = Request::new(GenerallyLockRequest { args });
|
let mut release_req = Request::new(GenerallyLockRequest {
|
||||||
|
args: serde_json::to_string(&status_request)
|
||||||
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||||
|
});
|
||||||
attach_lock_mutation_body_digest(&mut release_req)?;
|
attach_lock_mutation_body_digest(&mut release_req)?;
|
||||||
if let Ok(mut client) = self.get_client().await {
|
let _ = self
|
||||||
let _ = self
|
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
|
||||||
.execute_rpc(
|
.await;
|
||||||
"check_status_release",
|
|
||||||
&resource_summary,
|
|
||||||
Self::rpc_timeout(),
|
|
||||||
async move { client.un_lock(release_req).await },
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
@@ -971,8 +582,19 @@ impl LockClient for RemoteClient {
|
|||||||
|
|
||||||
async fn is_online(&self) -> bool {
|
async fn is_online(&self) -> bool {
|
||||||
let online_timeout = Self::online_check_timeout();
|
let online_timeout = Self::online_check_timeout();
|
||||||
let mut client = match timeout(online_timeout, self.get_client()).await {
|
match timeout(online_timeout, async {
|
||||||
Ok(Ok(client)) => client,
|
let mut client = self.get_client().await?;
|
||||||
|
let ping_req = Request::new(Self::build_ping_request());
|
||||||
|
self.execute_rpc("ping", Self::ONLINE_CHECK_RESOURCE, client.ping(ping_req))
|
||||||
|
.await?;
|
||||||
|
Ok::<(), LockError>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
||||||
|
true
|
||||||
|
}
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
@@ -980,39 +602,16 @@ impl LockClient for RemoteClient {
|
|||||||
error = %err,
|
error = %err,
|
||||||
"remote lock client online check failed"
|
"remote lock client online check failed"
|
||||||
);
|
);
|
||||||
return false;
|
false
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
let reason = format!("online check timed out after {:?}", online_timeout);
|
||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
timeout_ms = online_timeout.as_millis(),
|
timeout_ms = online_timeout.as_millis(),
|
||||||
"remote lock client online check timed out while dialing"
|
"remote lock client online check timed out"
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let ping_req = Request::new(Self::build_ping_request());
|
|
||||||
match self
|
|
||||||
.execute_rpc(
|
|
||||||
"ping",
|
|
||||||
Self::ONLINE_CHECK_RESOURCE,
|
|
||||||
online_timeout,
|
|
||||||
async move { client.ping(ping_req).await },
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
addr = %self.addr,
|
|
||||||
timeout_ms = online_timeout.as_millis(),
|
|
||||||
error = %err,
|
|
||||||
"remote lock client online check failed"
|
|
||||||
);
|
);
|
||||||
|
self.evict_connection("ping", &reason, Self::ONLINE_CHECK_RESOURCE).await;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1074,232 +673,6 @@ mod tests {
|
|||||||
.with_priority(LockPriority::Normal)
|
.with_priority(LockPriority::Normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eviction_verdict_distinguishes_slow_peers_from_dead_channels() {
|
|
||||||
let now = Instant::now() + Duration::from_secs(3600);
|
|
||||||
let window = Duration::from_secs(6);
|
|
||||||
let cooldown = Duration::from_secs(5);
|
|
||||||
|
|
||||||
let idle = LockPeerChannelHealth::default();
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&idle, now, EvictionTrigger::Timeout, window, cooldown),
|
|
||||||
EvictionVerdict::Evict
|
|
||||||
);
|
|
||||||
|
|
||||||
let serving = LockPeerChannelHealth {
|
|
||||||
last_success: Some(now - Duration::from_secs(1)),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&serving, now, EvictionTrigger::Timeout, window, cooldown),
|
|
||||||
EvictionVerdict::PeerRecentlyServed,
|
|
||||||
"a timeout on a peer that just answered is load, not a dead channel"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&serving, now, EvictionTrigger::Transport, window, cooldown),
|
|
||||||
EvictionVerdict::Evict,
|
|
||||||
"a transport failure is reported by the channel itself and still evicts"
|
|
||||||
);
|
|
||||||
|
|
||||||
let quiet = LockPeerChannelHealth {
|
|
||||||
last_success: Some(now - Duration::from_secs(30)),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&quiet, now, EvictionTrigger::Timeout, window, cooldown),
|
|
||||||
EvictionVerdict::Evict
|
|
||||||
);
|
|
||||||
|
|
||||||
let just_evicted = LockPeerChannelHealth {
|
|
||||||
last_eviction: Some(now - Duration::from_secs(1)),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&just_evicted, now, EvictionTrigger::Timeout, window, cooldown),
|
|
||||||
EvictionVerdict::CoolingDown
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&just_evicted, now, EvictionTrigger::Transport, window, cooldown),
|
|
||||||
EvictionVerdict::CoolingDown
|
|
||||||
);
|
|
||||||
|
|
||||||
let cooled = LockPeerChannelHealth {
|
|
||||||
last_eviction: Some(now - Duration::from_secs(10)),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
eviction_verdict(&cooled, now, EvictionTrigger::Timeout, window, cooldown),
|
|
||||||
EvictionVerdict::Evict
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn acquired_lock_ids_picks_only_granted_batch_entries() {
|
|
||||||
let lock_ids = vec![
|
|
||||||
LockId::new_unique(&ObjectKey::new("bucket", "a")),
|
|
||||||
LockId::new_unique(&ObjectKey::new("bucket", "b")),
|
|
||||||
LockId::new_unique(&ObjectKey::new("bucket", "c")),
|
|
||||||
];
|
|
||||||
let results = vec![
|
|
||||||
GenerallyLockResult {
|
|
||||||
success: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
GenerallyLockResult {
|
|
||||||
success: false,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let acquired = acquired_lock_ids(&lock_ids, &results);
|
|
||||||
assert_eq!(
|
|
||||||
acquired,
|
|
||||||
vec![lock_ids[0].clone()],
|
|
||||||
"only granted entries with a matching id are released"
|
|
||||||
);
|
|
||||||
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
|
||||||
with_lock_peer_health(&addr, |health| health.last_success = Some(Instant::now()));
|
|
||||||
|
|
||||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
|
||||||
let client = RemoteClient::new(addr.clone());
|
|
||||||
let response = client
|
|
||||||
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!response.success, "timed out lock acquisition should fail");
|
|
||||||
assert!(
|
|
||||||
runtime_sources::test_node_channel_is_cached(&addr).await,
|
|
||||||
"a peer that served a lock RPC within the liveness window is slow, not gone"
|
|
||||||
);
|
|
||||||
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 1);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
accept_task.abort();
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn test_remote_client_repeated_timeouts_evict_at_most_once_per_cooldown() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
|
||||||
|
|
||||||
temp_env::async_with_vars(
|
|
||||||
[
|
|
||||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
|
||||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS, Some("60000")),
|
|
||||||
],
|
|
||||||
async {
|
|
||||||
let client = RemoteClient::new(addr.clone());
|
|
||||||
let request = test_lock_request(Duration::from_millis(5));
|
|
||||||
|
|
||||||
let _ = client.acquire_lock(&request).await.unwrap();
|
|
||||||
assert!(
|
|
||||||
!runtime_sources::test_node_channel_is_cached(&addr).await,
|
|
||||||
"the first timeout on a quiet peer evicts the cached channel"
|
|
||||||
);
|
|
||||||
|
|
||||||
cache_lazy_channel(&addr).await;
|
|
||||||
let _ = client.acquire_lock(&request).await.unwrap();
|
|
||||||
assert!(
|
|
||||||
runtime_sources::test_node_channel_is_cached(&addr).await,
|
|
||||||
"a second timeout inside the cooldown must not tear the fresh channel down again"
|
|
||||||
);
|
|
||||||
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 2);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
accept_task.abort();
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn test_remote_client_detaches_timed_out_rpc_and_reclaims_its_slot() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
|
||||||
|
|
||||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
|
||||||
let client = RemoteClient::new(addr.clone());
|
|
||||||
let _ = client
|
|
||||||
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
lock_peer_health_for_test(&addr).detached_rpcs,
|
|
||||||
1,
|
|
||||||
"the timed-out stream keeps running instead of being reset"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The hanging listener drops its socket after two seconds; the detached
|
|
||||||
// task then observes the transport failure and frees its slot.
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
|
||||||
while lock_peer_health_for_test(&addr).detached_rpcs != 0 {
|
|
||||||
assert!(Instant::now() < deadline, "detached RPC slot must be reclaimed once the stream ends");
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
accept_task.abort();
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn test_remote_client_cancels_timed_out_rpc_when_detached_budget_is_exhausted() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
|
||||||
|
|
||||||
temp_env::async_with_vars(
|
|
||||||
[
|
|
||||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
|
||||||
(rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT, Some("0")),
|
|
||||||
],
|
|
||||||
async {
|
|
||||||
let client = RemoteClient::new(addr.clone());
|
|
||||||
let response = client
|
|
||||||
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!response.success);
|
|
||||||
assert_eq!(
|
|
||||||
lock_peer_health_for_test(&addr).detached_rpcs,
|
|
||||||
0,
|
|
||||||
"an exhausted detached budget falls back to cancelling the stream"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
accept_task.abort();
|
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
||||||
let mut single = Request::new(GenerallyLockRequest {
|
let mut single = Request::new(GenerallyLockRequest {
|
||||||
@@ -1341,7 +714,6 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -1387,7 +759,6 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -1434,7 +805,6 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -1472,7 +842,6 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -1515,7 +884,6 @@ mod tests {
|
|||||||
let Some(addr) = closed_listener_addr().await else {
|
let Some(addr) = closed_listener_addr().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
reset_lock_peer_health_for_test(&addr);
|
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
|
|||||||
@@ -840,21 +840,15 @@ fn is_decommission_start_active_pool(pool: &PoolStatus) -> bool {
|
|||||||
decommission_start_pool_state(Some(pool)) == DecommissionStartPoolState::Active
|
decommission_start_pool_state(Some(pool)) == DecommissionStartPoolState::Active
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalid_decommission_request(reason: impl Into<String>) -> Error {
|
|
||||||
Error::InvalidArgument("decommission".to_string(), "pool-state".to_string(), reason.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_decommission_start_allowed(state: DecommissionStartPoolState) -> Result<()> {
|
fn ensure_decommission_start_allowed(state: DecommissionStartPoolState) -> Result<()> {
|
||||||
match state {
|
match state {
|
||||||
DecommissionStartPoolState::Missing => {
|
DecommissionStartPoolState::Missing => Err(Error::other("failed to start decommission: target pool was not found")),
|
||||||
Err(invalid_decommission_request("failed to start decommission: target pool was not found"))
|
|
||||||
}
|
|
||||||
DecommissionStartPoolState::Active | DecommissionStartPoolState::Retryable => Ok(()),
|
DecommissionStartPoolState::Active | DecommissionStartPoolState::Retryable => Ok(()),
|
||||||
DecommissionStartPoolState::Decommissioning => Err(StorageError::DecommissionAlreadyRunning),
|
DecommissionStartPoolState::Decommissioning => Err(StorageError::DecommissionAlreadyRunning),
|
||||||
DecommissionStartPoolState::Decommissioned => Err(invalid_decommission_request(
|
DecommissionStartPoolState::Decommissioned => {
|
||||||
"failed to start decommission: target pool is already decommissioned",
|
Err(Error::other("failed to start decommission: target pool is already decommissioned"))
|
||||||
)),
|
}
|
||||||
DecommissionStartPoolState::Blocked => Err(invalid_decommission_request(
|
DecommissionStartPoolState::Blocked => Err(Error::other(
|
||||||
"failed to start decommission: target pool decommission is blocked; clear failed or canceled metadata before starting again",
|
"failed to start decommission: target pool decommission is blocked; clear failed or canceled metadata before starting again",
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -871,7 +865,7 @@ fn ensure_decommission_start_keeps_active_pool(meta: &PoolMeta, indices: &[usize
|
|||||||
.filter(|idx| meta.pools.get(**idx).is_some_and(is_decommission_start_active_pool))
|
.filter(|idx| meta.pools.get(**idx).is_some_and(is_decommission_start_active_pool))
|
||||||
.count();
|
.count();
|
||||||
if active_count.saturating_sub(active_target_count) == 0 {
|
if active_count.saturating_sub(active_target_count) == 0 {
|
||||||
return Err(invalid_decommission_request(
|
return Err(Error::other(
|
||||||
"failed to start decommission: at least one active pool must remain after decommission start",
|
"failed to start decommission: at least one active pool must remain after decommission start",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1757,53 +1751,8 @@ fn ensure_decommission_capacity_reservations_available(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
fn ensure_external_decommission_target_admission(meta: &PoolMeta, target_pool_index: usize, phase: &'static str) -> Result<()> {
|
||||||
pub(crate) enum DecommissionCapacityAdmission {
|
if active_decommission_source_indices(meta).into_iter().any(|source_pool_index| {
|
||||||
Mutation,
|
|
||||||
ExistingMultipart,
|
|
||||||
ScannerBacklog,
|
|
||||||
BatchDelete,
|
|
||||||
Heal,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DecommissionCapacityAdmission {
|
|
||||||
fn phase(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Mutation => "mutation",
|
|
||||||
Self::ExistingMultipart => "existing_multipart",
|
|
||||||
Self::ScannerBacklog => "scanner_backlog",
|
|
||||||
Self::BatchDelete => "batch_delete",
|
|
||||||
Self::Heal => "heal",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_external_decommission_target_admission(
|
|
||||||
meta: &PoolMeta,
|
|
||||||
target_pool_index: usize,
|
|
||||||
admission: DecommissionCapacityAdmission,
|
|
||||||
) -> Result<()> {
|
|
||||||
let phase = admission.phase();
|
|
||||||
// Pool selection may predate retirement or use a stale node-local snapshot.
|
|
||||||
// Recheck publication against the fenced durable state. Repair and pure
|
|
||||||
// capacity release retain their separate admission contracts.
|
|
||||||
if matches!(admission, DecommissionCapacityAdmission::ScannerBacklog)
|
|
||||||
&& !meta.scanner_pause_backlog_pool_writable(target_pool_index)
|
|
||||||
{
|
|
||||||
return Err(Error::SlowDown);
|
|
||||||
}
|
|
||||||
let active_sources = active_decommission_source_indices(meta);
|
|
||||||
if meta.is_suspended(target_pool_index) {
|
|
||||||
let active_source = active_sources.contains(&target_pool_index);
|
|
||||||
if !matches!(
|
|
||||||
admission,
|
|
||||||
DecommissionCapacityAdmission::Heal | DecommissionCapacityAdmission::ScannerBacklog
|
|
||||||
) && !(matches!(admission, DecommissionCapacityAdmission::ExistingMultipart) && active_source)
|
|
||||||
{
|
|
||||||
return Err(Error::SlowDown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if active_sources.into_iter().any(|source_pool_index| {
|
|
||||||
meta.pools
|
meta.pools
|
||||||
.get(source_pool_index)
|
.get(source_pool_index)
|
||||||
.and_then(|pool| pool.decommission.as_ref())
|
.and_then(|pool| pool.decommission.as_ref())
|
||||||
@@ -1813,13 +1762,6 @@ fn ensure_external_decommission_target_admission(
|
|||||||
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
||||||
return Err(Error::SlowDown);
|
return Err(Error::SlowDown);
|
||||||
}
|
}
|
||||||
// Migration reservations budget the mover, not exclusive ownership of a
|
|
||||||
// healthy pool. Foreground publication shares its actual disk capacity;
|
|
||||||
// migration must retain the source if its capacity or target write fails.
|
|
||||||
// Repair keeps its separate, conservative reservation admission contract.
|
|
||||||
if !matches!(admission, DecommissionCapacityAdmission::Heal) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let reserved = active_decommission_target_reservations(meta)
|
let reserved = active_decommission_target_reservations(meta)
|
||||||
.get(&target_pool_index)
|
.get(&target_pool_index)
|
||||||
.copied()
|
.copied()
|
||||||
@@ -4326,7 +4268,7 @@ fn should_retry_decommission_cancel_reload(changed: bool, already_canceled: bool
|
|||||||
|
|
||||||
fn ensure_decommission_cancel_allowed(pool_present: bool, decommission_present: bool, terminal: bool) -> Result<()> {
|
fn ensure_decommission_cancel_allowed(pool_present: bool, decommission_present: bool, terminal: bool) -> Result<()> {
|
||||||
if !pool_present {
|
if !pool_present {
|
||||||
return Err(invalid_decommission_request("failed to cancel decommission: target pool was not found"));
|
return Err(Error::other("failed to cancel decommission: target pool was not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !decommission_present || terminal {
|
if !decommission_present || terminal {
|
||||||
@@ -4345,7 +4287,7 @@ fn ensure_decommission_clear_allowed(
|
|||||||
unresolved_entries: usize,
|
unresolved_entries: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !pool_present {
|
if !pool_present {
|
||||||
return Err(invalid_decommission_request("failed to clear decommission: target pool was not found"));
|
return Err(Error::other("failed to clear decommission: target pool was not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !decommission_present {
|
if !decommission_present {
|
||||||
@@ -4361,7 +4303,7 @@ fn ensure_decommission_clear_allowed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if unresolved_entries > 0 {
|
if unresolved_entries > 0 {
|
||||||
return Err(invalid_decommission_request(format!(
|
return Err(Error::other(format!(
|
||||||
"failed to clear decommission: {unresolved_entries} unresolved listing entries must be reconciled by retrying decommission"
|
"failed to clear decommission: {unresolved_entries} unresolved listing entries must be reconciled by retrying decommission"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -4371,7 +4313,7 @@ fn ensure_decommission_clear_allowed(
|
|||||||
|
|
||||||
fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation: &str) -> Result<()> {
|
fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation: &str) -> Result<()> {
|
||||||
if single_pool {
|
if single_pool {
|
||||||
return Err(invalid_decommission_request(format!(
|
return Err(Error::other(format!(
|
||||||
"failed to {operation}: single pool deployments do not support decommission"
|
"failed to {operation}: single pool deployments do not support decommission"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -4381,9 +4323,7 @@ fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation
|
|||||||
|
|
||||||
fn validate_start_decommission_request(indices: &[usize], single_pool: bool) -> Result<()> {
|
fn validate_start_decommission_request(indices: &[usize], single_pool: bool) -> Result<()> {
|
||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
return Err(invalid_decommission_request(
|
return Err(Error::other("failed to start decommission: no target pools were provided"));
|
||||||
"failed to start decommission: no target pools were provided",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_decommission_terminal_operation_supported(single_pool, "start decommission")
|
ensure_decommission_terminal_operation_supported(single_pool, "start decommission")
|
||||||
@@ -4522,41 +4462,9 @@ pub(crate) struct PoolMetaWriteState {
|
|||||||
cluster_epoch: Option<u64>,
|
cluster_epoch: Option<u64>,
|
||||||
pool_meta_absent: bool,
|
pool_meta_absent: bool,
|
||||||
bootstrap_authority: PoolMetaBootstrapAuthority,
|
bootstrap_authority: PoolMetaBootstrapAuthority,
|
||||||
/// First-hand bootstrap authority this process holds for each pool
|
|
||||||
/// (index = pool index): `Fresh` only for pools it formatted itself,
|
|
||||||
/// `LegacyAdoption` only for pools whose migration it verified. Empty when
|
|
||||||
/// the caller tracks deployment-wide authority only.
|
|
||||||
pool_bootstrap_authorities: Vec<PoolMetaBootstrapAuthority>,
|
|
||||||
/// Whether this process hosts the first endpoint of the first pool and is
|
|
||||||
/// therefore the only writer allowed to publish the initial `pool.bin`.
|
|
||||||
/// `None` when the caller did not say; unknown writers are treated as
|
|
||||||
/// elected so every fail-closed rule still applies to them.
|
|
||||||
elected_bootstrap_writer: Option<bool>,
|
|
||||||
identity_initialized: Option<bool>,
|
identity_initialized: Option<bool>,
|
||||||
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
||||||
identity_needs_repair: bool,
|
identity_needs_repair: bool,
|
||||||
/// At least one pool has no identity replica at all.
|
|
||||||
identity_replicas_missing: bool,
|
|
||||||
/// At least one pool has a replica that is present but not a valid identity.
|
|
||||||
identity_replicas_invalid: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why an all-missing `pool.bin` set may not be initialized right now.
|
|
||||||
enum MissingMetadataRejection {
|
|
||||||
/// Another node still has to act (mint, attest, or publish); retrying the
|
|
||||||
/// startup loop is the remedy, so the write gate stays open.
|
|
||||||
BootstrapPending(Error),
|
|
||||||
/// The durable state contradicts a fresh bootstrap; writes stay blocked
|
|
||||||
/// until an operator recovers the metadata.
|
|
||||||
RecoveryRequired(Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MissingMetadataRejection {
|
|
||||||
fn into_error(self) -> Error {
|
|
||||||
match self {
|
|
||||||
Self::BootstrapPending(err) | Self::RecoveryRequired(err) => err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -4588,7 +4496,6 @@ impl PoolMetaWriteState {
|
|||||||
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
|
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn for_startup_with_bootstrap_authority(
|
pub(crate) fn for_startup_with_bootstrap_authority(
|
||||||
cluster_id: uuid::Uuid,
|
cluster_id: uuid::Uuid,
|
||||||
bootstrap_authority: PoolMetaBootstrapAuthority,
|
bootstrap_authority: PoolMetaBootstrapAuthority,
|
||||||
@@ -4600,82 +4507,10 @@ impl PoolMetaWriteState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Startup state for a process that loaded every pool format itself and
|
|
||||||
/// remembers, per pool, whether it created (or adopted) that pool
|
|
||||||
/// first-hand. Deployment-wide authority is the conjunction across pools:
|
|
||||||
/// any pool this process merely read yields `None`, exactly as before.
|
|
||||||
pub(crate) fn for_startup_with_pool_bootstrap_authorities(
|
|
||||||
cluster_id: uuid::Uuid,
|
|
||||||
pool_bootstrap_authorities: Vec<PoolMetaBootstrapAuthority>,
|
|
||||||
elected_bootstrap_writer: bool,
|
|
||||||
) -> Self {
|
|
||||||
let bootstrap_authority = pool_bootstrap_authorities
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.reduce(PoolMetaBootstrapAuthority::combine_across_pools)
|
|
||||||
.unwrap_or_default();
|
|
||||||
Self {
|
|
||||||
expected_cluster_id: Some(cluster_id),
|
|
||||||
bootstrap_authority,
|
|
||||||
pool_bootstrap_authorities,
|
|
||||||
elected_bootstrap_writer: Some(elected_bootstrap_writer),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
|
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
|
||||||
self.bootstrap_authority.is_proven()
|
self.bootstrap_authority.is_proven()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pool_bootstrap_authority_proven(&self, pool_idx: usize) -> bool {
|
|
||||||
self.pool_bootstrap_authorities
|
|
||||||
.get(pool_idx)
|
|
||||||
.is_some_and(|authority| authority.is_proven())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pools this process formatted or adopted first-hand during this startup.
|
|
||||||
pub(crate) fn attested_pool_indices(&self) -> Vec<usize> {
|
|
||||||
self.pool_bootstrap_authorities
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, authority)| authority.is_proven())
|
|
||||||
.map(|(pool_idx, _)| pool_idx)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deployment-level proof assembled from per-pool creators: this process
|
|
||||||
/// created the first pool itself, and every pool replica carries the same
|
|
||||||
/// pending identity. A pending replica is only ever written by the process
|
|
||||||
/// that formatted that pool with first-hand proof (see
|
|
||||||
/// [`PoolMetaIdentityWriteScope::Pools`]), so a complete, agreeing pending
|
|
||||||
/// set proves that every pool joined this bootstrap fresh. A missing,
|
|
||||||
/// corrupt, or disagreeing replica keeps the writer fail-closed, and a
|
|
||||||
/// restart without first-hand proof never reopens bootstrap on its own.
|
|
||||||
fn pending_identity_attested_by_every_pool(&self) -> bool {
|
|
||||||
self.elected_bootstrap_writer == Some(true)
|
|
||||||
&& self.pool_bootstrap_authority_proven(0)
|
|
||||||
&& self.identity_initialized == Some(false)
|
|
||||||
&& !self.identity_needs_repair
|
|
||||||
&& self.identity_fresh_bootstrap_nonce.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The elected writer minted (or holds) the nonce and the only thing
|
|
||||||
/// standing between it and a complete attestation is a pool whose creator
|
|
||||||
/// has not written its replica yet. Corrupt replicas are never transient.
|
|
||||||
fn awaiting_creator_attestation(&self) -> bool {
|
|
||||||
self.elected_bootstrap_writer == Some(true)
|
|
||||||
&& self.pool_bootstrap_authority_proven(0)
|
|
||||||
&& self.identity_initialized == Some(false)
|
|
||||||
&& self.identity_fresh_bootstrap_nonce.is_some()
|
|
||||||
&& self.identity_needs_repair
|
|
||||||
&& self.identity_replicas_missing
|
|
||||||
&& !self.identity_replicas_invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_non_elected_bootstrap_observer(&self) -> bool {
|
|
||||||
self.elected_bootstrap_writer == Some(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn identity_is_pending(&self) -> bool {
|
pub(crate) fn identity_is_pending(&self) -> bool {
|
||||||
self.identity_initialized == Some(false)
|
self.identity_initialized == Some(false)
|
||||||
}
|
}
|
||||||
@@ -4795,19 +4630,9 @@ impl PoolMetaWriteState {
|
|||||||
self.identity_needs_repair = selection.needs_repair;
|
self.identity_needs_repair = selection.needs_repair;
|
||||||
self.identity_initialized = selection.identity.map(|identity| identity.initialized);
|
self.identity_initialized = selection.identity.map(|identity| identity.initialized);
|
||||||
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
|
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
|
||||||
self.identity_replicas_missing = selection
|
|
||||||
.cas_tokens
|
|
||||||
.iter()
|
|
||||||
.any(|token| matches!(token, PoolMetaCasToken::Missing));
|
|
||||||
self.identity_replicas_invalid = selection
|
|
||||||
.valid_replicas
|
|
||||||
.iter()
|
|
||||||
.zip(&selection.cas_tokens)
|
|
||||||
.any(|(valid, token)| !valid && !matches!(token, PoolMetaCasToken::Missing));
|
|
||||||
if let Some(identity) = selection.identity {
|
if let Some(identity) = selection.identity {
|
||||||
if identity.initialized {
|
if identity.initialized {
|
||||||
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
|
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
|
||||||
self.pool_bootstrap_authorities.clear();
|
|
||||||
}
|
}
|
||||||
if let Some(metadata_epoch) = self.cluster_epoch
|
if let Some(metadata_epoch) = self.cluster_epoch
|
||||||
&& metadata_epoch != identity.epoch
|
&& metadata_epoch != identity.epoch
|
||||||
@@ -4830,44 +4655,22 @@ impl PoolMetaWriteState {
|
|||||||
if !self.pool_meta_absent {
|
if !self.pool_meta_absent {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match self.validate_missing_metadata_can_initialize() {
|
self.validate_missing_metadata_can_initialize()
|
||||||
Ok(()) => Ok(()),
|
.map_err(|err| block_pool_meta_validation(self, err, "metadata_absence"))
|
||||||
Err(MissingMetadataRejection::BootstrapPending(err)) => Err(err),
|
|
||||||
Err(MissingMetadataRejection::RecoveryRequired(err)) => {
|
|
||||||
Err(block_pool_meta_validation(self, err, "metadata_absence"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_missing_metadata_can_initialize(&self) -> std::result::Result<(), MissingMetadataRejection> {
|
fn validate_missing_metadata_can_initialize(&self) -> Result<()> {
|
||||||
use MissingMetadataRejection::{BootstrapPending, RecoveryRequired};
|
|
||||||
match self.identity_initialized {
|
match self.identity_initialized {
|
||||||
Some(false)
|
Some(false) if self.bootstrap_identity_proven() && self.identity_fresh_bootstrap_nonce.is_some() => Ok(()),
|
||||||
if self.identity_fresh_bootstrap_nonce.is_some()
|
Some(false) => Err(Error::other(
|
||||||
&& (self.bootstrap_identity_proven() || self.pending_identity_attested_by_every_pool()) =>
|
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof or legacy-adoption proof",
|
||||||
{
|
)),
|
||||||
Ok(())
|
Some(true) => Err(Error::other(
|
||||||
}
|
|
||||||
Some(false) if self.awaiting_creator_attestation() => Err(BootstrapPending(Error::other(
|
|
||||||
"pool metadata bootstrap pending: waiting for every pool creator to attest the pending cluster identity",
|
|
||||||
))),
|
|
||||||
Some(false) if self.is_non_elected_bootstrap_observer() && self.identity_fresh_bootstrap_nonce.is_some() => {
|
|
||||||
Err(BootstrapPending(Error::other(
|
|
||||||
"pool metadata bootstrap pending: waiting for the elected writer to publish the initial pool.bin",
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Some(false) => Err(RecoveryRequired(Error::other(
|
|
||||||
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof, legacy-adoption proof, or complete per-pool creator attestation",
|
|
||||||
))),
|
|
||||||
Some(true) => Err(RecoveryRequired(Error::other(
|
|
||||||
"pool metadata recovery required: initialized cluster identity exists but every pool.bin replica is missing",
|
"pool metadata recovery required: initialized cluster identity exists but every pool.bin replica is missing",
|
||||||
))),
|
)),
|
||||||
None if self.is_non_elected_bootstrap_observer() => Err(BootstrapPending(Error::other(
|
None => Err(Error::other(
|
||||||
"pool metadata bootstrap pending: waiting for the elected writer to establish the cluster identity",
|
|
||||||
))),
|
|
||||||
None => Err(RecoveryRequired(Error::other(
|
|
||||||
"pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available",
|
"pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available",
|
||||||
))),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5578,9 +5381,7 @@ where
|
|||||||
write_state.validate_selection(&selection)?;
|
write_state.validate_selection(&selection)?;
|
||||||
selection.replica_state.ensure_write_safe(operation)?;
|
selection.replica_state.ensure_write_safe(operation)?;
|
||||||
if selection.absent && (write_state.expected_cluster_id.is_some() || write_state.identity_initialized.is_some()) {
|
if selection.absent && (write_state.expected_cluster_id.is_some() || write_state.identity_initialized.is_some()) {
|
||||||
write_state
|
write_state.validate_missing_metadata_can_initialize()?;
|
||||||
.validate_missing_metadata_can_initialize()
|
|
||||||
.map_err(MissingMetadataRejection::into_error)?;
|
|
||||||
}
|
}
|
||||||
Ok(selection)
|
Ok(selection)
|
||||||
}
|
}
|
||||||
@@ -5682,8 +5483,6 @@ struct PoolMetaIdentitySelection {
|
|||||||
needs_repair: bool,
|
needs_repair: bool,
|
||||||
repair_write_safe: bool,
|
repair_write_safe: bool,
|
||||||
cas_tokens: Vec<PoolMetaCasToken>,
|
cas_tokens: Vec<PoolMetaCasToken>,
|
||||||
/// Per pool: whether the replica decoded as a valid identity.
|
|
||||||
valid_replicas: Vec<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_pool_meta_identity(identity: PersistedPoolMetaIdentity) -> Result<Vec<u8>> {
|
fn encode_pool_meta_identity(identity: PersistedPoolMetaIdentity) -> Result<Vec<u8>> {
|
||||||
@@ -5732,17 +5531,6 @@ pub(crate) fn pool_meta_identity_initialized_for_test(data: &[u8]) -> Result<boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn pending_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64, nonce: uuid::Uuid) -> Result<Vec<u8>> {
|
|
||||||
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
|
||||||
version: POOL_META_IDENTITY_VERSION,
|
|
||||||
cluster_id,
|
|
||||||
epoch,
|
|
||||||
initialized: false,
|
|
||||||
fresh_bootstrap_nonce: Some(nonce),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn initialized_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64) -> Result<Vec<u8>> {
|
pub(crate) fn initialized_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64) -> Result<Vec<u8>> {
|
||||||
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||||
@@ -5783,10 +5571,6 @@ fn select_pool_meta_identity(
|
|||||||
expected_cluster_id: uuid::Uuid,
|
expected_cluster_id: uuid::Uuid,
|
||||||
) -> Result<PoolMetaIdentitySelection> {
|
) -> Result<PoolMetaIdentitySelection> {
|
||||||
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
||||||
let valid_replicas = reads
|
|
||||||
.iter()
|
|
||||||
.map(|read| matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
|
||||||
.collect();
|
|
||||||
let mut selected: Option<PersistedPoolMetaIdentity> = None;
|
let mut selected: Option<PersistedPoolMetaIdentity> = None;
|
||||||
let mut needs_repair = false;
|
let mut needs_repair = false;
|
||||||
let mut repair_write_safe = true;
|
let mut repair_write_safe = true;
|
||||||
@@ -5844,7 +5628,6 @@ fn select_pool_meta_identity(
|
|||||||
needs_repair,
|
needs_repair,
|
||||||
repair_write_safe,
|
repair_write_safe,
|
||||||
cas_tokens,
|
cas_tokens,
|
||||||
valid_replicas,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6114,43 +5897,10 @@ where
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which pool replicas a cluster-identity write may touch.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum PoolMetaIdentityWriteScope<'a> {
|
|
||||||
/// Every pool. Creating a pending identity here requires deployment-wide
|
|
||||||
/// fresh-bootstrap or legacy-adoption proof.
|
|
||||||
All,
|
|
||||||
/// Only the listed pools, each of which this process formatted or adopted
|
|
||||||
/// first-hand. Multi-pool bootstraps whose pools have distinct format
|
|
||||||
/// creators use this scope: the first pool's creator mints the deployment
|
|
||||||
/// nonce and every other creator copies it to its own pool, so the elected
|
|
||||||
/// writer can verify a complete, agreeing pending set instead of trusting
|
|
||||||
/// an in-process flag it cannot observe on another node.
|
|
||||||
Pools(&'a [usize]),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identity_write_satisfied(
|
|
||||||
selection: &PoolMetaIdentitySelection,
|
|
||||||
identity: PersistedPoolMetaIdentity,
|
|
||||||
scope: PoolMetaIdentityWriteScope<'_>,
|
|
||||||
targets: &[usize],
|
|
||||||
) -> bool {
|
|
||||||
if selection.identity != Some(identity) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match scope {
|
|
||||||
PoolMetaIdentityWriteScope::All => !selection.needs_repair,
|
|
||||||
PoolMetaIdentityWriteScope::Pools(_) => targets
|
|
||||||
.iter()
|
|
||||||
.all(|pool_idx| selection.valid_replicas.get(*pool_idx).copied().unwrap_or(false)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn persist_pool_meta_identity<S>(
|
async fn persist_pool_meta_identity<S>(
|
||||||
pools: Vec<Arc<S>>,
|
pools: Vec<Arc<S>>,
|
||||||
write_state: &mut PoolMetaWriteState,
|
write_state: &mut PoolMetaWriteState,
|
||||||
initialized: bool,
|
initialized: bool,
|
||||||
scope: PoolMetaIdentityWriteScope<'_>,
|
|
||||||
fence: &PoolMetaPersistenceFence<'_>,
|
fence: &PoolMetaPersistenceFence<'_>,
|
||||||
transaction_arm: &mut PoolMetaTransactionArm,
|
transaction_arm: &mut PoolMetaTransactionArm,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
@@ -6160,23 +5910,6 @@ where
|
|||||||
let Some(cluster_id) = write_state.expected_cluster_id else {
|
let Some(cluster_id) = write_state.expected_cluster_id else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let targets: Vec<usize> = match scope {
|
|
||||||
PoolMetaIdentityWriteScope::All => (0..pools.len()).collect(),
|
|
||||||
PoolMetaIdentityWriteScope::Pools(indices) => {
|
|
||||||
if initialized {
|
|
||||||
return Err(Error::other("pool metadata identity commit must address every pool"));
|
|
||||||
}
|
|
||||||
if indices
|
|
||||||
.iter()
|
|
||||||
.any(|pool_idx| *pool_idx >= pools.len() || !write_state.pool_bootstrap_authority_proven(*pool_idx))
|
|
||||||
{
|
|
||||||
return Err(Error::other(
|
|
||||||
"pool metadata recovery required: a pending cluster identity can only be attested for pools this startup formatted or adopted first-hand",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
indices.to_vec()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
||||||
let selection = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
let selection = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
||||||
if !selection.repair_write_safe {
|
if !selection.repair_write_safe {
|
||||||
@@ -6185,39 +5918,20 @@ where
|
|||||||
"pool metadata recovery required: cluster identity has an unreadable replica",
|
"pool metadata recovery required: cluster identity has an unreadable replica",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let identity = match (selection.identity, scope) {
|
let identity = match selection.identity {
|
||||||
// An initialized deployment (for example a pool expansion) never
|
Some(identity) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
||||||
// reopens bootstrap: first-hand proof for a new pool is not a
|
|
||||||
// reason to publish a pending identity.
|
|
||||||
(Some(identity), PoolMetaIdentityWriteScope::Pools(_)) if identity.initialized => return Ok(()),
|
|
||||||
(Some(identity), _) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
|
||||||
initialized: true,
|
initialized: true,
|
||||||
fresh_bootstrap_nonce: None,
|
fresh_bootstrap_nonce: None,
|
||||||
..identity
|
..identity
|
||||||
},
|
},
|
||||||
(Some(identity), _) => identity,
|
Some(identity) => identity,
|
||||||
// Only the first pool's creator mints the deployment nonce; every
|
None if !initialized && !write_state.bootstrap_identity_proven() => {
|
||||||
// other creator waits until it is durable and copies it, so two
|
|
||||||
// concurrent creators can never publish disagreeing replicas.
|
|
||||||
(None, PoolMetaIdentityWriteScope::Pools(indices)) => {
|
|
||||||
if !indices.contains(&0) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
PersistedPoolMetaIdentity {
|
|
||||||
version: POOL_META_IDENTITY_VERSION,
|
|
||||||
cluster_id,
|
|
||||||
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
|
||||||
initialized: false,
|
|
||||||
fresh_bootstrap_nonce: Some(uuid::Uuid::new_v4()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, PoolMetaIdentityWriteScope::All) if !initialized && !write_state.bootstrap_identity_proven() => {
|
|
||||||
write_state.block_writes();
|
write_state.block_writes();
|
||||||
return Err(Error::other(
|
return Err(Error::other(
|
||||||
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
|
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
(None, PoolMetaIdentityWriteScope::All) => PersistedPoolMetaIdentity {
|
None => PersistedPoolMetaIdentity {
|
||||||
version: POOL_META_IDENTITY_VERSION,
|
version: POOL_META_IDENTITY_VERSION,
|
||||||
cluster_id,
|
cluster_id,
|
||||||
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
||||||
@@ -6225,15 +5939,12 @@ where
|
|||||||
fresh_bootstrap_nonce: (!initialized).then(uuid::Uuid::new_v4),
|
fresh_bootstrap_nonce: (!initialized).then(uuid::Uuid::new_v4),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if identity_write_satisfied(&selection, identity, scope, &targets) {
|
if selection.identity == Some(identity) && !selection.needs_repair {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let data = encode_pool_meta_identity(identity)?;
|
let data = encode_pool_meta_identity(identity)?;
|
||||||
let mut conflict = false;
|
let mut conflict = false;
|
||||||
for (pool_idx, (pool, token)) in pools.iter().cloned().zip(&selection.cas_tokens).enumerate() {
|
for (pool, token) in pools.iter().cloned().zip(&selection.cas_tokens) {
|
||||||
if !targets.contains(&pool_idx) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match save_pool_meta_object_cas(
|
match save_pool_meta_object_cas(
|
||||||
pool,
|
pool,
|
||||||
POOL_META_IDENTITY_NAME,
|
POOL_META_IDENTITY_NAME,
|
||||||
@@ -6260,7 +5971,7 @@ where
|
|||||||
return Err(Error::PreconditionFailed);
|
return Err(Error::PreconditionFailed);
|
||||||
}
|
}
|
||||||
let confirmed = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
let confirmed = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
||||||
if identity_write_satisfied(&confirmed, identity, scope, &targets) {
|
if confirmed.identity == Some(identity) && !confirmed.needs_repair {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6292,34 +6003,6 @@ where
|
|||||||
pools,
|
pools,
|
||||||
write_state,
|
write_state,
|
||||||
initialized,
|
initialized,
|
||||||
PoolMetaIdentityWriteScope::All,
|
|
||||||
&PoolMetaPersistenceFence::Distributed(None),
|
|
||||||
&mut transaction_arm,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
transaction_arm.disarm();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attest, during startup, the pending cluster identity for the pools this
|
|
||||||
/// process formatted or adopted first-hand. The first pool's creator mints the
|
|
||||||
/// deployment nonce; every other creator copies it once it is durable. Nothing
|
|
||||||
/// is written while the deployment is already initialized or while the nonce
|
|
||||||
/// is not yet durable, so callers simply retry through the startup loop.
|
|
||||||
pub(crate) async fn persist_pool_meta_identity_for_attested_pools<S>(
|
|
||||||
pools: Vec<Arc<S>>,
|
|
||||||
write_state: &mut PoolMetaWriteState,
|
|
||||||
pool_indices: &[usize],
|
|
||||||
) -> Result<()>
|
|
||||||
where
|
|
||||||
S: EcstoreObjectIO,
|
|
||||||
{
|
|
||||||
let mut transaction_arm = write_state.arm_transaction();
|
|
||||||
persist_pool_meta_identity(
|
|
||||||
pools,
|
|
||||||
write_state,
|
|
||||||
false,
|
|
||||||
PoolMetaIdentityWriteScope::Pools(pool_indices),
|
|
||||||
&PoolMetaPersistenceFence::Distributed(None),
|
&PoolMetaPersistenceFence::Distributed(None),
|
||||||
&mut transaction_arm,
|
&mut transaction_arm,
|
||||||
)
|
)
|
||||||
@@ -6935,15 +6618,7 @@ where
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
persist_pool_meta_identity(
|
persist_pool_meta_identity(pools.clone(), write_state, true, fence, &mut transaction_arm).await?;
|
||||||
pools.clone(),
|
|
||||||
write_state,
|
|
||||||
true,
|
|
||||||
PoolMetaIdentityWriteScope::All,
|
|
||||||
fence,
|
|
||||||
&mut transaction_arm,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let confirmed = load_pool_meta_for_transaction_recovery(pools, write_state).await?;
|
let confirmed = load_pool_meta_for_transaction_recovery(pools, write_state).await?;
|
||||||
if confirmed.revision != expected_revision
|
if confirmed.revision != expected_revision
|
||||||
|| confirmed.canonical.as_ref() != Some(&expected_canonical)
|
|| confirmed.canonical.as_ref() != Some(&expected_canonical)
|
||||||
@@ -7076,15 +6751,6 @@ impl PoolMeta {
|
|||||||
.is_some_and(is_decommission_suspended)
|
.is_some_and(is_decommission_suspended)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn scanner_pause_backlog_pool_writable(&self, idx: usize) -> bool {
|
|
||||||
self.pools.get(idx).is_some_and(|pool| {
|
|
||||||
!pool
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|info| info.has_decommission_state() && !info.failed && !info.canceled)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_decommission_progress_saved(&mut self) {
|
fn mark_decommission_progress_saved(&mut self) {
|
||||||
for pool in &mut self.pools {
|
for pool in &mut self.pools {
|
||||||
if let Some(info) = pool.decommission.as_mut() {
|
if let Some(info) = pool.decommission.as_mut() {
|
||||||
@@ -7569,15 +7235,7 @@ impl PoolMeta {
|
|||||||
}
|
}
|
||||||
if !selection.absent && write_state.identity_requires_repair() {
|
if !selection.absent && write_state.identity_requires_repair() {
|
||||||
let initialized = write_state.identity_initialized != Some(false) || selection.revision.is_generation_protocol();
|
let initialized = write_state.identity_initialized != Some(false) || selection.revision.is_generation_protocol();
|
||||||
persist_pool_meta_identity(
|
persist_pool_meta_identity(pools.clone(), write_state, initialized, fence, transaction_arm).await?;
|
||||||
pools.clone(),
|
|
||||||
write_state,
|
|
||||||
initialized,
|
|
||||||
PoolMetaIdentityWriteScope::All,
|
|
||||||
fence,
|
|
||||||
transaction_arm,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Startup is the only path allowed to create an all-missing metadata
|
// Startup is the only path allowed to create an all-missing metadata
|
||||||
@@ -7767,7 +7425,7 @@ impl PoolMeta {
|
|||||||
confirmed
|
confirmed
|
||||||
};
|
};
|
||||||
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
||||||
persist_pool_meta_identity(pools, write_state, true, PoolMetaIdentityWriteScope::All, fence, transaction_arm).await?;
|
persist_pool_meta_identity(pools, write_state, true, fence, transaction_arm).await?;
|
||||||
#[cfg(feature = "e2e-test-hooks")]
|
#[cfg(feature = "e2e-test-hooks")]
|
||||||
startup_cas_test_observe(serde_json::json!({
|
startup_cas_test_observe(serde_json::json!({
|
||||||
"kind": "confirmed", "object": POOL_META_NAME,
|
"kind": "confirmed", "object": POOL_META_NAME,
|
||||||
@@ -10102,10 +9760,10 @@ impl ECStore {
|
|||||||
pub(crate) async fn acquire_external_decommission_capacity_fence(
|
pub(crate) async fn acquire_external_decommission_capacity_fence(
|
||||||
&self,
|
&self,
|
||||||
target_pool_indices: &[usize],
|
target_pool_indices: &[usize],
|
||||||
admission: DecommissionCapacityAdmission,
|
phase: &'static str,
|
||||||
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, admission)
|
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, phase)
|
||||||
.await?
|
.await?
|
||||||
.0)
|
.0)
|
||||||
}
|
}
|
||||||
@@ -10113,14 +9771,14 @@ impl ECStore {
|
|||||||
pub(crate) async fn acquire_external_decommission_capacity_fence_with_active_source(
|
pub(crate) async fn acquire_external_decommission_capacity_fence_with_active_source(
|
||||||
&self,
|
&self,
|
||||||
target_pool_indices: &[usize],
|
target_pool_indices: &[usize],
|
||||||
admission: DecommissionCapacityAdmission,
|
phase: &'static str,
|
||||||
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
||||||
let save_guard = self.pool_meta_save_gate.lock().await;
|
let save_guard = self.pool_meta_save_gate.lock().await;
|
||||||
let (pool_meta_guard, snapshot) = self
|
let (pool_meta_guard, snapshot) = self
|
||||||
.acquire_pool_meta_read_guard(&save_guard, "target capacity admission failed")
|
.acquire_pool_meta_read_guard(&save_guard, "target capacity admission failed")
|
||||||
.await?;
|
.await?;
|
||||||
for target_pool_index in target_pool_indices.iter().copied() {
|
for target_pool_index in target_pool_indices.iter().copied() {
|
||||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, admission)?;
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, phase)?;
|
||||||
}
|
}
|
||||||
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
||||||
drop(save_guard);
|
drop(save_guard);
|
||||||
@@ -10142,9 +9800,7 @@ impl ECStore {
|
|||||||
let admissions = target_pool_indices
|
let admissions = target_pool_indices
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.map(|target_pool_index| {
|
.map(|target_pool_index| ensure_external_decommission_target_admission(&snapshot, target_pool_index, "heal"))
|
||||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, DecommissionCapacityAdmission::Heal)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
drop(save_guard);
|
drop(save_guard);
|
||||||
Ok((pool_meta_guard, admissions))
|
Ok((pool_meta_guard, admissions))
|
||||||
@@ -10818,7 +10474,7 @@ impl ECStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let Some((owner, model_version)) = admitted_owner else {
|
let Some((owner, model_version)) = admitted_owner else {
|
||||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, DecommissionCapacityAdmission::Mutation)?;
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, "mutation")?;
|
||||||
drop(save_guard);
|
drop(save_guard);
|
||||||
let capacity_lease = read_guard.lock_lost_signal();
|
let capacity_lease = read_guard.lock_lost_signal();
|
||||||
return operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
return operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
||||||
@@ -20982,17 +20638,17 @@ mod pools_tests {
|
|||||||
DecommissionStartPoolState, DecommissionTargetConsumption, DecommissionTerminalState, DecommissionUnresolvedEntry,
|
DecommissionStartPoolState, DecommissionTargetConsumption, DecommissionTerminalState, DecommissionUnresolvedEntry,
|
||||||
ListCallback, POOL_META_GENERATION_VERSION, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_V1_VERSION,
|
ListCallback, POOL_META_GENERATION_VERSION, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_V1_VERSION,
|
||||||
POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolMetaCasToken, PoolMetaPersistenceFence, PoolSpaceInfo, PoolStatus,
|
POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolMetaCasToken, PoolMetaPersistenceFence, PoolSpaceInfo, PoolStatus,
|
||||||
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, active_decommission_source_indices,
|
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, apply_decommission_status_space_info,
|
||||||
apply_decommission_status_space_info, await_decommission_worker, bind_decommission_cancelers,
|
await_decommission_worker, bind_decommission_cancelers, bind_missing_decommission_cancelers,
|
||||||
bind_missing_decommission_cancelers, build_decommission_capacity_reservation,
|
build_decommission_capacity_reservation, build_decommission_capacity_reservation_with_model,
|
||||||
build_decommission_capacity_reservation_with_model, cancel_decommission_canceler, clamp_decommission_entry_concurrency,
|
cancel_decommission_canceler, clamp_decommission_entry_concurrency, classify_decommission_terminal_state,
|
||||||
classify_decommission_terminal_state, count_decommission_item, decommission_cancel_signal_result,
|
count_decommission_item, decommission_cancel_signal_result, decommission_durable_ilm_receipt_path,
|
||||||
decommission_durable_ilm_receipt_path, decommission_durable_ilm_receipt_run_prefix,
|
decommission_durable_ilm_receipt_run_prefix, decommission_durable_ilm_receipt_run_token,
|
||||||
decommission_durable_ilm_receipt_run_token, decommission_entry_queue_capacity, decommission_item_size,
|
decommission_entry_queue_capacity, decommission_item_size, decommission_meta_bucket_options,
|
||||||
decommission_meta_bucket_options, decommission_physical_pool_capacity, decommission_retry_backoff_delay,
|
decommission_physical_pool_capacity, decommission_retry_backoff_delay, decommission_start_pool_state,
|
||||||
decommission_start_pool_state, decommission_unresolved_listing_error, dedup_indices,
|
decommission_unresolved_listing_error, dedup_indices, default_decommission_bucket_concurrency,
|
||||||
default_decommission_bucket_concurrency, default_decommission_entry_concurrency, drain_decommission_entry_queue,
|
default_decommission_entry_concurrency, drain_decommission_entry_queue, enqueue_decommission_entry,
|
||||||
enqueue_decommission_entry, ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_available,
|
ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_available,
|
||||||
ensure_decommission_clear_allowed, ensure_decommission_generation, ensure_decommission_listing_disks_available,
|
ensure_decommission_clear_allowed, ensure_decommission_generation, ensure_decommission_listing_disks_available,
|
||||||
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
||||||
ensure_decommission_start_local_leader, ensure_decommission_start_pool_states,
|
ensure_decommission_start_local_leader, ensure_decommission_start_pool_states,
|
||||||
@@ -21028,13 +20684,12 @@ mod pools_tests {
|
|||||||
with_decommission_entry_context,
|
with_decommission_entry_context,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
DecommissionCapacityAdmission, DecommissionCapacityOwner, DecommissionCapacityReleaseProof,
|
DecommissionCapacityOwner, DecommissionCapacityReleaseProof, DecommissionCapacityReservation,
|
||||||
DecommissionCapacityReservation, DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id,
|
DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id, ensure_decommission_target_owner_admission,
|
||||||
ensure_decommission_target_owner_admission, ensure_exact_delete_capacity_namespace_fences,
|
ensure_exact_delete_capacity_namespace_fences, ensure_external_decommission_target_admission,
|
||||||
ensure_external_decommission_target_admission, is_decommission_capacity_blocked_error,
|
is_decommission_capacity_blocked_error, plan_exact_delete_capacity_reconciliations,
|
||||||
plan_exact_delete_capacity_reconciliations, record_decommission_target_consumption, release_decommission_target_inflight,
|
record_decommission_target_consumption, release_decommission_target_inflight, reserve_decommission_target_pending,
|
||||||
reserve_decommission_target_pending, resolve_decommission_target_pending,
|
resolve_decommission_target_pending, set_decommission_capacity_info_overrides_for_test,
|
||||||
set_decommission_capacity_info_overrides_for_test,
|
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::{
|
use crate::bucket::lifecycle::{
|
||||||
DurableIlmRecordCheckpoint,
|
DurableIlmRecordCheckpoint,
|
||||||
@@ -25188,25 +24843,6 @@ mod pools_tests {
|
|||||||
assert!(!pool_meta_has_active_decommission(&terminal_meta));
|
assert!(!pool_meta_has_active_decommission(&terminal_meta));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decommission_request_rejections_preserve_invalid_argument_type() {
|
|
||||||
for result in [
|
|
||||||
ensure_decommission_start_allowed(DecommissionStartPoolState::Missing),
|
|
||||||
ensure_decommission_start_allowed(DecommissionStartPoolState::Decommissioned),
|
|
||||||
ensure_decommission_start_allowed(DecommissionStartPoolState::Blocked),
|
|
||||||
ensure_decommission_cancel_allowed(false, false, false),
|
|
||||||
ensure_decommission_clear_allowed(false, false, false, false, false, 0),
|
|
||||||
ensure_decommission_clear_allowed(true, true, false, true, false, 1),
|
|
||||||
ensure_decommission_terminal_operation_supported(true, "cancel decommission"),
|
|
||||||
validate_start_decommission_request(&[], false),
|
|
||||||
validate_start_decommission_request(&[0], true),
|
|
||||||
ensure_decommission_start_keeps_active_pool(&PoolMeta::default(), &[]),
|
|
||||||
] {
|
|
||||||
let err = result.expect_err("invalid lifecycle requests must be rejected before mutation");
|
|
||||||
assert!(matches!(&err, Error::InvalidArgument(_, _, reason) if !reason.is_empty()), "{err:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ensure_decommission_start_allowed_rejects_missing_pool() {
|
fn test_ensure_decommission_start_allowed_rejects_missing_pool() {
|
||||||
let err =
|
let err =
|
||||||
@@ -25909,7 +25545,7 @@ mod pools_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ordinary_write_admission_shares_a_reserved_target_without_becoming_its_owner() {
|
fn ordinary_write_admission_cannot_race_into_a_reserved_target() {
|
||||||
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
let capacity_infos = vec![
|
let capacity_infos = vec![
|
||||||
@@ -25933,18 +25569,13 @@ mod pools_tests {
|
|||||||
)
|
)
|
||||||
.expect("the decommission reservation should fit");
|
.expect("the decommission reservation should fit");
|
||||||
|
|
||||||
for admission in [
|
assert!(
|
||||||
DecommissionCapacityAdmission::Mutation,
|
matches!(
|
||||||
DecommissionCapacityAdmission::BatchDelete,
|
ensure_external_decommission_target_admission(&meta, 1, "ordinary_put"),
|
||||||
DecommissionCapacityAdmission::ScannerBacklog,
|
Err(Error::SlowDown)
|
||||||
] {
|
),
|
||||||
ensure_external_decommission_target_admission(&meta, 1, admission)
|
"an ordinary write must not consume a target reservation"
|
||||||
.expect("a healthy target must remain writable while sharing capacity with migration");
|
);
|
||||||
}
|
|
||||||
assert!(matches!(
|
|
||||||
ensure_external_decommission_target_admission(&meta, 1, DecommissionCapacityAdmission::Heal),
|
|
||||||
Err(Error::SlowDown)
|
|
||||||
));
|
|
||||||
let rebalance_opts = ObjectOptions {
|
let rebalance_opts = ObjectOptions {
|
||||||
data_movement: true,
|
data_movement: true,
|
||||||
src_pool_idx: 0,
|
src_pool_idx: 0,
|
||||||
@@ -25971,119 +25602,6 @@ mod pools_tests {
|
|||||||
let mut decommission_opts = rebalance_opts;
|
let mut decommission_opts = rebalance_opts;
|
||||||
expected_owner.apply_to(&mut decommission_opts);
|
expected_owner.apply_to(&mut decommission_opts);
|
||||||
assert_eq!(DecommissionCapacityOwner::from_options(&decommission_opts), Some(expected_owner));
|
assert_eq!(DecommissionCapacityOwner::from_options(&decommission_opts), Some(expected_owner));
|
||||||
|
|
||||||
meta.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_mut()
|
|
||||||
.expect("active source")
|
|
||||||
.capacity_reservation = None;
|
|
||||||
for admission in [
|
|
||||||
DecommissionCapacityAdmission::Mutation,
|
|
||||||
DecommissionCapacityAdmission::BatchDelete,
|
|
||||||
DecommissionCapacityAdmission::ScannerBacklog,
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
matches!(ensure_external_decommission_target_admission(&meta, 1, admission), Err(Error::SlowDown)),
|
|
||||||
"shared capacity must not bypass an active source's missing durable ledger"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn external_decommission_admission_fences_suspended_sources_but_preserves_repair() {
|
|
||||||
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
||||||
let capacity_infos = vec![
|
|
||||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
|
||||||
];
|
|
||||||
let mut active = PoolMeta {
|
|
||||||
version: POOL_META_VERSION,
|
|
||||||
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
active
|
|
||||||
.decommission(0, capacity_infos[0].space)
|
|
||||||
.expect("start the source admission fixture");
|
|
||||||
reserve_decommission_start_target_capacity(
|
|
||||||
&mut active,
|
|
||||||
&[0],
|
|
||||||
&capacity_infos,
|
|
||||||
uuid::Uuid::new_v4(),
|
|
||||||
1,
|
|
||||||
now,
|
|
||||||
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
|
||||||
)
|
|
||||||
.expect("the active source must have a valid reservation to isolate its write fence");
|
|
||||||
|
|
||||||
for (state, queued, failed, canceled, complete) in [
|
|
||||||
("running", false, false, false, false),
|
|
||||||
("queued", true, false, false, false),
|
|
||||||
("failed", false, true, false, false),
|
|
||||||
("canceled", false, false, true, false),
|
|
||||||
("completed", false, false, false, true),
|
|
||||||
] {
|
|
||||||
let mut meta = active.clone();
|
|
||||||
let info = meta.pools[0].decommission.as_mut().expect("the source fixture must exist");
|
|
||||||
info.queued = queued;
|
|
||||||
info.failed = failed;
|
|
||||||
info.canceled = canceled;
|
|
||||||
info.complete = complete;
|
|
||||||
if queued || failed || canceled || complete {
|
|
||||||
info.start_time = None;
|
|
||||||
}
|
|
||||||
for admission in [
|
|
||||||
DecommissionCapacityAdmission::Mutation,
|
|
||||||
DecommissionCapacityAdmission::BatchDelete,
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
matches!(ensure_external_decommission_target_admission(&meta, 0, admission), Err(Error::SlowDown)),
|
|
||||||
"{state} source must reject new publication until its decommission metadata is cleared"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let existing_multipart =
|
|
||||||
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::ExistingMultipart);
|
|
||||||
if active_decommission_source_indices(&meta).contains(&0) {
|
|
||||||
existing_multipart
|
|
||||||
.unwrap_or_else(|err| panic!("{state} source must allow an existing multipart upload to drain: {err}"));
|
|
||||||
} else {
|
|
||||||
assert!(
|
|
||||||
matches!(existing_multipart, Err(Error::SlowDown)),
|
|
||||||
"{state} terminal source must reject an existing multipart publication"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::Heal)
|
|
||||||
.unwrap_or_else(|err| panic!("{state} source repair must retain its capacity-only admission: {err}"));
|
|
||||||
let scanner_result =
|
|
||||||
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::ScannerBacklog);
|
|
||||||
assert_eq!(
|
|
||||||
meta.scanner_pause_backlog_pool_writable(0),
|
|
||||||
failed || canceled,
|
|
||||||
"{state} scanner selection"
|
|
||||||
);
|
|
||||||
if failed || canceled {
|
|
||||||
scanner_result.unwrap_or_else(|err| panic!("{state} scanner membership repair must remain writable: {err}"));
|
|
||||||
} else {
|
|
||||||
assert!(
|
|
||||||
matches!(scanner_result, Err(Error::SlowDown)),
|
|
||||||
"{state} scanner publication must reject its source"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
meta.pools[0].decommission = None;
|
|
||||||
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::Mutation)
|
|
||||||
.unwrap_or_else(|err| panic!("cleared {state} source must become writable again: {err}"));
|
|
||||||
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::ScannerBacklog)
|
|
||||||
.unwrap_or_else(|err| panic!("cleared {state} scanner source must rejoin membership: {err}"));
|
|
||||||
}
|
|
||||||
assert!(!active.scanner_pause_backlog_pool_writable(active.pools.len()));
|
|
||||||
assert!(matches!(
|
|
||||||
ensure_external_decommission_target_admission(
|
|
||||||
&active,
|
|
||||||
active.pools.len(),
|
|
||||||
DecommissionCapacityAdmission::ScannerBacklog
|
|
||||||
),
|
|
||||||
Err(Error::SlowDown)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -570,465 +570,6 @@ mod decommission_lock_order_tests {
|
|||||||
.expect("decommission activation should commit after the probe release");
|
.expect("decommission activation should commit after the probe release");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn staged_external_put_rechecks_retiring_source_on_another_node() {
|
|
||||||
run_large_stack_current_thread_async_test("staged-retiring-source", || async {
|
|
||||||
let (_temp_dirs, store, other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
||||||
let bucket = test_bucket("staged-source");
|
|
||||||
let object = "selected-before-retirement.bin";
|
|
||||||
let original = b"original source object";
|
|
||||||
store
|
|
||||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("create staged source bucket");
|
|
||||||
store.pools[0]
|
|
||||||
.put_object(&bucket, object, &mut PutObjReader::from_vec(original.to_vec()), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("seed the source selected before retirement");
|
|
||||||
|
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
||||||
set_decommission_capacity_info_overrides_for_test(
|
|
||||||
other_store.id,
|
|
||||||
vec![vec![
|
|
||||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
let barrier = DecommissionCapacityLockOrderBarrier::install(store.id, store.id);
|
|
||||||
barrier.pause_external_object_commit_phase();
|
|
||||||
let put_store = Arc::clone(&store);
|
|
||||||
let put_bucket = bucket.clone();
|
|
||||||
let put = tokio::spawn(async move {
|
|
||||||
put_store
|
|
||||||
.put_object(
|
|
||||||
&put_bucket,
|
|
||||||
object,
|
|
||||||
&mut PutObjReader::from_vec(b"must not replace a retiring source".to_vec()),
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_external_object_commit_phase_started())
|
|
||||||
.await
|
|
||||||
.expect("public PUT must stage before its decommission commit probe");
|
|
||||||
assert!(!store.pool_meta.read().await.is_suspended(0));
|
|
||||||
other_store
|
|
||||||
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
||||||
.await
|
|
||||||
.expect("the other node should activate retirement before the staged PUT commits");
|
|
||||||
assert!(other_store.pool_meta.read().await.is_suspended(0));
|
|
||||||
assert!(
|
|
||||||
!store.pool_meta.read().await.is_suspended(0),
|
|
||||||
"the writer's local snapshot must remain stale to exercise the durable admission probe"
|
|
||||||
);
|
|
||||||
barrier.release_external_object_commit_phase();
|
|
||||||
let result = tokio::time::timeout(Duration::from_secs(30), put)
|
|
||||||
.await
|
|
||||||
.expect("staged PUT must finish after the commit probe is released")
|
|
||||||
.expect("staged PUT must not panic");
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(crate::error::Error::SlowDown)),
|
|
||||||
"a staged PUT must retry pool selection instead of committing to a newly retiring source: {result:?}"
|
|
||||||
);
|
|
||||||
let mut reader = store.pools[0]
|
|
||||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("the original source must remain readable after admission rejects the replacement");
|
|
||||||
let mut body = Vec::new();
|
|
||||||
reader
|
|
||||||
.stream
|
|
||||||
.read_to_end(&mut body)
|
|
||||||
.await
|
|
||||||
.expect("read the full retained source body");
|
|
||||||
assert_eq!(body, original);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn reserved_target_shares_business_io_and_retains_source_after_capacity_loss() {
|
|
||||||
run_large_stack_current_thread_async_test("shared-decommission-capacity", || async {
|
|
||||||
for lose_capacity in [false, true] {
|
|
||||||
let (_temp_dirs, store, other_store) =
|
|
||||||
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
|
||||||
let bucket = test_bucket("shared-capacity");
|
|
||||||
let object = "migrating-source.bin";
|
|
||||||
let business_object = "business-write.bin";
|
|
||||||
let multipart_object = "business-multipart.bin";
|
|
||||||
let source_body = vec![0x35; 256 * 1024];
|
|
||||||
let business_body = vec![0x57; 64 * 1024];
|
|
||||||
store
|
|
||||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("create shared-capacity bucket");
|
|
||||||
store.pools[0]
|
|
||||||
.put_object(
|
|
||||||
&bucket,
|
|
||||||
object,
|
|
||||||
&mut PutObjReader::from_vec(source_body.clone()),
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("seed the retiring source");
|
|
||||||
store.pools[2]
|
|
||||||
.put_object(
|
|
||||||
&bucket,
|
|
||||||
business_object,
|
|
||||||
&mut PutObjReader::from_vec(b"previous business value".to_vec()),
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("pin the public overwrite to the migration target");
|
|
||||||
let multipart_opts = ObjectOptions {
|
|
||||||
expected_bucket_incarnation_id: Some(
|
|
||||||
store
|
|
||||||
.bucket_incarnation_id(&bucket)
|
|
||||||
.await
|
|
||||||
.expect("load the multipart bucket identity"),
|
|
||||||
),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let routing_upload = new_multipart_upload(&store, 2, &bucket, multipart_object, multipart_opts.clone())
|
|
||||||
.await
|
|
||||||
.expect("pin subsequent public multipart creation to the migration target");
|
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
||||||
let target_total = source_body.len() * 8;
|
|
||||||
let capacities = vec![
|
|
||||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, source_body.len() * 2, source_body.len() * 2),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
|
||||||
];
|
|
||||||
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
|
|
||||||
store
|
|
||||||
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
||||||
.await
|
|
||||||
.expect("activate source retirement");
|
|
||||||
*other_store.pool_meta.write().await = store.pool_meta.read().await.clone();
|
|
||||||
let before = other_store.pool_meta.read().await.clone();
|
|
||||||
let reservation = before.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.expect("active source")
|
|
||||||
.capacity_reservation
|
|
||||||
.as_ref()
|
|
||||||
.expect("durable reservation");
|
|
||||||
assert_eq!(
|
|
||||||
reservation.model_version, 2,
|
|
||||||
"exercise migration I/O outside the global metadata write lock"
|
|
||||||
);
|
|
||||||
assert_eq!(reservation.targets[0].pool_index, 2);
|
|
||||||
|
|
||||||
let barrier =
|
|
||||||
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
|
||||||
let migration_store = Arc::clone(&store);
|
|
||||||
let migration_bucket = bucket.clone();
|
|
||||||
let migration = tokio::spawn(async move {
|
|
||||||
migration_store
|
|
||||||
.decommission_entry_for_test_with_bucket_incarnation(
|
|
||||||
0,
|
|
||||||
MetaCacheEntry {
|
|
||||||
name: object.to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
migration_bucket,
|
|
||||||
migration_store.pools[0].get_disks_by_key(object),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
|
|
||||||
.await
|
|
||||||
.expect("migration must reach target publication");
|
|
||||||
assert!(!migration.is_finished());
|
|
||||||
let mut pending = crate::core::pools::PoolMeta::default();
|
|
||||||
pending
|
|
||||||
.load_no_lock_from_replicas(other_store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("read migration intent from the other node");
|
|
||||||
let pending_reservation = pending.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.expect("active source")
|
|
||||||
.capacity_reservation
|
|
||||||
.as_ref()
|
|
||||||
.expect("pending reservation")
|
|
||||||
.clone();
|
|
||||||
assert_eq!(pending_reservation.pending_target_physical_bytes, source_body.len());
|
|
||||||
assert_eq!(pending_reservation.consumed_target_physical_bytes, 0);
|
|
||||||
|
|
||||||
tokio::time::timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
other_store.put_object(
|
|
||||||
&bucket,
|
|
||||||
business_object,
|
|
||||||
&mut PutObjReader::from_vec(business_body.clone()),
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("business PUT must finish without waiting for the migration target gate")
|
|
||||||
.expect("a reserved healthy pool must accept ordinary PUT");
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), async {
|
|
||||||
let upload = other_store
|
|
||||||
.new_multipart_upload(&bucket, multipart_object, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("the reserved target must accept public multipart creation");
|
|
||||||
assert_ne!(upload.upload_id, routing_upload.upload_id);
|
|
||||||
let lifecycle_guard = other_store
|
|
||||||
.acquire_bucket_lifecycle_read_lock(&bucket)
|
|
||||||
.await
|
|
||||||
.expect("fence the exact-pool multipart placement check");
|
|
||||||
let mut lookup_opts = multipart_opts.clone();
|
|
||||||
lookup_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
|
|
||||||
other_store.pools[2]
|
|
||||||
.get_multipart_info(&bucket, multipart_object, &upload.upload_id, &lookup_opts)
|
|
||||||
.await
|
|
||||||
.expect("public multipart creation must actually select the reserved target");
|
|
||||||
drop(lifecycle_guard);
|
|
||||||
let mut final_part = None;
|
|
||||||
for payload in [vec![0x18; business_body.len()], business_body.clone()] {
|
|
||||||
final_part = Some(
|
|
||||||
other_store
|
|
||||||
.put_object_part(
|
|
||||||
&bucket,
|
|
||||||
multipart_object,
|
|
||||||
&upload.upload_id,
|
|
||||||
1,
|
|
||||||
&mut PutObjReader::from_vec(payload),
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("the reserved target must accept UploadPart and replacement of the same part"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let part = final_part.expect("the replacement part must be present");
|
|
||||||
Arc::clone(&other_store)
|
|
||||||
.complete_multipart_upload(
|
|
||||||
&bucket,
|
|
||||||
multipart_object,
|
|
||||||
&upload.upload_id,
|
|
||||||
vec![crate::storage_api_contracts::multipart::CompletePart {
|
|
||||||
part_num: part.part_num,
|
|
||||||
etag: part.etag,
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("the reserved target must accept multipart completion");
|
|
||||||
other_store
|
|
||||||
.abort_multipart_upload(&bucket, multipart_object, &routing_upload.upload_id, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("ordinary multipart cleanup must not consume the migration's pending intent");
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("business multipart operations must finish while migration I/O is paused");
|
|
||||||
assert!(!migration.is_finished(), "business publication must overlap paused migration I/O");
|
|
||||||
let mut after_business = crate::core::pools::PoolMeta::default();
|
|
||||||
after_business
|
|
||||||
.load_no_lock_from_replicas(other_store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("reload the shared-capacity ledger");
|
|
||||||
assert_eq!(
|
|
||||||
after_business.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.expect("active source")
|
|
||||||
.capacity_reservation
|
|
||||||
.as_ref(),
|
|
||||||
Some(&pending_reservation),
|
|
||||||
"ordinary PUT and multipart operations must not settle or consume the migration's pending identity"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut after_capacity = capacities;
|
|
||||||
// Capacity injection is deterministic; the object I/O and durable metadata use real temporary disks.
|
|
||||||
let free = if lose_capacity {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
target_total - source_body.len() - business_body.len() * 2
|
|
||||||
};
|
|
||||||
after_capacity[2] = DecommissionPoolCapacityInfo::for_test(2, layout, free, target_total, target_total - free);
|
|
||||||
set_decommission_capacity_info_overrides_for_test(store.id, vec![after_capacity]);
|
|
||||||
barrier.release();
|
|
||||||
drop(barrier);
|
|
||||||
let migrated = tokio::time::timeout(Duration::from_secs(30), migration)
|
|
||||||
.await
|
|
||||||
.expect("migration must finish after publication resumes")
|
|
||||||
.expect("migration task must not panic");
|
|
||||||
if lose_capacity {
|
|
||||||
let err =
|
|
||||||
migrated.expect_err("capacity loss must prevent source cleanup, even after the target write commits");
|
|
||||||
assert!(err.to_string().contains("capacity"), "unexpected migration error: {err}");
|
|
||||||
} else {
|
|
||||||
migrated.expect("shared-capacity migration should finish when space remains sufficient");
|
|
||||||
}
|
|
||||||
let mut persisted = crate::core::pools::PoolMeta::default();
|
|
||||||
persisted
|
|
||||||
.load_no_lock_from_replicas(other_store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("reload finalized migration state");
|
|
||||||
let info = persisted.pools[0].decommission.as_ref().expect("source state");
|
|
||||||
let reservation = info.capacity_reservation.as_ref().expect("migration ledger");
|
|
||||||
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
|
||||||
assert_eq!(
|
|
||||||
reservation.consumed_target_physical_bytes,
|
|
||||||
source_body.len(),
|
|
||||||
"foreign writes must not count as committed source bytes"
|
|
||||||
);
|
|
||||||
assert_eq!(reservation.committed_data_bytes, source_body.len());
|
|
||||||
assert_eq!(info.capacity_blocked_reason.is_some(), lose_capacity);
|
|
||||||
for (pool, key, expected) in [
|
|
||||||
(2, business_object, &business_body),
|
|
||||||
(2, multipart_object, &business_body),
|
|
||||||
(2, object, &source_body),
|
|
||||||
] {
|
|
||||||
let mut reader = other_store.pools[pool]
|
|
||||||
.get_object_reader(&bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("all acknowledged target objects must remain readable");
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
reader.read_to_end(&mut actual).await.expect("read the complete target body");
|
|
||||||
assert_eq!(&actual, expected);
|
|
||||||
}
|
|
||||||
if lose_capacity {
|
|
||||||
let mut source = other_store.pools[0]
|
|
||||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("capacity-blocked migration must retain its source");
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
source
|
|
||||||
.read_to_end(&mut actual)
|
|
||||||
.await
|
|
||||||
.expect("read the complete retained source");
|
|
||||||
assert_eq!(actual, source_body);
|
|
||||||
other_store
|
|
||||||
.put_object(
|
|
||||||
&bucket,
|
|
||||||
business_object,
|
|
||||||
&mut PutObjReader::from_vec(business_body.clone()),
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("a capacity-blocked migration must not itself make the healthy target read-only");
|
|
||||||
} else {
|
|
||||||
let err = other_store.pools[0]
|
|
||||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect_err("successful migration must clean the exact source");
|
|
||||||
assert!(crate::error::is_err_object_not_found(&err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn mixed_batch_delete_admits_only_marker_destinations_during_retirement() {
|
|
||||||
run_large_stack_current_thread_async_test("batch-marker-admission", || async {
|
|
||||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
|
||||||
|
|
||||||
for marker_target in [1, 2] {
|
|
||||||
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
||||||
let bucket = test_bucket("batch-marker");
|
|
||||||
store
|
|
||||||
.make_bucket(
|
|
||||||
&bucket,
|
|
||||||
&MakeBucketOptions {
|
|
||||||
versioning_enabled: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("create a versioned batch-delete bucket");
|
|
||||||
let source_version = uuid::Uuid::new_v4();
|
|
||||||
for (pool, object, version) in [
|
|
||||||
(0, "purge-source", source_version),
|
|
||||||
(marker_target, "mark-active", uuid::Uuid::new_v4()),
|
|
||||||
] {
|
|
||||||
store.pools[pool]
|
|
||||||
.put_object(
|
|
||||||
&bucket,
|
|
||||||
object,
|
|
||||||
&mut PutObjReader::from_vec(b"version to delete".to_vec()),
|
|
||||||
&ObjectOptions {
|
|
||||||
versioned: true,
|
|
||||||
version_id: Some(version.to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("seed each exact batch-delete destination");
|
|
||||||
}
|
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
||||||
set_decommission_capacity_info_overrides_for_test(
|
|
||||||
store.id,
|
|
||||||
vec![vec![
|
|
||||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
||||||
.await
|
|
||||||
.expect("reserve pool 1 while pool 0 retires and pool 2 remains unreserved");
|
|
||||||
let (deleted, errors) = store
|
|
||||||
.delete_objects(
|
|
||||||
&bucket,
|
|
||||||
vec![
|
|
||||||
ObjectToDelete {
|
|
||||||
object_name: "mark-active".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
ObjectToDelete {
|
|
||||||
object_name: "purge-source".to_string(),
|
|
||||||
version_id: Some(source_version),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ObjectOptions::default(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(errors.len(), 2);
|
|
||||||
assert!(
|
|
||||||
errors.iter().all(Option::is_none),
|
|
||||||
"the unrelated retiring/reserved pools must not reject marker admission: {errors:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(deleted.len(), 2);
|
|
||||||
assert_eq!(deleted[0].object_name, "mark-active");
|
|
||||||
assert!(deleted[0].delete_marker);
|
|
||||||
assert!(
|
|
||||||
deleted[0].version_id.is_none(),
|
|
||||||
"a latest-version delete does not request an explicit version"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
deleted[0].delete_marker_version_id.is_some(),
|
|
||||||
"the newly created marker must have its own version identity"
|
|
||||||
);
|
|
||||||
assert_eq!(deleted[1].object_name, "purge-source");
|
|
||||||
assert!(!deleted[1].delete_marker);
|
|
||||||
assert_eq!(deleted[1].version_id, Some(source_version));
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
store.pools[0]
|
|
||||||
.get_object_info(
|
|
||||||
&bucket,
|
|
||||||
"purge-source",
|
|
||||||
&ObjectOptions {
|
|
||||||
version_id: Some(source_version.to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
Err(crate::error::Error::ObjectNotFound(..) | crate::error::Error::VersionNotFound(..))
|
|
||||||
),
|
|
||||||
"an exact source deletion must retain its capacity-release path"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
||||||
@@ -4958,470 +4499,6 @@ mod decommission_lock_order_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn scanner_backlog_cas_keeps_fences_after_waiter_cancellation_until_rename_drains() {
|
|
||||||
run_large_stack_current_thread_async_test("scanner-backlog-canceled-waiter", async || {
|
|
||||||
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
|
||||||
let (_temp_dirs, writer, other) =
|
|
||||||
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
|
||||||
let object = "buckets/.scanner-pause-backlog.json";
|
|
||||||
let set_index = 1;
|
|
||||||
let body = vec![0x37; 1024];
|
|
||||||
assert!(
|
|
||||||
!writer.pools[0].disk_set[0]
|
|
||||||
.shares_namespace_lock_domain(&writer.pools[0].disk_set[set_index])
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
let rename_tasks = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
|
|
||||||
let tail =
|
|
||||||
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
|
||||||
let put_store = Arc::clone(&writer);
|
|
||||||
let put_body = body.clone();
|
|
||||||
let mut put = tokio::spawn(async move {
|
|
||||||
put_store
|
|
||||||
.save_scanner_pause_backlog_replica(0, set_index, put_body, Default::default())
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
|
|
||||||
.await
|
|
||||||
.expect("the native write must reach its held rename");
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), async {
|
|
||||||
while rename_tasks.running() != 1 {
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("the other disks must reach quorum before canceling the waiter");
|
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(100), &mut put).await.is_err(),
|
|
||||||
"native replica publication must await the entire rename tail"
|
|
||||||
);
|
|
||||||
put.abort();
|
|
||||||
assert!(put.await.expect_err("the scanner waiter must be canceled").is_cancelled());
|
|
||||||
|
|
||||||
let capacity_lock = other
|
|
||||||
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
|
||||||
.await
|
|
||||||
.expect("capacity lock probe");
|
|
||||||
let object_lock = other
|
|
||||||
.new_ns_lock(RUSTFS_META_BUCKET, object)
|
|
||||||
.await
|
|
||||||
.expect("fixed object lock probe");
|
|
||||||
let mut capacity_probe = tokio::spawn(async move { capacity_lock.get_write_lock(Duration::from_secs(30)).await });
|
|
||||||
let mut object_probe = tokio::spawn(async move { object_lock.get_write_lock(Duration::from_secs(30)).await });
|
|
||||||
for (label, probe) in [("capacity", &mut capacity_probe), ("fixed object", &mut object_probe)] {
|
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(100), probe).await.is_err(),
|
|
||||||
"canceling the scanner waiter must retain its {label} fence while rename is pending"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tail.release();
|
|
||||||
drop(tail);
|
|
||||||
for probe in [capacity_probe, object_probe] {
|
|
||||||
drop(
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), probe)
|
|
||||||
.await
|
|
||||||
.expect("publication fence must drain after rename")
|
|
||||||
.expect("lock probe must not panic")
|
|
||||||
.expect("publication fence must eventually be released"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut reader = writer.pools[0].disk_set[set_index]
|
|
||||||
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("canceled waiter must leave the committed replica readable");
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
reader
|
|
||||||
.read_to_end(&mut actual)
|
|
||||||
.await
|
|
||||||
.expect("read the full native replica after tail drain");
|
|
||||||
assert_eq!(actual, body);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn scanner_backlog_cas_rejects_lost_capacity_lease_before_publication() {
|
|
||||||
run_large_stack_current_thread_async_test("scanner-backlog-lease-loss", async || {
|
|
||||||
let (_temp_dirs, writer, other) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
|
||||||
let object = "buckets/.scanner-pause-backlog.json";
|
|
||||||
let body = b"native source before lease loss".to_vec();
|
|
||||||
let original = writer
|
|
||||||
.save_scanner_pause_backlog_replica(2, 1, body.clone(), Default::default())
|
|
||||||
.await
|
|
||||||
.expect("seed the exact native replica set");
|
|
||||||
let (lossy, refresh_calls) = store_with_capacity_lease_loss(&other).await;
|
|
||||||
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, object, PutObjectCommitPause::BeforeQuotaRename);
|
|
||||||
let put = tokio::spawn(async move {
|
|
||||||
lossy
|
|
||||||
.save_scanner_pause_backlog_replica(
|
|
||||||
2,
|
|
||||||
1,
|
|
||||||
b"must not commit after lease loss".to_vec(),
|
|
||||||
crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_match: original.etag,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
|
|
||||||
.await
|
|
||||||
.expect("native CAS must reach its commit barrier");
|
|
||||||
tokio::time::pause();
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
refresh_calls.arm();
|
|
||||||
tokio::time::advance(Duration::from_secs(11)).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(
|
|
||||||
refresh_calls.load(Ordering::Acquire) > 0,
|
|
||||||
"the durable metadata lease must lose refresh quorum"
|
|
||||||
);
|
|
||||||
barrier.release();
|
|
||||||
tokio::time::resume();
|
|
||||||
let err = tokio::time::timeout(Duration::from_secs(30), put)
|
|
||||||
.await
|
|
||||||
.expect("native CAS must finish after the barrier release")
|
|
||||||
.expect("native CAS task must not panic")
|
|
||||||
.expect_err("a lost outer capacity lease must reject native publication");
|
|
||||||
assert!(
|
|
||||||
matches!(err, crate::error::Error::NamespaceLockQuorumUnavailable { .. }),
|
|
||||||
"unexpected lease error: {err}"
|
|
||||||
);
|
|
||||||
drop(barrier);
|
|
||||||
let mut reader = writer.pools[2].disk_set[1]
|
|
||||||
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("the preexisting replica must survive lease loss");
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
reader.read_to_end(&mut actual).await.expect("read the full retained replica");
|
|
||||||
assert_eq!(actual, body);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn scanner_backlog_cas_rejects_a_retiring_source_on_a_stale_node() {
|
|
||||||
run_large_stack_current_thread_async_test("scanner-backlog-source-fence", async || {
|
|
||||||
let (_temp_dirs, store, writer) = test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
|
||||||
let object = "buckets/.scanner-pause-backlog.json";
|
|
||||||
let body = b"frozen native scanner replica".to_vec();
|
|
||||||
let source_set_index = (writer.pools[0].get_disks_by_key(object).set_index + 1) % writer.pools[0].disk_set.len();
|
|
||||||
assert_ne!(
|
|
||||||
source_set_index,
|
|
||||||
writer.pools[0].get_disks_by_key(object).set_index,
|
|
||||||
"exercise a non-routed native set"
|
|
||||||
);
|
|
||||||
let original = writer
|
|
||||||
.save_scanner_pause_backlog_replica(
|
|
||||||
0,
|
|
||||||
source_set_index,
|
|
||||||
body.clone(),
|
|
||||||
crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_none_match: Some("*".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("seed a native scanner replica before retirement");
|
|
||||||
assert!(
|
|
||||||
writer
|
|
||||||
.scanner_pause_backlog_writable_set_disks()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.any(|set| set.pool_index == 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
||||||
let target_total = body.len() * 8;
|
|
||||||
set_decommission_capacity_info_overrides_for_test(
|
|
||||||
store.id,
|
|
||||||
vec![vec![
|
|
||||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
||||||
.await
|
|
||||||
.expect("another node durably retires the selected source");
|
|
||||||
assert!(
|
|
||||||
writer.pool_meta.read().await.pools[0].decommission.is_none(),
|
|
||||||
"the writer must retain a stale snapshot"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
writer
|
|
||||||
.scanner_pause_backlog_writable_set_disks()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.any(|set| set.pool_index == 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = writer
|
|
||||||
.save_scanner_pause_backlog_replica(
|
|
||||||
0,
|
|
||||||
source_set_index,
|
|
||||||
b"late native scanner update".to_vec(),
|
|
||||||
crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_match: original.etag.clone(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(crate::error::Error::SlowDown)),
|
|
||||||
"late native source publication must fail: {result:?}"
|
|
||||||
);
|
|
||||||
let mut source = writer.pools[0].disk_set[source_set_index]
|
|
||||||
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("the original source must remain readable");
|
|
||||||
assert_eq!(source.object_info.etag, original.etag);
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
source
|
|
||||||
.read_to_end(&mut actual)
|
|
||||||
.await
|
|
||||||
.expect("read the entire retained source");
|
|
||||||
assert_eq!(actual, body);
|
|
||||||
|
|
||||||
for set in &writer.pools[2].disk_set {
|
|
||||||
let target_body = format!("surviving native scanner set {}", set.set_index).into_bytes();
|
|
||||||
let committed = writer
|
|
||||||
.save_scanner_pause_backlog_replica(
|
|
||||||
2,
|
|
||||||
set.set_index,
|
|
||||||
target_body.clone(),
|
|
||||||
crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_none_match: Some("*".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("every reserved healthy target set must still accept scanner replicas");
|
|
||||||
let conflict = writer
|
|
||||||
.save_scanner_pause_backlog_replica(
|
|
||||||
2,
|
|
||||||
set.set_index,
|
|
||||||
b"must not bypass CAS".to_vec(),
|
|
||||||
crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_match: Some("stale-native-revision".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("capacity admission must retain the native writer's CAS");
|
|
||||||
assert!(matches!(conflict, crate::error::Error::PreconditionFailed));
|
|
||||||
let mut target = set
|
|
||||||
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("read the actual replica set, not the hash-routed set");
|
|
||||||
assert_eq!(target.object_info.etag, committed.etag);
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
target
|
|
||||||
.read_to_end(&mut actual)
|
|
||||||
.await
|
|
||||||
.expect("read the complete native target");
|
|
||||||
assert_eq!(actual, target_body);
|
|
||||||
}
|
|
||||||
for (pool_index, set_index) in [(writer.pools.len(), 0), (0, writer.pools[0].disk_set.len())] {
|
|
||||||
assert!(matches!(
|
|
||||||
writer
|
|
||||||
.save_scanner_pause_backlog_replica(pool_index, set_index, Vec::new(), Default::default())
|
|
||||||
.await,
|
|
||||||
Err(crate::error::Error::InvalidArgument(_, _, _))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
store
|
|
||||||
.decommission_cancel(0)
|
|
||||||
.await
|
|
||||||
.expect("cancel retirement before restoring native membership");
|
|
||||||
writer
|
|
||||||
.save_scanner_pause_backlog_replica(
|
|
||||||
0,
|
|
||||||
source_set_index,
|
|
||||||
b"canceled source membership repair".to_vec(),
|
|
||||||
crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_match: original.etag,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("cancel must retain scanner's existing native membership repair contract");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
fn scanner_backlog_native_replica_reconciles_capacity_and_cleans_source() {
|
|
||||||
run_large_stack_current_thread_async_test("scanner-backlog-reconcile", async || {
|
|
||||||
let (_temp_dirs, store, other_store) =
|
|
||||||
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
|
||||||
let object = "buckets/.scanner-pause-backlog.json";
|
|
||||||
let body = br#"{"schemaVersion":1,"generation":2}"#.to_vec();
|
|
||||||
let old_body = br#"{"schemaVersion":1,"generation":1}"#.to_vec();
|
|
||||||
let source_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20);
|
|
||||||
let target_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10);
|
|
||||||
for (pool_index, payload, mod_time) in [(0, body.clone(), source_time), (2, old_body, target_time)] {
|
|
||||||
store.pools[pool_index]
|
|
||||||
.put_object(
|
|
||||||
RUSTFS_META_BUCKET,
|
|
||||||
object,
|
|
||||||
&mut PutObjReader::from_vec(payload),
|
|
||||||
&ObjectOptions {
|
|
||||||
max_parity: true,
|
|
||||||
mod_time: Some(mod_time),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("seed native scanner replicas with independent write times");
|
|
||||||
}
|
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
|
||||||
let target_total = body.len() * 8;
|
|
||||||
let capacities = vec![
|
|
||||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
|
||||||
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
|
||||||
];
|
|
||||||
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
|
|
||||||
store
|
|
||||||
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
|
||||||
.await
|
|
||||||
.expect("activate the source reservation");
|
|
||||||
let owner = decommission_capacity_owner(&*store.pool_meta.read().await);
|
|
||||||
let source_reader = store.pools[0]
|
|
||||||
.get_object_reader(
|
|
||||||
RUSTFS_META_BUCKET,
|
|
||||||
object,
|
|
||||||
None,
|
|
||||||
HeaderMap::new(),
|
|
||||||
&ObjectOptions {
|
|
||||||
no_lock: true,
|
|
||||||
data_movement: true,
|
|
||||||
raw_data_movement_read: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("read the frozen source replica");
|
|
||||||
let conflict = data_movement::migrate_decommission_object(
|
|
||||||
Arc::clone(&store),
|
|
||||||
0,
|
|
||||||
RUSTFS_META_BUCKET.to_string(),
|
|
||||||
source_reader,
|
|
||||||
None,
|
|
||||||
"scanner_backlog_conflict",
|
|
||||||
Some(owner),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("a different older native ledger must retain its source and capacity intent");
|
|
||||||
assert!(conflict.to_string().contains("Precondition failed"), "unexpected conflict: {conflict}");
|
|
||||||
let mut persisted = crate::core::pools::PoolMeta::default();
|
|
||||||
persisted
|
|
||||||
.load_no_lock_from_replicas(store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("reload the unresolved intent");
|
|
||||||
assert_eq!(
|
|
||||||
persisted.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.expect("source state")
|
|
||||||
.capacity_reservation
|
|
||||||
.as_ref()
|
|
||||||
.expect("durable capacity")
|
|
||||||
.pending_target_physical_bytes,
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
let previous = store.pools[2]
|
|
||||||
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("read the native writer's CAS revision");
|
|
||||||
let replacement = store.pools[2]
|
|
||||||
.put_object(
|
|
||||||
RUSTFS_META_BUCKET,
|
|
||||||
object,
|
|
||||||
&mut PutObjReader::from_vec(body.clone()),
|
|
||||||
&ObjectOptions {
|
|
||||||
max_parity: true,
|
|
||||||
mod_time: Some(target_time),
|
|
||||||
http_preconditions: Some(crate::storage_api_contracts::object::HTTPPreconditions {
|
|
||||||
if_match: previous.etag,
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("native scanner CAS converges the payload without a migration marker");
|
|
||||||
assert!(!data_movement::is_owned_data_movement_target(&replacement));
|
|
||||||
*other_store.pool_meta.write().await = persisted;
|
|
||||||
set_decommission_capacity_info_overrides_for_test(other_store.id, vec![capacities]);
|
|
||||||
tokio::time::timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
other_store.decommission_entry_for_test(
|
|
||||||
0,
|
|
||||||
MetaCacheEntry {
|
|
||||||
name: object.to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
RUSTFS_META_BUCKET.to_string(),
|
|
||||||
other_store.pools[0].get_disks_by_key(object),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("replica conflict recovery must be bounded")
|
|
||||||
.expect("identical native replica should finish migration on the reloaded node");
|
|
||||||
let mut reconciled = crate::core::pools::PoolMeta::default();
|
|
||||||
reconciled
|
|
||||||
.load_no_lock_from_replicas(other_store.pools.clone())
|
|
||||||
.await
|
|
||||||
.expect("reload reconciled capacity");
|
|
||||||
let reservation = reconciled.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.expect("source state")
|
|
||||||
.capacity_reservation
|
|
||||||
.as_ref()
|
|
||||||
.expect("reconciled capacity");
|
|
||||||
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
|
||||||
assert_eq!(reservation.committed_data_bytes, body.len());
|
|
||||||
assert_eq!(reservation.consumed_target_physical_bytes, body.len());
|
|
||||||
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
|
|
||||||
assert_eq!(
|
|
||||||
other_store.pool_meta.read().await.pools[0]
|
|
||||||
.decommission
|
|
||||||
.as_ref()
|
|
||||||
.expect("worker progress")
|
|
||||||
.items_decommission_failed,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
let missing = other_store.pools[0]
|
|
||||||
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect_err("the source should be cleaned only after equivalent-target capacity reconciliation");
|
|
||||||
assert!(crate::error::is_err_object_not_found(&missing));
|
|
||||||
let mut target_reader = other_store.pools[2]
|
|
||||||
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("the surviving replica should remain readable");
|
|
||||||
assert_eq!(
|
|
||||||
target_reader.object_info.mod_time,
|
|
||||||
Some(target_time),
|
|
||||||
"recovery must not overwrite the native target"
|
|
||||||
);
|
|
||||||
let mut actual = Vec::new();
|
|
||||||
target_reader
|
|
||||||
.read_to_end(&mut actual)
|
|
||||||
.await
|
|
||||||
.expect("read surviving ledger bytes");
|
|
||||||
assert_eq!(actual, body);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
||||||
|
|||||||
@@ -984,24 +984,6 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
|
|||||||
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_equivalent_scanner_backlog_replica(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
|
||||||
// Scanner publishes this exact payload to surviving sets with CAS. Each
|
|
||||||
// set assigns its own write time; that timestamp is not a ledger generation.
|
|
||||||
// Accept only an identical, known unversioned identity, never a different
|
|
||||||
// record based on timestamp ordering or a similarly named user object.
|
|
||||||
source.bucket == crate::disk::RUSTFS_META_BUCKET
|
|
||||||
&& target.bucket == source.bucket
|
|
||||||
&& source.name == "buckets/.scanner-pause-backlog.json"
|
|
||||||
&& target.name == source.name
|
|
||||||
&& is_unversioned_data_movement_object(source)
|
|
||||||
&& is_unversioned_data_movement_object(target)
|
|
||||||
&& !source.delete_marker
|
|
||||||
&& source.mod_time.is_some()
|
|
||||||
&& target.mod_time.is_some()
|
|
||||||
&& source.etag.as_ref().is_some_and(|etag| !etag.is_empty())
|
|
||||||
&& is_equivalent_data_movement_object_identity(source, target, false, compare_part_checksums)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||||
let identity = data_movement_upload_identity(source);
|
let identity = data_movement_upload_identity(source);
|
||||||
source.mod_time.is_some()
|
source.mod_time.is_some()
|
||||||
@@ -1471,9 +1453,7 @@ fn resolve_data_movement_overwrite_resume_result_for(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(matches!(err, Error::PreconditionFailed)
|
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
||||||
&& (is_equivalent_scanner_backlog_replica(source, &target, compare_part_checksums)
|
|
||||||
|| is_superseding_unversioned_data_movement_object(source, &target)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -3308,132 +3288,6 @@ mod tests {
|
|||||||
assert!(overwrite_resume_for_target(&source, source.clone()));
|
assert!(overwrite_resume_for_target(&source, source.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scanner_backlog_replica_pair() -> (ObjectInfo, ObjectInfo) {
|
|
||||||
let source = ObjectInfo {
|
|
||||||
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
|
||||||
name: "buckets/.scanner-pause-backlog.json".to_string(),
|
|
||||||
version_id: None,
|
|
||||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND),
|
|
||||||
..overwrite_equivalence_source()
|
|
||||||
};
|
|
||||||
let target = ObjectInfo {
|
|
||||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
||||||
..source.clone()
|
|
||||||
};
|
|
||||||
(source, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scanner_backlog_precondition_resumes(source: &ObjectInfo, target: ObjectInfo) -> bool {
|
|
||||||
resolve_data_movement_overwrite_resume_result_for(&Error::PreconditionFailed, Ok(Some(target)), source, 0, 1, true)
|
|
||||||
.expect("scanner replica conflict should be adjudicated")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scanner_backlog_resume_accepts_identical_native_replica_with_older_write_time() {
|
|
||||||
let (source, target) = scanner_backlog_replica_pair();
|
|
||||||
assert!(!is_owned_data_movement_target(&target), "native scanner writes are not migration copies");
|
|
||||||
assert!(!is_equivalent_data_movement_object(&source, &target));
|
|
||||||
assert!(
|
|
||||||
scanner_backlog_precondition_resumes(&source, target),
|
|
||||||
"identical ledger payloads have replica-local write times, not distinct committed generations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scanner_backlog_resume_rejects_changed_payload_or_metadata() {
|
|
||||||
let (source, target) = scanner_backlog_replica_pair();
|
|
||||||
let mut different_etag = target.clone();
|
|
||||||
different_etag.etag = Some("different-ledger-generation".to_string());
|
|
||||||
let mut different_size = target.clone();
|
|
||||||
different_size.size += 1;
|
|
||||||
let mut different_checksum = target.clone();
|
|
||||||
different_checksum.checksum = Some(Bytes::from_static(b"different-checksum"));
|
|
||||||
let mut different_metadata = target.clone();
|
|
||||||
Arc::make_mut(&mut different_metadata.user_defined).insert("x-amz-meta-key".to_string(), "different".to_string());
|
|
||||||
let mut different_tags = target.clone();
|
|
||||||
different_tags.user_tags = Arc::new("tag=changed".to_string());
|
|
||||||
let mut different_parts = target.clone();
|
|
||||||
Arc::make_mut(&mut different_parts.parts)[0].etag = "different-part".to_string();
|
|
||||||
let mut different_tier = target;
|
|
||||||
different_tier.transitioned_object.tier = "different-tier".to_string();
|
|
||||||
for (label, different) in [
|
|
||||||
("etag", different_etag),
|
|
||||||
("size", different_size),
|
|
||||||
("checksum", different_checksum),
|
|
||||||
("metadata", different_metadata),
|
|
||||||
("tags", different_tags),
|
|
||||||
("parts", different_parts),
|
|
||||||
("tier", different_tier),
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!scanner_backlog_precondition_resumes(&source, different),
|
|
||||||
"replica-local timestamps do not authorize a changed {label}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scanner_backlog_resume_rejects_other_namespaces_and_incomplete_identity() {
|
|
||||||
let (source, target) = scanner_backlog_replica_pair();
|
|
||||||
for (bucket, name) in [
|
|
||||||
("user-bucket", "buckets/.scanner-pause-backlog.json"),
|
|
||||||
(crate::disk::RUSTFS_META_BUCKET, "buckets/.scanner-pause-backlog.json.bkp"),
|
|
||||||
(crate::disk::RUSTFS_META_BUCKET, "buckets/.usage-cache.bin"),
|
|
||||||
] {
|
|
||||||
let mut source = source.clone();
|
|
||||||
let mut target = target.clone();
|
|
||||||
for replica in [&mut source, &mut target] {
|
|
||||||
replica.bucket = bucket.to_string();
|
|
||||||
replica.name = name.to_string();
|
|
||||||
}
|
|
||||||
assert!(!scanner_backlog_precondition_resumes(&source, target), "out-of-scope key {bucket}/{name}");
|
|
||||||
}
|
|
||||||
for missing in ["etag", "empty-etag", "source-time", "target-time", "version", "delete-marker"] {
|
|
||||||
let mut source = source.clone();
|
|
||||||
let mut target = target.clone();
|
|
||||||
match missing {
|
|
||||||
"etag" => {
|
|
||||||
source.etag = None;
|
|
||||||
target.etag = None;
|
|
||||||
}
|
|
||||||
"empty-etag" => {
|
|
||||||
source.etag = Some(String::new());
|
|
||||||
target.etag = Some(String::new());
|
|
||||||
}
|
|
||||||
"source-time" => source.mod_time = None,
|
|
||||||
"target-time" => target.mod_time = None,
|
|
||||||
"version" => {
|
|
||||||
source.version_id = Some(Uuid::from_u128(1));
|
|
||||||
target.version_id = source.version_id;
|
|
||||||
}
|
|
||||||
"delete-marker" => {
|
|
||||||
source.delete_marker = true;
|
|
||||||
target.delete_marker = true;
|
|
||||||
}
|
|
||||||
_ => unreachable!("all identity variants are enumerated above"),
|
|
||||||
}
|
|
||||||
assert!(!scanner_backlog_precondition_resumes(&source, target), "unsupported identity: {missing}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scanner_backlog_resume_requires_a_cross_pool_precondition_conflict() {
|
|
||||||
let (source, target) = scanner_backlog_replica_pair();
|
|
||||||
for (err, target_pool) in [
|
|
||||||
(Error::PreconditionFailed, 0),
|
|
||||||
(Error::SlowDown, 1),
|
|
||||||
(
|
|
||||||
Error::InvalidUploadID(source.bucket.clone(), source.name.clone(), "upload".to_string()),
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target.clone())), &source, 0, target_pool, true)
|
|
||||||
.expect("non-resumable conflict should return false")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
||||||
let source = overwrite_equivalence_source();
|
let source = overwrite_equivalence_source();
|
||||||
|
|||||||
@@ -20,8 +20,6 @@
|
|||||||
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
||||||
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
||||||
|
|
||||||
use crate::core::pools::DecommissionCapacityAdmission;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::super::GetObjectMetadataCacheKey;
|
use super::super::GetObjectMetadataCacheKey;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1811,10 +1809,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
Some(
|
Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||||
&[self.pool_index],
|
|
||||||
DecommissionCapacityAdmission::ExistingMultipart,
|
|
||||||
)
|
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -2350,7 +2345,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts.no_lock || object_lock_guard.is_some(),
|
opts.no_lock || object_lock_guard.is_some(),
|
||||||
DecommissionCapacityAdmission::ExistingMultipart,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
decommission_object_lock_guard = object_guard;
|
decommission_object_lock_guard = object_guard;
|
||||||
@@ -3116,10 +3110,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||||
&[self.pool_index],
|
|
||||||
DecommissionCapacityAdmission::ExistingMultipart,
|
|
||||||
)
|
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
//! bounds are unchanged, and the impls reach shared primitives through the
|
//! bounds are unchanged, and the impls reach shared primitives through the
|
||||||
//! SetDisks core (io_primitives) via inherent calls.
|
//! SetDisks core (io_primitives) via inherent calls.
|
||||||
|
|
||||||
use crate::core::pools::DecommissionCapacityAdmission;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::super::MetadataCacheInvalidationProbe;
|
use super::super::MetadataCacheInvalidationProbe;
|
||||||
use super::super::{
|
use super::super::{
|
||||||
@@ -3907,7 +3905,6 @@ impl SetDisks {
|
|||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts.no_lock || object_lock_guard.is_some(),
|
opts.no_lock || object_lock_guard.is_some(),
|
||||||
DecommissionCapacityAdmission::Mutation,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
decommission_object_lock_guard = object_guard;
|
decommission_object_lock_guard = object_guard;
|
||||||
@@ -4105,7 +4102,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use super::{
|
|||||||
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::lifecycle;
|
use crate::bucket::lifecycle::lifecycle;
|
||||||
use crate::core::pools::DecommissionCapacityAdmission;
|
|
||||||
use rustfs_filemeta::RestoreStatusOps;
|
use rustfs_filemeta::RestoreStatusOps;
|
||||||
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
||||||
use s3s::dto::{RestoreStatus, Timestamp};
|
use s3s::dto::{RestoreStatus, Timestamp};
|
||||||
@@ -161,13 +160,7 @@ impl SetDisks {
|
|||||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_commit_guards(
|
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
||||||
self.pool_index,
|
|
||||||
bucket,
|
|
||||||
object,
|
|
||||||
opts.no_lock,
|
|
||||||
DecommissionCapacityAdmission::Mutation,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
(None, false, None)
|
(None, false, None)
|
||||||
@@ -185,7 +178,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -271,13 +264,7 @@ impl SetDisks {
|
|||||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_commit_guards(
|
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
||||||
self.pool_index,
|
|
||||||
bucket,
|
|
||||||
object,
|
|
||||||
opts.no_lock,
|
|
||||||
DecommissionCapacityAdmission::Mutation,
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
(None, false, None)
|
(None, false, None)
|
||||||
@@ -295,7 +282,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,9 +671,9 @@ mod tests {
|
|||||||
use crate::cluster::rpc::PeerS3Client;
|
use crate::cluster::rpc::PeerS3Client;
|
||||||
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
||||||
use crate::core::pools::{
|
use crate::core::pools::{
|
||||||
DecommissionCapacityAdmission, DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout,
|
DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME,
|
||||||
DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus,
|
PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus, initialized_pool_meta_identity_for_test,
|
||||||
initialized_pool_meta_identity_for_test, set_decommission_capacity_info_overrides_for_test,
|
set_decommission_capacity_info_overrides_for_test,
|
||||||
};
|
};
|
||||||
use crate::core::sets::HealFormatAfterSaveBarrier;
|
use crate::core::sets::HealFormatAfterSaveBarrier;
|
||||||
use crate::disk::error::Result as DiskResult;
|
use crate::disk::error::Result as DiskResult;
|
||||||
@@ -1149,7 +1149,7 @@ mod tests {
|
|||||||
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||||
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||||
let capacity_guard = store
|
let capacity_guard = store
|
||||||
.acquire_external_decommission_capacity_fence(&[0], DecommissionCapacityAdmission::Heal)
|
.acquire_external_decommission_capacity_fence(&[0], "heal")
|
||||||
.await
|
.await
|
||||||
.expect("ordinary heal capacity fence should be acquired");
|
.expect("ordinary heal capacity fence should be acquired");
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::pools::{
|
use crate::core::pools::{
|
||||||
PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix, persist_pool_meta_identity_for_attested_pools,
|
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix,
|
||||||
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||||
};
|
};
|
||||||
use crate::runtime::instance::InstanceContext;
|
use crate::runtime::instance::InstanceContext;
|
||||||
@@ -174,20 +174,9 @@ where
|
|||||||
S: EcstoreObjectIO,
|
S: EcstoreObjectIO,
|
||||||
{
|
{
|
||||||
if elected_writer && write_state.bootstrap_identity_proven() {
|
if elected_writer && write_state.bootstrap_identity_proven() {
|
||||||
return persist_pool_meta_identity_for_startup(pools, write_state, false).await;
|
persist_pool_meta_identity_for_startup(pools, write_state, false).await?;
|
||||||
}
|
}
|
||||||
if write_state.bootstrap_identity_proven() {
|
Ok(())
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Multi-pool bootstrap whose pools were formatted by different nodes: no
|
|
||||||
// single process can prove the whole deployment fresh in memory, so each
|
|
||||||
// creator attests the pools it formatted first-hand with the shared nonce
|
|
||||||
// and the elected writer waits for a complete, agreeing pending set.
|
|
||||||
let attested = write_state.attested_pool_indices();
|
|
||||||
if attested.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
persist_pool_meta_identity_for_attested_pools(pools, write_state, &attested).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_validated_pool_meta_for_startup<S>(
|
async fn save_validated_pool_meta_for_startup<S>(
|
||||||
@@ -418,7 +407,7 @@ impl ECStore {
|
|||||||
preflight_startup_rpc_secret(&endpoint_pools)?;
|
preflight_startup_rpc_secret(&endpoint_pools)?;
|
||||||
|
|
||||||
let mut deployment_id = None;
|
let mut deployment_id = None;
|
||||||
let mut pool_meta_bootstrap_authorities = Vec::new();
|
let mut pool_meta_bootstrap_authority = None;
|
||||||
|
|
||||||
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
||||||
|
|
||||||
@@ -534,10 +523,12 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}?;
|
}?;
|
||||||
// First-hand authority for this pool only: `Fresh` when this process
|
pool_meta_bootstrap_authority = Some(pool_meta_bootstrap_authority.map_or(
|
||||||
// formatted it, `LegacyAdoption` when it verified the migration, and
|
loaded_format.pool_meta_bootstrap_authority,
|
||||||
// `None` when it merely read a format another node created.
|
|authority: PoolMetaBootstrapAuthority| {
|
||||||
pool_meta_bootstrap_authorities.push(loaded_format.pool_meta_bootstrap_authority);
|
authority.combine_across_pools(loaded_format.pool_meta_bootstrap_authority)
|
||||||
|
},
|
||||||
|
));
|
||||||
let fm = loaded_format.format;
|
let fm = loaded_format.format;
|
||||||
|
|
||||||
// Format loading succeeded, enable health monitoring on all disks
|
// Format loading succeeded, enable health monitoring on all disks
|
||||||
@@ -578,13 +569,9 @@ impl ECStore {
|
|||||||
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
||||||
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||||
pool_meta.dont_save = true;
|
pool_meta.dont_save = true;
|
||||||
let elected_bootstrap_writer = pools
|
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_bootstrap_authority(
|
||||||
.first()
|
|
||||||
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
|
|
||||||
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
|
||||||
deployment_id,
|
deployment_id,
|
||||||
pool_meta_bootstrap_authorities,
|
pool_meta_bootstrap_authority.unwrap_or_default(),
|
||||||
elected_bootstrap_writer,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
||||||
@@ -974,9 +961,8 @@ mod tests {
|
|||||||
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
||||||
core::pools::{
|
core::pools::{
|
||||||
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
||||||
PoolDecommissionInfo, PoolMeta, PoolStatus, pending_pool_meta_identity_for_test,
|
PoolDecommissionInfo, PoolMeta, PoolStatus, pool_meta_identity_initialized_for_test,
|
||||||
pool_meta_identity_initialized_for_test, pool_meta_v3_commit_state_for_test,
|
pool_meta_v3_commit_state_for_test, set_decommission_capacity_info_overrides_for_test,
|
||||||
set_decommission_capacity_info_overrides_for_test,
|
|
||||||
},
|
},
|
||||||
disk::endpoint::Endpoint,
|
disk::endpoint::Endpoint,
|
||||||
error::{Error, Result, StorageError},
|
error::{Error, Result, StorageError},
|
||||||
@@ -1479,331 +1465,6 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn startup_object(storage: &StartupPoolMetaStorage, object: &str) -> Option<Vec<u8>> {
|
|
||||||
storage
|
|
||||||
.objects
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.get(object)
|
|
||||||
.map(|(payload, _)| payload.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Startup errors wrap their cause in context whose `Display` hides the
|
|
||||||
/// source, so assertions walk the chain the same way
|
|
||||||
/// `Error::pool_metadata_failure` does.
|
|
||||||
fn error_chain_text(err: &Error) -> String {
|
|
||||||
let mut parts = vec![err.to_string()];
|
|
||||||
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
|
|
||||||
while let Some(error) = current {
|
|
||||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
|
||||||
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
|
||||||
} else {
|
|
||||||
error.source()
|
|
||||||
};
|
|
||||||
if let Some(next) = current {
|
|
||||||
parts.push(next.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.join(" <- ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inject_startup_object(storage: &StartupPoolMetaStorage, object: &str, payload: Vec<u8>) {
|
|
||||||
storage
|
|
||||||
.objects
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.insert(object.to_string(), (payload, format!("injected-{object}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_test_pool_meta_with_pools(pool_count: usize) -> PoolMeta {
|
|
||||||
PoolMeta {
|
|
||||||
version: POOL_META_VERSION,
|
|
||||||
pools: (0..pool_count)
|
|
||||||
.map(|id| PoolStatus {
|
|
||||||
id,
|
|
||||||
cmd_line: format!("pool-{id}"),
|
|
||||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
||||||
decommission: None,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
dont_save: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two single-node pools whose formats were created by different nodes:
|
|
||||||
/// node0 formatted pool0 and only read pool1's format, node1 the reverse.
|
|
||||||
fn two_pool_creator_states(deployment_id: Uuid) -> (PoolMetaWriteState, PoolMetaWriteState) {
|
|
||||||
let node0 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
|
||||||
deployment_id,
|
|
||||||
vec![PoolMetaBootstrapAuthority::Fresh, PoolMetaBootstrapAuthority::None],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
let node1 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
|
||||||
deployment_id,
|
|
||||||
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
(node0, node1)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_two_pool_bootstrap_with_distinct_format_creators_converges_through_creator_attestation() {
|
|
||||||
let deployment_id = Uuid::new_v4();
|
|
||||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pools = vec![pool0.clone(), pool1.clone()];
|
|
||||||
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
|
||||||
assert!(!node0.bootstrap_identity_proven(), "reading pool1's format is not deployment-wide proof");
|
|
||||||
assert!(!node1.bootstrap_identity_proven());
|
|
||||||
|
|
||||||
// node1 (pool1 creator, non-elected) starts first: no durable nonce exists
|
|
||||||
// yet, so it must neither mint one nor latch its write gate while waiting.
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
|
||||||
.await
|
|
||||||
.expect("a non-first creator without a durable nonce writes nothing");
|
|
||||||
assert!(startup_object(&pool0, POOL_META_IDENTITY_NAME).is_none());
|
|
||||||
assert!(startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none());
|
|
||||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
|
||||||
.await
|
|
||||||
.expect_err("nothing durable authorizes a non-elected node");
|
|
||||||
assert!(err.to_string().contains("bootstrap pending"), "{err}");
|
|
||||||
node1
|
|
||||||
.ensure_write_safe("waiting non-elected creator")
|
|
||||||
.expect("waiting for the elected writer must not latch the write gate");
|
|
||||||
|
|
||||||
// node0 (pool0 creator, elected) mints the nonce on the pool it created;
|
|
||||||
// pool1 is still unattested, so it cannot publish pool.bin and must not latch.
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
|
||||||
.await
|
|
||||||
.expect("the first pool's creator mints the pending identity");
|
|
||||||
let minted = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("pool0 pending identity");
|
|
||||||
assert!(!pool_meta_identity_initialized_for_test(&minted).expect("decode pending identity"));
|
|
||||||
assert!(
|
|
||||||
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
|
||||||
"node0 holds no first-hand proof for pool1 and must not attest it"
|
|
||||||
);
|
|
||||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
|
||||||
.await
|
|
||||||
.expect_err("an unattested pool keeps the elected writer from publishing");
|
|
||||||
assert!(err.to_string().contains("waiting for every pool creator"), "{err}");
|
|
||||||
node0
|
|
||||||
.ensure_write_safe("waiting elected writer")
|
|
||||||
.expect("waiting for creators must not latch the write gate");
|
|
||||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
|
||||||
|
|
||||||
// node1 retries: it copies pool0's pending identity (same nonce) onto the
|
|
||||||
// pool it created, then keeps waiting for the elected writer's pool.bin.
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
|
||||||
.await
|
|
||||||
.expect("the pool1 creator attests with the durable nonce");
|
|
||||||
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME).as_deref(), Some(minted.as_slice()));
|
|
||||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
|
||||||
.await
|
|
||||||
.expect_err("a complete pending set never unlocks a non-elected node");
|
|
||||||
assert!(err.to_string().contains("waiting for the elected writer to publish"), "{err}");
|
|
||||||
node1
|
|
||||||
.ensure_write_safe("attested non-elected creator")
|
|
||||||
.expect("waiting for pool.bin must not latch the write gate");
|
|
||||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
|
||||||
|
|
||||||
// node0 retries: every pool is attested under one nonce, so it publishes
|
|
||||||
// pool.bin and commits the identity on both pools.
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
|
||||||
.await
|
|
||||||
.expect("re-establishing an already minted identity is idempotent");
|
|
||||||
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
|
||||||
.await
|
|
||||||
.expect("complete creator attestation authorizes the initial pool metadata write");
|
|
||||||
persist_pool_meta_for_startup_if_safe(
|
|
||||||
&init_test_pool_meta_with_pools(2),
|
|
||||||
pools.clone(),
|
|
||||||
replica_state,
|
|
||||||
&mut node0,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("the elected writer publishes pool.bin and commits the identity");
|
|
||||||
for pool in [&pool0, &pool1] {
|
|
||||||
assert!(startup_object(pool, POOL_META_NAME).is_some());
|
|
||||||
let identity = startup_object(pool, POOL_META_IDENTITY_NAME).expect("committed identity");
|
|
||||||
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode committed identity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// node1 retries once more: pool.bin exists and nothing is rewritten.
|
|
||||||
let before = startup_object(&pool1, POOL_META_IDENTITY_NAME);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
|
||||||
.await
|
|
||||||
.expect("an initialized deployment never reopens bootstrap");
|
|
||||||
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME), before);
|
|
||||||
load_pool_meta_for_startup(pools, &mut node1)
|
|
||||||
.await
|
|
||||||
.expect("published pool metadata admits the non-elected node");
|
|
||||||
node1
|
|
||||||
.ensure_write_safe("converged non-elected creator")
|
|
||||||
.expect("no latch remains after convergence");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_two_pool_bootstrap_rejects_pending_replicas_from_different_bootstraps() {
|
|
||||||
let deployment_id = Uuid::new_v4();
|
|
||||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pools = vec![pool0.clone(), pool1.clone()];
|
|
||||||
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
|
||||||
.await
|
|
||||||
.expect("the first pool's creator mints the pending identity");
|
|
||||||
inject_startup_object(
|
|
||||||
&pool1,
|
|
||||||
POOL_META_IDENTITY_NAME,
|
|
||||||
pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode foreign pending identity"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
|
||||||
.await
|
|
||||||
.expect_err("a pending replica bound to another bootstrap nonce must fail closed");
|
|
||||||
let chain = error_chain_text(&err);
|
|
||||||
assert!(chain.contains("disagree on fresh-bootstrap proof"), "{chain}");
|
|
||||||
node0
|
|
||||||
.ensure_write_safe("split bootstrap")
|
|
||||||
.expect_err("a split bootstrap latches the write gate");
|
|
||||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_two_pool_bootstrap_treats_corrupt_creator_replica_as_recovery_not_waiting() {
|
|
||||||
let deployment_id = Uuid::new_v4();
|
|
||||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pools = vec![pool0.clone(), pool1.clone()];
|
|
||||||
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
|
||||||
.await
|
|
||||||
.expect("the first pool's creator mints the pending identity");
|
|
||||||
// Keep the on-disk format/version header so the replica classifies as
|
|
||||||
// corrupt (undecodable payload) rather than as an incompatible format.
|
|
||||||
let mut corrupt = pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode identity");
|
|
||||||
corrupt.truncate(4);
|
|
||||||
corrupt.extend_from_slice(b"not a cluster identity");
|
|
||||||
inject_startup_object(&pool1, POOL_META_IDENTITY_NAME, corrupt);
|
|
||||||
|
|
||||||
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
|
||||||
.await
|
|
||||||
.expect_err("a corrupt replica is not a creator that is still catching up");
|
|
||||||
let chain = error_chain_text(&err);
|
|
||||||
assert!(chain.contains("no verified fresh-bootstrap proof"), "{chain}");
|
|
||||||
node0
|
|
||||||
.ensure_write_safe("corrupt attestation")
|
|
||||||
.expect_err("a corrupt attestation latches the write gate");
|
|
||||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_elected_restart_without_first_hand_proof_cannot_reuse_a_complete_pending_set() {
|
|
||||||
let deployment_id = Uuid::new_v4();
|
|
||||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pools = vec![pool0.clone(), pool1.clone()];
|
|
||||||
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
|
||||||
.await
|
|
||||||
.expect("mint");
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
|
||||||
.await
|
|
||||||
.expect("attest");
|
|
||||||
assert_eq!(
|
|
||||||
startup_object(&pool0, POOL_META_IDENTITY_NAME),
|
|
||||||
startup_object(&pool1, POOL_META_IDENTITY_NAME),
|
|
||||||
"both creators attested the same pending identity"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The elected node restarts before publishing: it now merely reads both
|
|
||||||
// formats, so the complete pending set alone must not reopen bootstrap.
|
|
||||||
let mut restarted = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
|
||||||
deployment_id,
|
|
||||||
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::None],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut restarted, true)
|
|
||||||
.await
|
|
||||||
.expect("a restart without first-hand proof writes nothing");
|
|
||||||
let err = load_pool_meta_for_startup(pools.clone(), &mut restarted)
|
|
||||||
.await
|
|
||||||
.expect_err("a pending set alone never authorizes a writer without first-hand proof");
|
|
||||||
assert!(err.to_string().contains("no verified fresh-bootstrap proof"), "{err}");
|
|
||||||
restarted
|
|
||||||
.ensure_write_safe("unproven restart")
|
|
||||||
.expect_err("the rejected restart latches the write gate");
|
|
||||||
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_fresh_pool_joining_an_initialized_deployment_never_reopens_bootstrap() {
|
|
||||||
let deployment_id = Uuid::new_v4();
|
|
||||||
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let mut founder = PoolMetaWriteState::for_startup(deployment_id, true);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(vec![pool0.clone()], &mut founder, true)
|
|
||||||
.await
|
|
||||||
.expect("the founder mints");
|
|
||||||
let (_, replica_state) = load_pool_meta_for_startup(vec![pool0.clone()], &mut founder)
|
|
||||||
.await
|
|
||||||
.expect("the founder may initialize");
|
|
||||||
persist_pool_meta_for_startup_if_safe(
|
|
||||||
&init_test_pool_meta(None),
|
|
||||||
vec![pool0.clone()],
|
|
||||||
replica_state,
|
|
||||||
&mut founder,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("the founder commits");
|
|
||||||
let founded = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("committed identity");
|
|
||||||
assert!(pool_meta_identity_initialized_for_test(&founded).expect("decode committed identity"));
|
|
||||||
|
|
||||||
// Expansion: pool1 is fresh and was formatted first-hand by the node
|
|
||||||
// hosting its first endpoint, whether or not that node is elected.
|
|
||||||
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
|
||||||
let pools = vec![pool0.clone(), pool1.clone()];
|
|
||||||
for elected in [false, true] {
|
|
||||||
let mut joiner = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
|
||||||
deployment_id,
|
|
||||||
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
|
||||||
elected,
|
|
||||||
);
|
|
||||||
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut joiner, elected)
|
|
||||||
.await
|
|
||||||
.expect("an initialized deployment ignores first-hand proof for a new pool");
|
|
||||||
assert!(
|
|
||||||
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
|
||||||
"no pending identity may be written to an expansion pool"
|
|
||||||
);
|
|
||||||
assert_eq!(startup_object(&pool0, POOL_META_IDENTITY_NAME).as_deref(), Some(founded.as_slice()));
|
|
||||||
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut joiner)
|
|
||||||
.await
|
|
||||||
.expect("published pool metadata admits the joiner");
|
|
||||||
joiner
|
|
||||||
.ensure_write_safe("expansion joiner")
|
|
||||||
.expect("joining never latches the write gate");
|
|
||||||
if elected {
|
|
||||||
persist_pool_meta_for_startup_if_safe(
|
|
||||||
&init_test_pool_meta_with_pools(2),
|
|
||||||
pools.clone(),
|
|
||||||
replica_state,
|
|
||||||
&mut joiner,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("the topology update repairs the new pool's replicas");
|
|
||||||
let identity = startup_object(&pool1, POOL_META_IDENTITY_NAME).expect("expansion pool identity");
|
|
||||||
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode repaired identity"));
|
|
||||||
assert!(startup_object(&pool1, POOL_META_NAME).is_some());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
||||||
let deployment_id = Uuid::new_v4();
|
let deployment_id = Uuid::new_v4();
|
||||||
@@ -4198,60 +3859,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("suspended source versions should be readable")
|
.expect("suspended source versions should be readable")
|
||||||
.expect("suspended source must exist before worker convergence");
|
.expect("suspended source must exist before worker convergence");
|
||||||
assert_eq!(versions.versions.len(), 1, "DELETE must not add a marker to the retiring source");
|
|
||||||
let source = &versions.versions[0];
|
|
||||||
assert!(
|
assert!(
|
||||||
!source.deleted && source.version_id.is_none_or(|version_id| version_id.is_nil()),
|
versions
|
||||||
"the source pool must retain its null data version until worker convergence"
|
.versions
|
||||||
|
.iter()
|
||||||
|
.any(|version| !version.deleted && version.version_id.is_none_or(|version_id| version_id.is_nil())),
|
||||||
|
"the source pool must retain its null data version while DELETE owns the fixed fence"
|
||||||
);
|
);
|
||||||
assert_eq!(source.mod_time, Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND));
|
|
||||||
|
|
||||||
let mut reader = store.pools[0]
|
|
||||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("the retiring source must remain directly readable before worker convergence");
|
|
||||||
let mut body = Vec::new();
|
|
||||||
reader
|
|
||||||
.stream
|
|
||||||
.read_to_end(&mut body)
|
|
||||||
.await
|
|
||||||
.expect("read retained source bytes");
|
|
||||||
assert_eq!(body, b"suspended source generation");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_suspended_null_delete_marker_visible(
|
|
||||||
store: &Arc<crate::store::ECStore>,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
marker_mod_time: OffsetDateTime,
|
|
||||||
) {
|
|
||||||
let versions = store.pools[1]
|
|
||||||
.get_disks_by_key(object)
|
|
||||||
.load_file_info_versions_exact(bucket, object)
|
|
||||||
.await
|
|
||||||
.expect("healthy target versions should be readable")
|
|
||||||
.expect("the healthy target must retain the DELETE marker");
|
|
||||||
assert_eq!(versions.versions.len(), 1, "the target must contain only the null delete marker");
|
|
||||||
let marker = &versions.versions[0];
|
|
||||||
assert!(marker.deleted, "migration must not replace the DELETE marker with source data");
|
|
||||||
assert!(marker.version_id.is_none_or(|version_id| version_id.is_nil()));
|
|
||||||
assert_eq!(marker.size, 0);
|
|
||||||
assert_eq!(marker.mod_time, Some(marker_mod_time), "migration must preserve the marker generation");
|
|
||||||
assert!(marker_mod_time > OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND);
|
|
||||||
|
|
||||||
let head_err = store
|
|
||||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect_err("HEAD must observe the DELETE marker instead of the old null source");
|
|
||||||
assert!(matches!(head_err, Error::ObjectNotFound(_, _)), "unexpected HEAD result: {head_err:?}");
|
|
||||||
let get_err = match store
|
|
||||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => panic!("GET must not resurrect the deleted null source"),
|
|
||||||
Err(err) => err,
|
|
||||||
};
|
|
||||||
assert!(matches!(get_err, Error::ObjectNotFound(_, _)), "unexpected GET result: {get_err:?}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -8089,7 +7703,7 @@ mod tests {
|
|||||||
write_suspended_decommission_source(&store, &bucket, object).await;
|
write_suspended_decommission_source(&store, &bucket, object).await;
|
||||||
mark_test_pool_decommissioning(&store, 0).await;
|
mark_test_pool_decommissioning(&store, 0).await;
|
||||||
|
|
||||||
let deleted = store
|
let delete_err = store
|
||||||
.delete_object(
|
.delete_object(
|
||||||
&bucket,
|
&bucket,
|
||||||
object,
|
object,
|
||||||
@@ -8099,12 +7713,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("a healthy reserved target must accept suspended DELETE");
|
.expect_err("capacity-reserved target must reject a concurrent suspended DELETE");
|
||||||
assert!(deleted.delete_marker);
|
assert!(
|
||||||
assert_eq!(deleted.version_id, Some(uuid::Uuid::nil()));
|
matches!(delete_err, Error::SlowDown),
|
||||||
let marker_mod_time = deleted.mod_time.expect("DELETE must return the marker generation");
|
"unexpected suspended DELETE result: {delete_err:?}"
|
||||||
|
);
|
||||||
assert_suspended_null_source_present(&store, &bucket, object).await;
|
assert_suspended_null_source_present(&store, &bucket, object).await;
|
||||||
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
|
||||||
|
|
||||||
let source_set = store.pools[0].get_disks_by_key(object);
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
let worker_store = Arc::clone(&store);
|
let worker_store = Arc::clone(&store);
|
||||||
@@ -8124,7 +7738,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("suspended decommission worker should join")
|
.expect("suspended decommission worker should join")
|
||||||
.expect("worker must converge the old null source behind the newer DELETE marker");
|
.expect("worker must migrate the fenced suspended source");
|
||||||
|
|
||||||
assert_decommission_source_absent(
|
assert_decommission_source_absent(
|
||||||
&store,
|
&store,
|
||||||
@@ -8136,7 +7750,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
assert_eq!(
|
||||||
|
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
||||||
|
b"suspended source generation"
|
||||||
|
);
|
||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8169,7 +7786,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let (deleted, errors) = store
|
let (_deleted, errors) = store
|
||||||
.delete_objects(
|
.delete_objects(
|
||||||
&bucket,
|
&bucket,
|
||||||
vec![ObjectToDelete {
|
vec![ObjectToDelete {
|
||||||
@@ -8183,23 +7800,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(errors.as_slice(), [None]),
|
matches!(errors.as_slice(), [Some(Error::SlowDown)]),
|
||||||
"unexpected suspended batch DELETE result: {errors:?}"
|
"unexpected suspended batch DELETE result: {errors:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(deleted.len(), 1);
|
|
||||||
assert!(deleted[0].delete_marker);
|
|
||||||
assert_eq!(deleted[0].object_name, object);
|
|
||||||
assert!(
|
|
||||||
deleted[0]
|
|
||||||
.delete_marker_version_id
|
|
||||||
.is_none_or(|version_id| version_id.is_nil()),
|
|
||||||
"batch DELETE must retain the native null version identity"
|
|
||||||
);
|
|
||||||
let marker_mod_time = deleted[0]
|
|
||||||
.delete_marker_mtime
|
|
||||||
.expect("batch DELETE must return the marker generation");
|
|
||||||
assert_suspended_null_source_present(&store, &bucket, object).await;
|
assert_suspended_null_source_present(&store, &bucket, object).await;
|
||||||
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
|
||||||
|
|
||||||
let source_set = store.pools[0].get_disks_by_key(object);
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
let worker_store = Arc::clone(&store);
|
let worker_store = Arc::clone(&store);
|
||||||
@@ -8219,7 +7823,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("suspended batch decommission worker should join")
|
.expect("suspended batch decommission worker should join")
|
||||||
.expect("worker must converge the old null source behind the newer batch DELETE marker");
|
.expect("worker must migrate the batch-fenced suspended source");
|
||||||
|
|
||||||
assert_decommission_source_absent(
|
assert_decommission_source_absent(
|
||||||
&store,
|
&store,
|
||||||
@@ -8231,7 +7835,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
assert_eq!(
|
||||||
|
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
||||||
|
b"suspended source generation"
|
||||||
|
);
|
||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -762,7 +762,13 @@ impl ECStore {
|
|||||||
self.pools
|
self.pools
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(pool_index, _)| pool_meta.scanner_pause_backlog_pool_writable(*pool_index))
|
.filter(|(pool_index, _)| {
|
||||||
|
!pool_meta.pools.get(*pool_index).is_some_and(|pool| {
|
||||||
|
pool.decommission
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|info| info.has_decommission_state() && !info.failed && !info.canceled)
|
||||||
|
})
|
||||||
|
})
|
||||||
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::bucket::object_lock::objectlock_sys::{
|
|||||||
};
|
};
|
||||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||||
use crate::bucket::versioning::VersioningApi;
|
use crate::bucket::versioning::VersioningApi;
|
||||||
use crate::core::pools::{DecommissionCapacityAdmission, DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
use crate::core::pools::{DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
||||||
use crate::disk::OldCurrentSize;
|
use crate::disk::OldCurrentSize;
|
||||||
use crate::object_api::{
|
use crate::object_api::{
|
||||||
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
||||||
@@ -3060,68 +3060,6 @@ impl ECStore {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a native scanner replica without allowing stale pool selection
|
|
||||||
/// to race retirement. Failed/canceled membership repair remains permitted.
|
|
||||||
/// A canceled waiter cannot release publication fences from an in-flight write.
|
|
||||||
pub async fn save_scanner_pause_backlog_replica(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
pool_index: usize,
|
|
||||||
set_index: usize,
|
|
||||||
data: Vec<u8>,
|
|
||||||
preconditions: crate::storage_api_contracts::object::HTTPPreconditions,
|
|
||||||
) -> Result<ObjectInfo> {
|
|
||||||
let set = self
|
|
||||||
.pools
|
|
||||||
.get(pool_index)
|
|
||||||
.and_then(|pool| pool.disk_set.get(set_index))
|
|
||||||
.ok_or_else(|| Error::InvalidArgument("scanner-backlog".into(), "replica".into(), "unknown pool or set".into()))?;
|
|
||||||
let set = Arc::clone(set);
|
|
||||||
let store = Arc::clone(self);
|
|
||||||
let write = async move {
|
|
||||||
let object = "buckets/.scanner-pause-backlog.json";
|
|
||||||
let mut opts = ObjectOptions {
|
|
||||||
max_parity: true,
|
|
||||||
http_preconditions: Some(preconditions),
|
|
||||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
// Match migration: fixed object namespace -> durable pool metadata ->
|
|
||||||
// actual replica namespace. The replica need not be the hash-routed set.
|
|
||||||
let object_guard = if store.single_pool() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
store
|
|
||||||
.acquire_object_write_lock("scanner_backlog_replica", RUSTFS_META_BUCKET, object)
|
|
||||||
.await?,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let capacity_guard = if let Some(guard) = object_guard.as_ref() {
|
|
||||||
guard.add_namespace_lock_fence(&mut opts);
|
|
||||||
opts.no_lock = match store.pools.first().and_then(|pool| pool.disk_set.first()) {
|
|
||||||
Some(fixed) => fixed.shares_namespace_lock_domain(&set).await,
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
let capacity_guard = store
|
|
||||||
.acquire_external_decommission_capacity_fence(&[pool_index], DecommissionCapacityAdmission::ScannerBacklog)
|
|
||||||
.await?;
|
|
||||||
opts.add_namespace_lock_guard(&capacity_guard);
|
|
||||||
Some(capacity_guard)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let result = set
|
|
||||||
.put_object(RUSTFS_META_BUCKET, object, &mut PutObjReader::from_vec(data), &opts)
|
|
||||||
.await;
|
|
||||||
drop(capacity_guard);
|
|
||||||
drop(object_guard);
|
|
||||||
result
|
|
||||||
};
|
|
||||||
// The set layer may detach its rename owner, even for full-tail writes.
|
|
||||||
// Keep these outer guards alive until that owner finishes if scanner exits.
|
|
||||||
tokio::spawn(write).await.map_err(Error::from)?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn run_external_decommission_capacity_object_mutation<T, F, Fut>(
|
pub(super) async fn run_external_decommission_capacity_object_mutation<T, F, Fut>(
|
||||||
&self,
|
&self,
|
||||||
target_pool_idx: usize,
|
target_pool_idx: usize,
|
||||||
@@ -3191,11 +3129,8 @@ impl ECStore {
|
|||||||
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
||||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
||||||
} else {
|
} else {
|
||||||
self.acquire_external_decommission_capacity_fence_with_active_source(
|
self.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
||||||
&[target_pool_idx],
|
.await?
|
||||||
DecommissionCapacityAdmission::Mutation,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
};
|
};
|
||||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||||
// Active migration acquires the object namespace before its capacity
|
// Active migration acquires the object namespace before its capacity
|
||||||
@@ -3211,7 +3146,7 @@ impl ECStore {
|
|||||||
let capacity_guard = if capacity_releasing {
|
let capacity_guard = if capacity_releasing {
|
||||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
||||||
} else {
|
} else {
|
||||||
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Mutation)
|
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], "mutation")
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
(capacity_guard, Some(guard))
|
(capacity_guard, Some(guard))
|
||||||
@@ -3241,10 +3176,9 @@ impl ECStore {
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
no_lock: bool,
|
no_lock: bool,
|
||||||
admission: DecommissionCapacityAdmission,
|
|
||||||
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
||||||
let (capacity_guard, has_active_decommission) = self
|
let (capacity_guard, has_active_decommission) = self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], admission)
|
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
||||||
.await?;
|
.await?;
|
||||||
if !has_active_decommission {
|
if !has_active_decommission {
|
||||||
// Keep the read probe through the staged commit. This closes the
|
// Keep the read probe through the staged commit. This closes the
|
||||||
@@ -3293,10 +3227,7 @@ impl ECStore {
|
|||||||
return operation(opts).await;
|
return operation(opts).await;
|
||||||
}
|
}
|
||||||
let (capacity_guard, has_active_decommission) = self
|
let (capacity_guard, has_active_decommission) = self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(
|
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "heal")
|
||||||
&[target_pool_idx],
|
|
||||||
DecommissionCapacityAdmission::Heal,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||||
// Active migration acquires the object namespace before its capacity
|
// Active migration acquires the object namespace before its capacity
|
||||||
@@ -3317,7 +3248,7 @@ impl ECStore {
|
|||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
let capacity_guard = self
|
let capacity_guard = self
|
||||||
.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Heal)
|
.acquire_external_decommission_capacity_fence(&[target_pool_idx], "heal")
|
||||||
.await?;
|
.await?;
|
||||||
opts.no_lock = target_lock_covered;
|
opts.no_lock = target_lock_covered;
|
||||||
(capacity_guard, Some(guard))
|
(capacity_guard, Some(guard))
|
||||||
@@ -5162,14 +5093,9 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
||||||
// Only marker destinations can grow. Other pools participate in
|
let target_pool_indices = (0..self.pools.len()).collect::<Vec<_>>();
|
||||||
// exact deletion under the same metadata read fence and must not
|
|
||||||
// be treated as publication targets merely because they retire.
|
|
||||||
let mut target_pool_indices = marker_target_pool_indices.iter().flatten().copied().collect::<Vec<_>>();
|
|
||||||
target_pool_indices.sort_unstable();
|
|
||||||
target_pool_indices.dedup();
|
|
||||||
match self
|
match self
|
||||||
.acquire_external_decommission_capacity_fence(&target_pool_indices, DecommissionCapacityAdmission::BatchDelete)
|
.acquire_external_decommission_capacity_fence(&target_pool_indices, "batch_delete")
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(fence) => Some(fence),
|
Ok(fence) => Some(fence),
|
||||||
|
|||||||
@@ -62,51 +62,6 @@ pub fn record_contention_event() {
|
|||||||
counter!("rustfs_lock_contentions").increment(1);
|
counter!("rustfs_lock_contentions").increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a remote lock RPC that exceeded its caller's deadline.
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn record_remote_lock_rpc_timeout(peer: &str, op: &'static str) {
|
|
||||||
use metrics::counter;
|
|
||||||
counter!("rustfs_remote_lock_rpc_timeouts_total", "peer" => peer.to_string(), "op" => op).increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record the cached lock channel to `peer` being evicted after an RPC failure.
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn record_remote_lock_channel_eviction(peer: &str, trigger: &'static str) {
|
|
||||||
use metrics::counter;
|
|
||||||
counter!("rustfs_remote_lock_channel_evictions_total", "peer" => peer.to_string(), "trigger" => trigger).increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record an RPC failure that did not evict the cached lock channel to `peer`
|
|
||||||
/// because the peer recently served a request or was re-dialed too recently.
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn record_remote_lock_channel_eviction_suppressed(peer: &str, verdict: &'static str) {
|
|
||||||
use metrics::counter;
|
|
||||||
counter!("rustfs_remote_lock_channel_evictions_suppressed_total", "peer" => peer.to_string(), "verdict" => verdict)
|
|
||||||
.increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a timed-out lock RPC that was left running (`detached`) or cancelled
|
|
||||||
/// because the per-peer detached budget was exhausted (`aborted`).
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn record_remote_lock_rpc_detached(op: &'static str, outcome: &'static str) {
|
|
||||||
use metrics::counter;
|
|
||||||
counter!("rustfs_remote_lock_rpc_detached_total", "op" => op, "outcome" => outcome).increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record how a detached lock RPC eventually ended.
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn record_remote_lock_rpc_late_completion(op: &'static str, outcome: &'static str) {
|
|
||||||
use metrics::counter;
|
|
||||||
counter!("rustfs_remote_lock_rpc_late_completions_total", "op" => op, "outcome" => outcome).increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record the release of a lock that was granted after its caller timed out.
|
|
||||||
#[inline(always)]
|
|
||||||
pub fn record_remote_lock_late_release(outcome: &'static str) {
|
|
||||||
use metrics::counter;
|
|
||||||
counter!("rustfs_remote_lock_late_releases_total", "outcome" => outcome).increment(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record object namespace lock diagnostics being enabled.
|
/// Record object namespace lock diagnostics being enabled.
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
||||||
@@ -228,12 +183,6 @@ mod tests {
|
|||||||
record_lock_hold_time(Duration::from_millis(100));
|
record_lock_hold_time(Duration::from_millis(100));
|
||||||
record_early_release();
|
record_early_release();
|
||||||
record_contention_event();
|
record_contention_event();
|
||||||
record_remote_lock_rpc_timeout("http://peer:9000", "lock");
|
|
||||||
record_remote_lock_channel_eviction("http://peer:9000", "timeout");
|
|
||||||
record_remote_lock_channel_eviction_suppressed("http://peer:9000", "peer_recently_served");
|
|
||||||
record_remote_lock_rpc_detached("lock", "detached");
|
|
||||||
record_remote_lock_rpc_late_completion("lock", "success");
|
|
||||||
record_remote_lock_late_release("released");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let emitted: std::collections::HashSet<String> = snapshotter
|
let emitted: std::collections::HashSet<String> = snapshotter
|
||||||
@@ -250,12 +199,6 @@ mod tests {
|
|||||||
"rustfs_lock_hold_time_secs",
|
"rustfs_lock_hold_time_secs",
|
||||||
"rustfs_lock_early_releases",
|
"rustfs_lock_early_releases",
|
||||||
"rustfs_lock_contentions",
|
"rustfs_lock_contentions",
|
||||||
"rustfs_remote_lock_rpc_timeouts_total",
|
|
||||||
"rustfs_remote_lock_channel_evictions_total",
|
|
||||||
"rustfs_remote_lock_channel_evictions_suppressed_total",
|
|
||||||
"rustfs_remote_lock_rpc_detached_total",
|
|
||||||
"rustfs_remote_lock_rpc_late_completions_total",
|
|
||||||
"rustfs_remote_lock_late_releases_total",
|
|
||||||
] {
|
] {
|
||||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,16 +38,6 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
||||||
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
||||||
/// Slow retry schedule for unlocks that survive the fast retry loop. Lock RPC
|
|
||||||
/// timeouts under load are transient (issue #7363); giving up after three
|
|
||||||
/// quick attempts left orphaned entries for the server lease to expire.
|
|
||||||
const DEFERRED_UNLOCK_BACKOFF: [Duration; 5] = [
|
|
||||||
Duration::from_secs(1),
|
|
||||||
Duration::from_secs(2),
|
|
||||||
Duration::from_secs(4),
|
|
||||||
Duration::from_secs(8),
|
|
||||||
Duration::from_secs(16),
|
|
||||||
];
|
|
||||||
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
||||||
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
||||||
@@ -729,7 +719,22 @@ impl DistributedLock {
|
|||||||
let mut pending = entries;
|
let mut pending = entries;
|
||||||
|
|
||||||
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
||||||
pending = Self::release_pending_once(pending, attempt, context).await;
|
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
||||||
|
match client.release(&lock_id).await {
|
||||||
|
Ok(true) => None,
|
||||||
|
Ok(false) => {
|
||||||
|
warn!(%lock_id, attempt, context, "distributed unlock did not find lock on client");
|
||||||
|
Some((lock_id, client))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(%lock_id, attempt, context, "distributed unlock failed on client: {}", err);
|
||||||
|
Some((lock_id, client))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
pending = release_results.into_iter().flatten().collect();
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
debug!(attempt, context, "distributed unlock completed");
|
debug!(attempt, context, "distributed unlock completed");
|
||||||
return;
|
return;
|
||||||
@@ -744,54 +749,7 @@ impl DistributedLock {
|
|||||||
remaining = pending.len(),
|
remaining = pending.len(),
|
||||||
attempts = UNLOCK_RETRY_ATTEMPTS,
|
attempts = UNLOCK_RETRY_ATTEMPTS,
|
||||||
context,
|
context,
|
||||||
"distributed unlock left unreleased entries after retry; continuing with deferred retries"
|
"distributed unlock left unreleased entries after retry"
|
||||||
);
|
|
||||||
Self::release_entries_deferred(pending, context).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn release_pending_once(
|
|
||||||
pending: Vec<(LockId, Arc<dyn LockClient>)>,
|
|
||||||
attempt: usize,
|
|
||||||
context: &'static str,
|
|
||||||
) -> Vec<(LockId, Arc<dyn LockClient>)> {
|
|
||||||
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
|
||||||
match client.release(&lock_id).await {
|
|
||||||
Ok(true) => None,
|
|
||||||
Ok(false) => {
|
|
||||||
warn!(%lock_id, attempt, context, "distributed unlock did not find lock on client");
|
|
||||||
Some((lock_id, client))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(%lock_id, attempt, context, "distributed unlock failed on client: {}", err);
|
|
||||||
Some((lock_id, client))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
release_results.into_iter().flatten().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bounded slow retries for entries the fast loop could not release. Every
|
|
||||||
/// caller runs on a background task, so waiting here blocks nobody; after
|
|
||||||
/// the schedule is exhausted the server lease reclaims the entry.
|
|
||||||
async fn release_entries_deferred(mut pending: Vec<(LockId, Arc<dyn LockClient>)>, context: &'static str) {
|
|
||||||
let mut attempt = UNLOCK_RETRY_ATTEMPTS;
|
|
||||||
for delay in DEFERRED_UNLOCK_BACKOFF {
|
|
||||||
tokio::time::sleep(delay).await;
|
|
||||||
attempt += 1;
|
|
||||||
pending = Self::release_pending_once(pending, attempt, context).await;
|
|
||||||
if pending.is_empty() {
|
|
||||||
debug!(attempt, context, "deferred distributed unlock converged");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!(
|
|
||||||
remaining = pending.len(),
|
|
||||||
attempts = attempt,
|
|
||||||
context,
|
|
||||||
"distributed unlock abandoned entries after deferred retry; the server lease will expire them"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,9 +795,7 @@ impl DistributedLock {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deferred retries may wait tens of seconds; never hold up the
|
Self::release_entries(vec![(lock_id, client.clone())], context).await;
|
||||||
// next late completion behind them.
|
|
||||||
drop(tokio::spawn(Self::release_entries(vec![(lock_id, client.clone())], context)));
|
|
||||||
}
|
}
|
||||||
Ok((idx, Ok(resp))) => {
|
Ok((idx, Ok(resp))) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1242,8 +1198,8 @@ fn record_lock_held_release(lock_type: LockType) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DEFERRED_UNLOCK_BACKOFF, DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF,
|
DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF, LockAcquireFailureKind,
|
||||||
LockAcquireFailureKind, LockLostSignal, UNLOCK_RETRY_ATTEMPTS, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
LockLostSignal, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
||||||
};
|
};
|
||||||
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
||||||
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
||||||
@@ -1736,94 +1692,6 @@ mod tests {
|
|||||||
drop(guard);
|
drop(guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fails `release` a fixed number of times before succeeding, mimicking a
|
|
||||||
/// peer whose lock RPCs time out under load and then recover.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct FlakyReleaseClient {
|
|
||||||
failures_left: AtomicUsize,
|
|
||||||
release_calls: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl LockClient for FlakyReleaseClient {
|
|
||||||
async fn acquire_lock(&self, _request: &LockRequest) -> crate::Result<LockResponse> {
|
|
||||||
Ok(LockResponse::failure("unused", Duration::ZERO))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
|
||||||
self.release_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
if self.failures_left.load(Ordering::SeqCst) > 0 {
|
|
||||||
self.failures_left.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
return Err(LockError::internal("remote lock rpc timed out: release"));
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn force_release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_status(&self, _lock_id: &LockId) -> crate::Result<Option<LockInfo>> {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_stats(&self) -> crate::Result<LockStats> {
|
|
||||||
Ok(LockStats::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn close(&self) -> crate::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_online(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_local(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn release_entries_keeps_retrying_transient_failures_after_the_fast_loop() {
|
|
||||||
let release_calls = Arc::new(AtomicUsize::new(0));
|
|
||||||
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
|
||||||
failures_left: AtomicUsize::new(UNLOCK_RETRY_ATTEMPTS + 2),
|
|
||||||
release_calls: release_calls.clone(),
|
|
||||||
});
|
|
||||||
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
|
||||||
|
|
||||||
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock").await;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
release_calls.load(Ordering::SeqCst),
|
|
||||||
UNLOCK_RETRY_ATTEMPTS + 3,
|
|
||||||
"two deferred attempts fail, the third releases the entry"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn release_entries_gives_up_after_the_deferred_schedule() {
|
|
||||||
let release_calls = Arc::new(AtomicUsize::new(0));
|
|
||||||
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
|
||||||
failures_left: AtomicUsize::new(usize::MAX),
|
|
||||||
release_calls: release_calls.clone(),
|
|
||||||
});
|
|
||||||
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
|
||||||
|
|
||||||
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock_abandoned").await;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
release_calls.load(Ordering::SeqCst),
|
|
||||||
UNLOCK_RETRY_ATTEMPTS + DEFERRED_UNLOCK_BACKOFF.len(),
|
|
||||||
"the retry budget is bounded; the server lease reclaims what remains"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ResponseClient {
|
struct ResponseClient {
|
||||||
response: LockResponse,
|
response: LockResponse,
|
||||||
|
|||||||
@@ -166,24 +166,6 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap<String, String>) -> bool {
|
|
||||||
let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
|
|
||||||
(None, None) => true,
|
|
||||||
(Some(key), _) if key.is_empty() => true,
|
|
||||||
(Some(key), Some(value)) => object_tags.get(key) == Some(value),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
filter
|
|
||||||
.and
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|and| and.tags.as_deref())
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.chain(filter.tag.iter())
|
|
||||||
.all(tag_matches)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ReplicationTargetValidationError {
|
pub enum ReplicationTargetValidationError {
|
||||||
RoleWithMultipleDestinations,
|
RoleWithMultipleDestinations,
|
||||||
@@ -722,7 +704,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
|||||||
|
|
||||||
if let Some(filter) = &rule.filter {
|
if let Some(filter) = &rule.filter {
|
||||||
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
|
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
|
||||||
if replication_filter_tags_match(filter, &object_tags) {
|
if filter.test_tags(&object_tags) {
|
||||||
rules.push(rule.clone());
|
rules.push(rule.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1157,47 +1139,6 @@ mod tests {
|
|||||||
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
|
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn actionable_rules_require_every_and_tag_to_match() {
|
|
||||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
|
||||||
rule.filter = Some(s3s::dto::ReplicationRuleFilter {
|
|
||||||
and: Some(s3s::dto::ReplicationRuleAndOperator {
|
|
||||||
prefix: None,
|
|
||||||
tags: Some(vec![
|
|
||||||
s3s::dto::Tag {
|
|
||||||
key: Some("env".to_string()),
|
|
||||||
value: Some("prod".to_string()),
|
|
||||||
},
|
|
||||||
s3s::dto::Tag {
|
|
||||||
key: Some("tier".to_string()),
|
|
||||||
value: Some("gold".to_string()),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
let config = structure_config(vec![rule]);
|
|
||||||
let object = |user_tags: &str| ObjectOpts {
|
|
||||||
name: "object".to_string(),
|
|
||||||
user_tags: user_tags.to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.filter_target_arns(&object("env=prod")).is_empty());
|
|
||||||
assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
|
|
||||||
assert!(config.filter_target_arns(&object("")).is_empty());
|
|
||||||
|
|
||||||
let mut malformed = config;
|
|
||||||
malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
|
|
||||||
key: Some("env".to_string()),
|
|
||||||
value: None,
|
|
||||||
}]);
|
|
||||||
assert!(
|
|
||||||
malformed.filter_target_arns(&object("env=prod")).is_empty(),
|
|
||||||
"a malformed tag filter must fail closed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
|
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
|
||||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||||
|
|||||||
@@ -580,30 +580,6 @@ impl FailStats {
|
|||||||
FailedMetric { count, size }
|
FailedMetric { count, size }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Both rolling windows from one walk of the samples. `short` must be the
|
|
||||||
/// narrower window; the walk stops at `long`. Callers that need both (the
|
|
||||||
/// per-node site snapshot) would otherwise scan the deque twice while
|
|
||||||
/// holding the bucket-stats read lock, and the deque is only bounded by
|
|
||||||
/// the one-hour window - an unreachable target under load fills it.
|
|
||||||
pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut short_metric = FailedMetric::default();
|
|
||||||
let mut long_metric = FailedMetric::default();
|
|
||||||
for sample in self.recent.iter().rev() {
|
|
||||||
let age = now.duration_since(sample.observed_at);
|
|
||||||
if age > long {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if age <= short {
|
|
||||||
short_metric.count += 1;
|
|
||||||
short_metric.size += sample.size;
|
|
||||||
}
|
|
||||||
long_metric.count += 1;
|
|
||||||
long_metric.size += sample.size;
|
|
||||||
}
|
|
||||||
(short_metric, long_metric)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge(&self, other: &FailStats) -> Self {
|
pub fn merge(&self, other: &FailStats) -> Self {
|
||||||
Self {
|
Self {
|
||||||
count: self.count.saturating_add(other.count),
|
count: self.count.saturating_add(other.count),
|
||||||
@@ -936,26 +912,6 @@ mod tests {
|
|||||||
assert_eq!(last_hour.size, 96);
|
assert_eq!(last_hour.size, 96);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fail_stats_recent_windows_matches_two_separate_scans() {
|
|
||||||
let mut stats = FailStats::default();
|
|
||||||
stats.add_size(64, None::<&()>);
|
|
||||||
stats.add_size(32, None::<&()>);
|
|
||||||
|
|
||||||
let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
|
||||||
let expected_minute = stats.recent_since(Duration::from_secs(60));
|
|
||||||
let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
|
|
||||||
|
|
||||||
assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
|
|
||||||
assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
|
|
||||||
assert_eq!(minute.count, 2);
|
|
||||||
assert_eq!(hour.size, 96);
|
|
||||||
|
|
||||||
let empty = FailStats::default();
|
|
||||||
let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
|
||||||
assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fail_stats_saturate_instead_of_wrapping() {
|
fn fail_stats_saturate_instead_of_wrapping() {
|
||||||
let mut stats = FailStats {
|
let mut stats = FailStats {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ use super::ScannerCycleOutcome;
|
|||||||
use crate::data_usage_define::DataUsageCacheRevision;
|
use crate::data_usage_define::DataUsageCacheRevision;
|
||||||
use crate::storage_api::ScannerStorage;
|
use crate::storage_api::ScannerStorage;
|
||||||
use crate::storage_api::owner::ObjectIO as _;
|
use crate::storage_api::owner::ObjectIO as _;
|
||||||
use crate::{BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks};
|
use crate::{
|
||||||
|
BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks, save_config_with_preconditions,
|
||||||
|
};
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -1193,14 +1195,13 @@ where
|
|||||||
};
|
};
|
||||||
let revision = revisions.get(&id).cloned();
|
let revision = revisions.get(&id).cloned();
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
let storeapi = storeapi.clone();
|
|
||||||
async move {
|
async move {
|
||||||
let Some(revision) = revision else {
|
let Some(revision) = revision else {
|
||||||
return (id, Err("replica revision is unavailable".to_string()));
|
return (id, Err("replica revision is unavailable".to_string()));
|
||||||
};
|
};
|
||||||
let result = storeapi
|
let result = save_config_with_preconditions(set, SCANNER_PAUSE_BACKLOG_PATH.as_str(), data, revision.preconditions())
|
||||||
.save_scanner_pause_backlog_replica(id.pool_index, id.set_index, data, revision.preconditions())
|
|
||||||
.await
|
.await
|
||||||
|
.map(|_| ())
|
||||||
.map_err(|err| err.to_string());
|
.map_err(|err| err.to_string());
|
||||||
(id, result)
|
(id, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,49 +152,6 @@ fn run_data_scanner_keeps_its_two_argument_api() {
|
|||||||
assert_run_data_scanner_signature(run_data_scanner);
|
assert_run_data_scanner_signature(run_data_scanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn native_backlog_replica_writes_preserve_cas_across_writer_restart() {
|
|
||||||
let (_temp_dir, store) = setup_scanner_cycle_store_with_pool_count(false, 2).await;
|
|
||||||
let now = scanner_pause_backlog_now();
|
|
||||||
let mut stale = ScannerPauseBacklogController::claim(store.clone(), now)
|
|
||||||
.await
|
|
||||||
.expect("the original scanner writer must publish to both pools");
|
|
||||||
let original = scanner_pause_backlog_status(store.clone()).await;
|
|
||||||
assert!(original.durable);
|
|
||||||
assert_eq!(original.healthy_replicas, 2);
|
|
||||||
let restarted = restart_scanner_cycle_store_from(&store).await;
|
|
||||||
let _replacement = ScannerPauseBacklogController::claim(restarted.clone(), now.saturating_add(1))
|
|
||||||
.await
|
|
||||||
.expect("the restarted scanner must claim the surviving native replicas");
|
|
||||||
let claimed = scanner_pause_backlog_status(restarted.clone()).await;
|
|
||||||
assert!(claimed.writer_epoch > original.writer_epoch);
|
|
||||||
assert_eq!(claimed.healthy_replicas, 2);
|
|
||||||
|
|
||||||
stale
|
|
||||||
.observe(ScannerPauseBacklogObservation {
|
|
||||||
now_unix_secs: now.saturating_add(2),
|
|
||||||
paused: true,
|
|
||||||
movement_generation: store.scanner_data_movement_generation().saturating_add(1),
|
|
||||||
movement_work_items: 1,
|
|
||||||
pause_started_at_unix_secs: now.saturating_add(2),
|
|
||||||
dirty_usage_buckets: 0,
|
|
||||||
discovered_expiry_items: 0,
|
|
||||||
discovered_transition_items: 0,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let retained = scanner_pause_backlog_status(restarted.clone()).await;
|
|
||||||
assert_eq!(retained.writer_epoch, claimed.writer_epoch);
|
|
||||||
assert_eq!(retained.generation, claimed.generation);
|
|
||||||
assert_eq!(retained.phase, ScannerPauseBacklogPhase::Idle);
|
|
||||||
assert_eq!(retained.healthy_replicas, 2);
|
|
||||||
assert_eq!(retained.stale_or_unavailable_replicas, 0);
|
|
||||||
let _recovered = ScannerPauseBacklogController::claim(restarted.clone(), now.saturating_add(3))
|
|
||||||
.await
|
|
||||||
.expect("a fresh writer must still recover after the stale CAS failure");
|
|
||||||
assert!(scanner_pause_backlog_status(restarted).await.error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restarted_main_loop_completes_durable_pause_backlog_catch_up() {
|
async fn restarted_main_loop_completes_durable_pause_backlog_catch_up() {
|
||||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||||
|
|||||||
@@ -355,13 +355,6 @@ pub(crate) trait ScannerStorage:
|
|||||||
async fn list_bucket_for_scanner(&self, opts: &storage_contracts::BucketOptions) -> EcstoreResultType<ScannerBucketListing>;
|
async fn list_bucket_for_scanner(&self, opts: &storage_contracts::BucketOptions) -> EcstoreResultType<ScannerBucketListing>;
|
||||||
fn all_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
fn all_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
||||||
async fn scanner_pause_backlog_writable_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
async fn scanner_pause_backlog_writable_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
||||||
async fn save_scanner_pause_backlog_replica(
|
|
||||||
self: Arc<Self>,
|
|
||||||
pool_index: usize,
|
|
||||||
set_index: usize,
|
|
||||||
data: Vec<u8>,
|
|
||||||
preconditions: storage_contracts::HTTPPreconditions,
|
|
||||||
) -> EcstoreResultType<()>;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn scanner_observed_probe_store_key(&self) -> usize;
|
fn scanner_observed_probe_store_key(&self) -> usize;
|
||||||
}
|
}
|
||||||
@@ -424,18 +417,6 @@ impl ScannerStorage for EcstoreStore {
|
|||||||
EcstoreStore::scanner_pause_backlog_writable_set_disks(self).await
|
EcstoreStore::scanner_pause_backlog_writable_set_disks(self).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_scanner_pause_backlog_replica(
|
|
||||||
self: Arc<Self>,
|
|
||||||
pool_index: usize,
|
|
||||||
set_index: usize,
|
|
||||||
data: Vec<u8>,
|
|
||||||
preconditions: storage_contracts::HTTPPreconditions,
|
|
||||||
) -> EcstoreResultType<()> {
|
|
||||||
EcstoreStore::save_scanner_pause_backlog_replica(&self, pool_index, set_index, data, preconditions)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn scanner_observed_probe_store_key(&self) -> usize {
|
fn scanner_observed_probe_store_key(&self) -> usize {
|
||||||
std::ptr::from_ref(self).cast::<()>() as usize
|
std::ptr::from_ref(self).cast::<()>() as usize
|
||||||
@@ -596,20 +577,6 @@ mod tests {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_scanner_pause_backlog_replica(
|
|
||||||
self: Arc<Self>,
|
|
||||||
_pool_index: usize,
|
|
||||||
_set_index: usize,
|
|
||||||
_data: Vec<u8>,
|
|
||||||
_preconditions: storage_contracts::HTTPPreconditions,
|
|
||||||
) -> EcstoreResultType<()> {
|
|
||||||
Err(EcstoreErrorType::InvalidArgument(
|
|
||||||
"scanner-backlog".into(),
|
|
||||||
"replica".into(),
|
|
||||||
"fake storage has no writable replicas".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scanner_observed_probe_store_key(&self) -> usize {
|
fn scanner_observed_probe_store_key(&self) -> usize {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Documentation
|
|
||||||
|
|
||||||
Use the focused indexes rather than treating this directory as an unordered
|
|
||||||
collection:
|
|
||||||
|
|
||||||
- [Architecture knowledge base](architecture/README.md)
|
|
||||||
- [Testing references](testing/README.md)
|
|
||||||
|
|
||||||
## Operations
|
|
||||||
|
|
||||||
Operational runbooks live under [`operations/`](operations/). Replication
|
|
||||||
operators should start with:
|
|
||||||
|
|
||||||
| Runbook | Use it for |
|
|
||||||
|---|---|
|
|
||||||
| [Site replication operations](operations/site-replication-operations.md) | Health fields, pending operations, outage recovery, re-pair admission, IAM/SSE boundaries, and upgrades. |
|
|
||||||
| [Replication target check](operations/replication-check.md) | Validating an S3 destination and version fidelity before enabling replication. |
|
|
||||||
| [Replication object size limits](operations/replication-object-size-limits.md) | Multipart routing, large-object limits, and retry characteristics. |
|
|
||||||
| [Replication outbound transport](operations/replication-outbound-transport.md) | Integrity headers, generic target behavior, and transport knobs. |
|
|
||||||
|
|
||||||
Other runbooks remain grouped by filename in [`operations/`](operations/);
|
|
||||||
architecture pages link to the relevant runbook where a cross-boundary
|
|
||||||
procedure is required.
|
|
||||||
@@ -60,8 +60,6 @@ Required headings and strings in these files are asserted by `scripts/check_arch
|
|||||||
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
|
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
|
||||||
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
|
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
|
||||||
|
|
||||||
Operations runbooks are registered in the [documentation operations index](../README.md#operations), and testing references live in [../testing/README.md](../testing/README.md).
|
Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
|
||||||
|
|
||||||
For replication operations, start with [site replication operations](../operations/site-replication-operations.md), [replication target check](../operations/replication-check.md), [replication object size limits](../operations/replication-object-size-limits.md), and [replication outbound transport](../operations/replication-outbound-transport.md).
|
|
||||||
|
|
||||||
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
|
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
|
|
||||||
RustFS supports queued multi-pool decommission start requests on multi-pool deployments. The admin handler accepts the MinIO-compatible request shape, including comma-separated pool targets. An empty target list is rejected; single-pool deployments reject decommission because there is no destination pool; on multi-pool deployments one or more valid target pools are accepted as a single queued operation.
|
RustFS supports queued multi-pool decommission start requests on multi-pool deployments. The admin handler accepts the MinIO-compatible request shape, including comma-separated pool targets. An empty target list is rejected; single-pool deployments reject decommission because there is no destination pool; on multi-pool deployments one or more valid target pools are accepted as a single queued operation.
|
||||||
|
|
||||||
Deterministic request rejections (unsupported single-pool operations, missing or terminal targets, an empty start request, removing the last active pool, and clearing unresolved recovery entries) retain the typed `InvalidArgument` error and its actionable reason. Active-operation conflicts retain their existing `InvalidRequest` or `OperationAborted` contract. Storage, quorum, and fleet-proof failures are not converted into argument errors.
|
|
||||||
|
|
||||||
### Request Semantics
|
### Request Semantics
|
||||||
|
|
||||||
`POST /v3/pools/decommission` with comma-separated pool targets is a queue submission:
|
`POST /v3/pools/decommission` with comma-separated pool targets is a queue submission:
|
||||||
@@ -17,8 +15,7 @@ Deterministic request rejections (unsupported single-pool operations, missing or
|
|||||||
- reject duplicate target pools in the same request;
|
- reject duplicate target pools in the same request;
|
||||||
- reject active or queued target pools;
|
- reject active or queued target pools;
|
||||||
- reject completed decommission targets, because completion means the pool can be removed from the deployment configuration;
|
- reject completed decommission targets, because completion means the pool can be removed from the deployment configuration;
|
||||||
- require failed or canceled targets to be cleared before restarting, except
|
- allow failed or canceled targets to be retried;
|
||||||
when unresolved listing entries require an explicit recovery retry;
|
|
||||||
- persist queued metadata before starting workers;
|
- persist queued metadata before starting workers;
|
||||||
- start only the local-leader prefix of the queue on the receiving node.
|
- start only the local-leader prefix of the queue on the receiving node.
|
||||||
|
|
||||||
@@ -59,29 +56,6 @@ Cancel separates active and queued behavior:
|
|||||||
|
|
||||||
Cancel requests can be accepted on non-leader nodes as remote cancel intent; the leader observes the pending cancel and applies it to the active worker.
|
Cancel requests can be accepted on non-leader nodes as remote cancel intent; the leader observes the pending cancel and applies it to the active worker.
|
||||||
|
|
||||||
`queuedBuckets` retains the unfinished work inventory after cancellation. It is
|
|
||||||
not evidence of active scheduling: `queued` is false and `startTime` is absent.
|
|
||||||
Operators and tests must inspect the terminal flags, peer state and progress
|
|
||||||
stability instead of requiring the historical inventory to be empty. A normal
|
|
||||||
canceled entry remains blocked until clear; unresolved listing entries instead
|
|
||||||
retain the explicit retry path that can re-observe or resolve those entries.
|
|
||||||
|
|
||||||
### Publication On Retiring Pools
|
|
||||||
|
|
||||||
Ordinary publication rechecks the selected pool against the durable pool metadata under its existing read fence. Selection may have happened before retirement, or on a node whose local pool state has not been refreshed. A staged new PUT must return `SlowDown` instead of publishing into a pool that has since become suspended. The staged input is not automatically replayed into another pool.
|
|
||||||
|
|
||||||
Running, queued, failed, canceled, and completed decommission states exclude the source from new ordinary publication, including new multipart uploads. Previously created multipart uploads retain their drain path while the source remains non-terminal; terminal source states reject further multipart publication. Failed and canceled entries become writable for new ordinary publication only after an allowed clear operation removes that state. This check does not change repair admission or the separate fence for operations that only release capacity.
|
|
||||||
|
|
||||||
For mixed batch deletes, only the pools selected to receive new delete markers are publication targets. Exact-version deletions on other pools remain protected by the same pool metadata read fence, without treating the retiring source or an unrelated reserved target as a destination for those markers.
|
|
||||||
|
|
||||||
### Shared Capacity On Healthy Targets
|
|
||||||
|
|
||||||
Ordinary publication into a healthy target is not rejected solely because that pool has an active decommission reservation. This follows the MinIO decommission write-routing contract: the retiring source stops accepting new writes, while the remaining pools share physical capacity between foreground requests and migration. A reservation remains a migration budget and recovery ledger, not an exclusive foreground-write quota. Repair retains its existing conservative reservation admission policy.
|
|
||||||
|
|
||||||
The existing durable metadata fence, valid active reservation checks, owner/mutation identity, pending-intent recovery, target write quorum and source-cleanup preflight remain required. Foreground writes do not acquire the mover's target I/O lock or settle its pending intent. Capacity estimates, including filesystem free-space deltas observed during migration, may include concurrent unrelated I/O; they are not proof of exclusive space or of a committed target object. Actual write failures and identity/quorum checks remain authoritative. Space loss can stop migration with the source retained, including after a target copy has committed. Capacity exhaustion can also fail foreground writes; this policy does not guarantee foreground priority or success. RustFS retains its existing capacity-blocked state and recovery behavior rather than changing terminal-state or retry semantics here.
|
|
||||||
|
|
||||||
The native regression overlaps public PUT and multipart create/part replacement/complete/abort operations with a paused target rename on another node context, checks that foreground publication leaves the pending migration ledger unchanged, and then checks both sufficient-capacity cleanup and injected capacity loss with byte-for-byte retained source and target data. Mixed batch deletion covers marker publication on both reserved and unreserved healthy targets together with exact-version removal on the retiring source. Capacity is injected deterministically; the object and metadata operations use real temporary disks, not a physical disk-exhaustion test.
|
|
||||||
|
|
||||||
### Status Response Shape
|
### Status Response Shape
|
||||||
|
|
||||||
`GET /v3/pools/list` and `GET /v3/pools/status?pool=...` expose per-pool machine-readable decommission state. The `status` field can report `active`, `running`, `queued`, `complete`, `failed`, or `canceled`.
|
`GET /v3/pools/list` and `GET /v3/pools/status?pool=...` expose per-pool machine-readable decommission state. The `status` field can report `active`, `running`, `queued`, `complete`, `failed`, or `canceled`.
|
||||||
@@ -96,40 +70,6 @@ When decommission metadata is present, `decommissionInfo` includes:
|
|||||||
|
|
||||||
This makes queued pools and stalled metadata visible without requiring operators to inspect pool metadata files directly.
|
This makes queued pools and stalled metadata visible without requiring operators to inspect pool metadata files directly.
|
||||||
|
|
||||||
### Scanner Backlog Replica Conflicts
|
|
||||||
|
|
||||||
Native scanner CAS publication uses the storage-owned replica write path, not a
|
|
||||||
direct write to a set selected from node-local pool state. On multi-pool stores,
|
|
||||||
the fixed object namespace precedes the durable pool metadata read fence and
|
|
||||||
the actual replica-set namespace. Admission excludes running, queued and
|
|
||||||
completed sources; failed/canceled sources retain the scanner's existing
|
|
||||||
membership-repair behavior. Missing pool metadata does not authorize a replica.
|
|
||||||
Healthy reserved targets remain writable under the shared-capacity contract.
|
|
||||||
|
|
||||||
The replica writer retains both outer guards in an owned task and waits for the
|
|
||||||
rename tail, including when its caller is canceled. Lock-loss signals remain
|
|
||||||
attached to the set commit. This does not require every disk to succeed or alter
|
|
||||||
write quorum/fsync policy. Replica writes for this one internal key serialize
|
|
||||||
through its fixed namespace; ordinary PUT/GET do not enter this writer. The
|
|
||||||
scanner still requires CAS success on every surviving set before acknowledging
|
|
||||||
a ledger generation, and retains its partial-commit recovery protocol.
|
|
||||||
Older scanner writers still use direct set CAS; this source-publication fence
|
|
||||||
requires updating every scanner-capable node. No new on-disk or wire format is
|
|
||||||
introduced.
|
|
||||||
|
|
||||||
The exact internal object `.rustfs.sys/buckets/.scanner-pause-backlog.json` is
|
|
||||||
published with CAS to surviving sets. Its replica-local object modification
|
|
||||||
times are not scanner ledger generations. A cross-pool migration receiving
|
|
||||||
`PreconditionFailed` can therefore accept an existing unversioned replica with
|
|
||||||
an identical known ETag, payload identity and metadata even when its write time
|
|
||||||
differs. This exception does not apply to other keys, versioned objects, delete
|
|
||||||
markers, missing identity evidence, or a different older ledger payload.
|
|
||||||
|
|
||||||
The source is still revalidated under its mutation fence before migration.
|
|
||||||
Existing capacity-owner and mutation checks reconcile the pending intent before
|
|
||||||
source cleanup; the replica exception does not clear an unknown intent, rewrite
|
|
||||||
the native target, or change the scanner's committed-membership selection.
|
|
||||||
|
|
||||||
## MinIO Divergence Decisions
|
## MinIO Divergence Decisions
|
||||||
|
|
||||||
Behavior that is close to MinIO but not byte-for-byte identical. Changing either decision requires an operator compatibility note and updated characterization tests.
|
Behavior that is close to MinIO but not byte-for-byte identical. Changing either decision requires an operator compatibility note and updated characterization tests.
|
||||||
|
|||||||
@@ -38,42 +38,6 @@ Counts ignore blank lines and comments; compute them from the files. The lifecyc
|
|||||||
|
|
||||||
"Supported" for the SSE row means RustFS encrypts and decrypts its own objects. MinIO SSE objects (SSE-S3, SSE-KMS, SSE-C) are not readable in default builds; see [minio-file-format-compat.md Part C](minio-file-format-compat.md#part-c--server-side-encryption-sse) for the `rio-v2` migration build.
|
"Supported" for the SSE row means RustFS encrypts and decrypts its own objects. MinIO SSE objects (SSE-S3, SSE-KMS, SSE-C) are not readable in default builds; see [minio-file-format-compat.md Part C](minio-file-format-compat.md#part-c--server-side-encryption-sse) for the `rio-v2` migration build.
|
||||||
|
|
||||||
## Replication Support Boundary
|
|
||||||
|
|
||||||
Site replication and bucket replication are not the same compatibility claim.
|
|
||||||
Site replication requires RustFS-compatible peer admin APIs and coordinates
|
|
||||||
IAM, topology, buckets, and metadata. A generic S3-compatible service can only
|
|
||||||
be a bucket-replication data target.
|
|
||||||
|
|
||||||
For a generic S3 target, RustFS supports object PUT/HEAD/DELETE, multipart
|
|
||||||
uploads, tags, version deletes, and Object Lock mutations when the target
|
|
||||||
implements the corresponding S3 APIs and has versioning enabled. Targets that
|
|
||||||
mint their own version IDs are supported through a per-target version ledger;
|
|
||||||
pre-ledger replicas are adopted only when exact key and ETag identify one
|
|
||||||
unambiguous target version. `NoSuchVersion` for an already absent addressed
|
|
||||||
replica is treated as converged.
|
|
||||||
|
|
||||||
The following are capability boundaries, not universal S3 claims:
|
|
||||||
|
|
||||||
- `GET /BUCKET?replication-check` must pass the phases required by the intended
|
|
||||||
workload. `VersionFidelity` may report a minting target as mismatched even
|
|
||||||
though ledger-addressed delete and Object Lock phases succeed.
|
|
||||||
- A target that rejects standard multipart constraints, required Object Lock
|
|
||||||
integrity headers, or the configured checksum framing is unsupported until
|
|
||||||
its transport settings are made compatible.
|
|
||||||
- SSE-S3 and SSE-KMS are decrypted at the source and re-encrypted by the
|
|
||||||
destination's KMS. SSE-C uses ciphertext passthrough and requires target
|
|
||||||
evidence. Unsupported or ambiguous encryption metadata fails closed.
|
|
||||||
- ACL authorization is intentionally unsupported, and generic targets never
|
|
||||||
receive RustFS IAM/site-control-plane state.
|
|
||||||
- RustFS does not guess between multiple target versions with the same key and
|
|
||||||
ETag. The mutation remains failed and retryable until repair establishes an
|
|
||||||
unambiguous mapping.
|
|
||||||
|
|
||||||
See [site replication operations](../operations/site-replication-operations.md)
|
|
||||||
for health, recovery, and upgrade rules and [replication outbound transport](../operations/replication-outbound-transport.md)
|
|
||||||
for the tested target classes and knobs.
|
|
||||||
|
|
||||||
## Not Yet Passing
|
## Not Yet Passing
|
||||||
|
|
||||||
Standard S3 areas that must not be described as complete:
|
Standard S3 areas that must not be described as complete:
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# Lock RPC storm protection
|
|
||||||
|
|
||||||
**Use this when:** a slow lock endpoint turns into cluster-wide `Remote lock RPC timed out`, `Evicting cached remote lock connection`, and `GOAWAY too_many_resets` log floods, or when you tune how the remote lock client reacts to per-request deadlines (rustfs#7363).
|
|
||||||
|
|
||||||
## What the client does on a failed lock RPC
|
|
||||||
|
|
||||||
Every remote lock call (`lock`, `lock_batch`, `release`, `refresh`, `force_release`, `check_status`, and the readiness `ping`) runs under the deadline from `RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS` (readiness uses `RUSTFS_HEALTH_LOCK_ONLINE_TIMEOUT_MS`). A deadline only says that one stream was slow; it says nothing about the shared HTTP/2 channel it ran on. The client therefore keeps a small per-peer history and decides per failure:
|
|
||||||
|
|
||||||
| Failure | Verdict | Effect |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Deadline expired, peer completed any lock RPC within two deadlines | `peer_recently_served` | Channel kept. The peer is slow, not gone. |
|
|
||||||
| Deadline expired, peer quiet for longer than two deadlines | `evict` | Cached channel evicted once, then the next request re-dials. |
|
|
||||||
| Any failure while the last eviction is younger than the cooldown | `cooling_down` | Channel kept so the fresh dial can prove itself; no re-dial burst. |
|
|
||||||
| Transport failure (refused, reset, `GOAWAY`) outside the cooldown | `evict` | Cached channel evicted once. |
|
|
||||||
|
|
||||||
A timed-out request is no longer cancelled. Cancelling sends `RST_STREAM`, and enough resets against a server that is slow to accept streams make it answer `GOAWAY too_many_resets`, which kills every stream on the connection and restarts the loop. Instead the stream is detached: it keeps running in the background (bounded by the internode RPC timeout), the caller still gets its timeout error, and if the peer grants a lock after the caller gave up the client releases it immediately instead of leaving an orphan for the lease to expire.
|
|
||||||
|
|
||||||
Unlocks that fail three quick retries no longer stop there. The background task continues with a deferred schedule (1s, 2s, 4s, 8s, 16s) before it gives up and leaves the entry to the server-side lease.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Environment variable | Default | Behavior |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| `RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS` | `3000` | Per-request deadline for remote lock RPCs. |
|
|
||||||
| `RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS` | `5000` | Minimum interval between channel evictions per peer. `0` restores eviction on every qualifying failure. |
|
|
||||||
| `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT` | `256` | How many timed-out lock RPCs per peer may keep running in the background. Beyond the budget a timed-out stream is cancelled as before. |
|
|
||||||
|
|
||||||
## Metrics
|
|
||||||
|
|
||||||
| Metric | Labels | Meaning |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `rustfs_remote_lock_rpc_timeouts_total` | `peer`, `op` | Remote lock RPCs that exceeded their deadline. |
|
|
||||||
| `rustfs_remote_lock_channel_evictions_total` | `peer`, `trigger` | Cached channel evictions; `trigger` is `timeout` or `transport`. |
|
|
||||||
| `rustfs_remote_lock_channel_evictions_suppressed_total` | `peer`, `verdict` | Failures that kept the channel; `verdict` is `peer_recently_served` or `cooling_down`. |
|
|
||||||
| `rustfs_remote_lock_rpc_detached_total` | `op`, `outcome` | Timed-out RPCs left running (`detached`) or cancelled for budget (`aborted`). |
|
|
||||||
| `rustfs_remote_lock_rpc_late_completions_total` | `op`, `outcome` | How detached RPCs ended (`success`, `error`, `join_error`). |
|
|
||||||
| `rustfs_remote_lock_late_releases_total` | `outcome` | Releases of locks granted after their caller timed out (`released`, `partial`, `failed`). |
|
|
||||||
|
|
||||||
## Reading an incident
|
|
||||||
|
|
||||||
A healthy-but-slow endpoint now shows a rising `rustfs_remote_lock_rpc_timeouts_total{peer}` with `evictions_suppressed_total{verdict="peer_recently_served"}` and at most one eviction per cooldown. A dead endpoint shows `evictions_total{trigger="transport"}` once per cooldown while the connection re-dials. Sustained `GOAWAY too_many_resets` in the server log means detached streams are being cancelled, which only happens once `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT` is exhausted; raise the limit or fix the slow lock service (`http_request_inflight_slow` on `NodeService/Lock` names the endpoint).
|
|
||||||
|
|
||||||
The client code lives in `crates/ecstore/src/cluster/rpc/remote_locker.rs`; the deferred unlock schedule lives in `crates/lock/src/distributed_lock.rs`.
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
# Site Replication Operations
|
|
||||||
|
|
||||||
**Use this when:** operating a site-replication deployment, diagnosing a peer
|
|
||||||
outage or incomplete topology change, pairing sites that already contain data,
|
|
||||||
or planning an upgrade.
|
|
||||||
|
|
||||||
**Source of truth:** `rustfs/src/admin/handlers/site_replication.rs`,
|
|
||||||
`rustfs/src/site_replication/`, and the bucket-replication worker under
|
|
||||||
`crates/ecstore/src/bucket/replication/`.
|
|
||||||
|
|
||||||
Site replication combines two different convergence paths:
|
|
||||||
|
|
||||||
- the control plane replicates buckets, bucket metadata, IAM, and topology;
|
|
||||||
- ordinary bucket replication moves object versions and delete operations.
|
|
||||||
|
|
||||||
An `enabled: true` response only says that a site has more than one configured
|
|
||||||
peer. It does not prove that every peer is reachable or caught up. Always read
|
|
||||||
`pendingOperation`, `retryStats`, `PeerErrors`, and `Metrics` as well.
|
|
||||||
|
|
||||||
## Routine checks
|
|
||||||
|
|
||||||
Run these commands from an admin workstation with one alias per site:
|
|
||||||
|
|
||||||
```console
|
|
||||||
mc admin replicate info site-a
|
|
||||||
mc admin replicate status site-a
|
|
||||||
```
|
|
||||||
|
|
||||||
Check more than one site. A partition can leave each side with a different but
|
|
||||||
locally valid view.
|
|
||||||
|
|
||||||
`replicate info` is the compact control-plane view:
|
|
||||||
|
|
||||||
| Field | Interpretation |
|
|
||||||
|---|---|
|
|
||||||
| `enabled` | More than one site is configured; this is not a health verdict. |
|
|
||||||
| `sites` | The locally persisted topology. Compare deployment IDs and endpoints on every site. |
|
|
||||||
| `retryStats.pending` | Collapsed peer deliveries waiting to be retried. |
|
|
||||||
| `retryStats.failed` | Deliveries that crossed the escalation threshold and require attention. |
|
|
||||||
| `retryStats.lastError` | A redacted summary of the most recent delivery failure. |
|
|
||||||
| `pendingOperation` | A durable multi-step topology operation described below. Absence is the healthy steady state. |
|
|
||||||
|
|
||||||
`replicate status` adds detailed convergence state:
|
|
||||||
|
|
||||||
| Field | Interpretation |
|
|
||||||
|---|---|
|
|
||||||
| `Sites` / `PeerStates` | Configured peers and derived reachability/configuration state. |
|
|
||||||
| `PeerErrors` | A peer could not be queried. Its detailed counters may be absent; do not read zeros as success. |
|
|
||||||
| `BucketStats` | Per-bucket presence and versioning, replication, lifecycle, Object Lock, and metadata mismatches. |
|
|
||||||
| `PolicyStats`, `UserStats`, `GroupStats` | IAM inventory mismatches. |
|
|
||||||
| `RetryStats` | Durable control-plane retry backlog and escalation count. |
|
|
||||||
| `Metrics.replMetrics` | Per-destination online state, downtime, replicated counts/bytes, and `failed` totals/windows. |
|
|
||||||
| `Metrics.queued` / `Metrics.inProgress` | Object work waiting or active on the responding node. |
|
|
||||||
| `Metrics.errors` | Node-level object-replication failures. When only queue statistics are available, RustFS synthesizes a node entry and preserves this counter rather than reporting zero. |
|
|
||||||
| `Metrics.retries` | Redeliveries. Always zero today: a failed object is not retried by an event, it waits for the scanner pass described below. Read `errors` instead. |
|
|
||||||
|
|
||||||
Healthy means: the same topology is visible on all sites, no pending operation,
|
|
||||||
no peer error, no failed retry escalation, required bucket/IAM state is in sync,
|
|
||||||
and queue/error counters are stable or falling. Counters are cumulative; alert on
|
|
||||||
their rate and on a backlog that does not drain, not merely on a non-zero total.
|
|
||||||
|
|
||||||
## Pending operations and recovery
|
|
||||||
|
|
||||||
`pendingOperation` contains `operation`, an opaque `id`, `pendingPeers`, and
|
|
||||||
`ackedPeers`. Do not edit the site-replication state object by hand. The marker
|
|
||||||
is the crash-recovery journal and removing it can make a partially applied
|
|
||||||
operation look complete.
|
|
||||||
|
|
||||||
The heavyweight reconciler runs once at startup and every 600 seconds. The
|
|
||||||
lightweight retry drain runs every 30 seconds. A restart is therefore a valid
|
|
||||||
way to cause an immediate heavyweight pass after the underlying fault has been
|
|
||||||
fixed, but it is not a substitute for fixing connectivity, credentials, TLS,
|
|
||||||
or the remote endpoint.
|
|
||||||
|
|
||||||
### `remove`
|
|
||||||
|
|
||||||
The original topology and each peer acknowledgement are persisted before the
|
|
||||||
operation finalizes. While peers remain in `pendingPeers`, restore access to
|
|
||||||
them and wait for reconciliation. If a peer is permanently gone, a new remove
|
|
||||||
request may remove all currently active unacknowledged peers; RustFS permits
|
|
||||||
that request and then finalizes against the remaining topology. Removing the
|
|
||||||
local site or all sites is also an explicit completion path.
|
|
||||||
|
|
||||||
Do not re-add a site merely to hide this marker. First compare the topology on
|
|
||||||
all reachable peers. If the same operation ID makes no progress for more than
|
|
||||||
one heavyweight interval, collect `PeerErrors`, `RetryStats`, and the
|
|
||||||
site-replication logs before retrying the remove.
|
|
||||||
|
|
||||||
### `rotate-svc-acct`
|
|
||||||
|
|
||||||
Service-account rotation keeps the candidate secrets and peer acknowledgements
|
|
||||||
until every current remote peer accepts the rotation. Restore the failing peer
|
|
||||||
and allow the reconciler to resume it. Do not manually delete either candidate
|
|
||||||
credential during this window: doing so can remove the only credential that a
|
|
||||||
not-yet-acknowledged peer accepts.
|
|
||||||
|
|
||||||
After the marker clears, verify `replicate status` from every site, then retire
|
|
||||||
any separately retained old credential material according to local policy.
|
|
||||||
|
|
||||||
### `endpoint-refresh`
|
|
||||||
|
|
||||||
An endpoint, CA, or TLS-verification edit first refreshes the replication
|
|
||||||
target on every active peer and records acknowledgements. On startup and every
|
|
||||||
heavyweight pass, RustFS probes peer capability, uses the endpoint-refresh API
|
|
||||||
when supported (or the legacy peer-edit fallback), refreshes local bucket
|
|
||||||
targets, and commits the edit only after every still-active peer acknowledges.
|
|
||||||
|
|
||||||
If this marker is stuck:
|
|
||||||
|
|
||||||
1. Confirm that the proposed endpoint and CA are correct and reachable from
|
|
||||||
every site, not only from the admin workstation.
|
|
||||||
2. Restore the site-replication service account and TLS trust path.
|
|
||||||
3. Wait for one 600-second pass or restart one healthy node to trigger the
|
|
||||||
startup pass.
|
|
||||||
4. Re-run the identical edit only if the operation remains visible; a different
|
|
||||||
endpoint edit is rejected while the existing refresh is pending. The journal
|
|
||||||
pins the edit's payload, so a re-run without `--replicate-ilm-expiry` keeps
|
|
||||||
the value the first attempt recorded, and a re-run asking for a different
|
|
||||||
value is rejected. Finish or remove the pending refresh before changing it.
|
|
||||||
|
|
||||||
A peer removed from the topology no longer blocks completion. A remove request
|
|
||||||
is accepted when it removes every active unacknowledged peer.
|
|
||||||
|
|
||||||
While this marker is present, control-plane retry replay to the other peers
|
|
||||||
keeps running, but bucket wiring reconciliation waits: it rewrites the same
|
|
||||||
targets the refresh is changing. Expect bucket-level drift on this site to
|
|
||||||
persist until the refresh settles.
|
|
||||||
|
|
||||||
## Outage recovery and convergence time
|
|
||||||
|
|
||||||
Control-plane retry begins on the 30-second drain, while heavyweight snapshots,
|
|
||||||
pending topology operations, and bucket wiring are revisited on the 600-second
|
|
||||||
pass. Object MRF entries are persisted every 10 seconds by default and target
|
|
||||||
health is probed every 5 seconds. These are scheduling bounds, not delivery
|
|
||||||
SLAs: network timeouts and the amount of queued work add to them.
|
|
||||||
|
|
||||||
Objects that must be rediscovered by the scanner have this conservative upper
|
|
||||||
bound before discovery:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES
|
|
||||||
× max(RUSTFS_SCANNER_CYCLE, actual duration of one scanner cycle)
|
|
||||||
```
|
|
||||||
|
|
||||||
The defaults re-descend a compacted directory every 16 cycles. A practical
|
|
||||||
production starting point for a tighter recovery objective is
|
|
||||||
`RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=4`; `1` forces re-descent every cycle.
|
|
||||||
Measure the additional disk and metadata load before lowering it further or
|
|
||||||
tuning the scanner cadence. For an immediate operator-driven recovery, start a
|
|
||||||
site resync with `mc admin replicate resync start` and monitor its status.
|
|
||||||
Transfer time after discovery remains proportional to backlog size, bandwidth,
|
|
||||||
worker capacity, and target latency. Use queue depth and the rate of
|
|
||||||
`Metrics.errors` rather than the formula alone to decide whether convergence is
|
|
||||||
progressing.
|
|
||||||
|
|
||||||
## Pairing sites that already contain data
|
|
||||||
|
|
||||||
When more than one requested site is non-empty, preflight considers each bucket
|
|
||||||
name held by more than one site:
|
|
||||||
|
|
||||||
- versioning must be `Enabled` on every site holding the shared bucket;
|
|
||||||
- Object Lock enablement must be identical on every holder.
|
|
||||||
|
|
||||||
A bucket present on only one site is safe: post-add backfill creates it on the
|
|
||||||
other peers. A shared unversioned bucket is rejected because merging can
|
|
||||||
overwrite the only copy of an object. An Object Lock mismatch is rejected
|
|
||||||
because lock enablement cannot be changed after bucket creation and convergence
|
|
||||||
could otherwise strip a WORM guarantee.
|
|
||||||
|
|
||||||
If preflight rejects the pair, keep the authoritative copy, delete the
|
|
||||||
conflicting bucket (or its contents) from all other sites, run `replicate add`
|
|
||||||
again, and then start `replicate resync` from the surviving site. Back up and
|
|
||||||
validate the authoritative data before deleting anything.
|
|
||||||
|
|
||||||
## IAM convergence and repair boundary
|
|
||||||
|
|
||||||
Ordinary IAM changes are delivered to each peer. A successful bulk IAM import
|
|
||||||
also schedules one collapsed full-IAM snapshot per remote peer. A failed IAM
|
|
||||||
deletion is replayed before that snapshot so the snapshot cannot re-create a
|
|
||||||
principal or grant that was already revoked.
|
|
||||||
|
|
||||||
The safety state has two bounds:
|
|
||||||
|
|
||||||
- deletion high-water marks are retained for 30 days;
|
|
||||||
- deletion replay bodies are capped at 256 distinct entities per peer.
|
|
||||||
|
|
||||||
Repeated deletion of the same entity replaces its saved body. When the per-peer
|
|
||||||
cap is exceeded or the body cannot be serialized, the retry entry remains
|
|
||||||
escalated rather than pretending the deletion is replayable. An item from an
|
|
||||||
older sender without a source timestamp cannot install the 30-day high-water
|
|
||||||
mark, so verify it explicitly after a prolonged split. A successful drain
|
|
||||||
clears replay bodies; removing the peer prunes its bodies. For an escalated IAM
|
|
||||||
retry, use the site-replication repair workflow for the affected peer and IAM
|
|
||||||
family, then verify users, service accounts, groups, policies, and mappings on
|
|
||||||
both sides. Repair is the operator's explicit accountability transfer and
|
|
||||||
clears the saved deletion bodies only after the IAM repair succeeds.
|
|
||||||
|
|
||||||
A group's status converges in one direction. An explicit disable is applied
|
|
||||||
everywhere, including through a snapshot, but a membership change never
|
|
||||||
carries an enable - it would otherwise re-enable a group frozen on the
|
|
||||||
receiving site. If a group ended up disabled on one site only, re-enable it
|
|
||||||
there explicitly with `mc admin group enable`; a snapshot or repair will not
|
|
||||||
do it.
|
|
||||||
|
|
||||||
Treat IAM divergence as a security incident: a user deleted on one site can
|
|
||||||
remain usable on an unreachable peer until replay or repair completes. A peer
|
|
||||||
whose IAM entry is escalated does not receive scheduled snapshots either -
|
|
||||||
including the one a bulk import schedules - until the repair settles it.
|
|
||||||
|
|
||||||
## Encrypted objects
|
|
||||||
|
|
||||||
| Source form | Replication behavior | Fail-closed condition |
|
|
||||||
|---|---|---|
|
|
||||||
| SSE-S3 | The source decrypts the object; the request sends only `AES256` intent; the destination encrypts with its own KMS. Source envelope material never leaves the site. | The destination cannot satisfy the encryption request, or the source metadata is incomplete/unsupported. The replica is `FAILED`; plaintext is not silently stored. |
|
|
||||||
| SSE-KMS | The source decrypts the object; the request sends `aws:kms` intent without the source-local key ID; the destination selects its own configured KMS key. | Either side cannot decrypt/encrypt, or the metadata mixes incompatible encryption evidence. |
|
|
||||||
| SSE-C | Stored ciphertext and the required SSE-C replication transport metadata pass through. RustFS verifies target evidence before accepting the replica. | The target does not echo the customer-algorithm evidence, required material/layout is absent, or the metadata is ambiguous. |
|
|
||||||
|
|
||||||
Unknown MinIO/RustFS encryption markers are never forwarded as ordinary user
|
|
||||||
metadata. They fail replication so an operator must migrate or repair the
|
|
||||||
object with a supported format.
|
|
||||||
|
|
||||||
## Rolling upgrades and rollback
|
|
||||||
|
|
||||||
Keep every node in one site on the same version whenever possible. Upgrade all
|
|
||||||
nodes of one site consecutively, verify its startup reconciliation and status,
|
|
||||||
then move to the next site. Do not intentionally leave a site mixed-version:
|
|
||||||
admin requests can land on different nodes, and an older node may not resume a
|
|
||||||
new pending-operation shape or expose its health fields.
|
|
||||||
|
|
||||||
Current state additions are optional and defaulted, so older readers ignore
|
|
||||||
them. The target-version ledger is stored as dual-prefixed internal object
|
|
||||||
metadata and is also ignored by older readers; rollback does not corrupt the
|
|
||||||
object format, but older code loses the assigned-version routing improvement.
|
|
||||||
|
|
||||||
Before rolling back across the fix that retains the data directory of a version
|
|
||||||
awaiting purge replication (rustfs/rustfs#7307), ensure no version purge is
|
|
||||||
pending. Older code can free that retained version's data directory before the
|
|
||||||
remote purge is acknowledged, leaving unreadable metadata and blocking bucket
|
|
||||||
deletion. Drain or repair replication and take a metadata/data backup first.
|
|
||||||
|
|
||||||
## Runtime knobs
|
|
||||||
|
|
||||||
These values are read when the owning background task starts. Restart the
|
|
||||||
server after changing them. The millisecond intervals have a 10 ms floor;
|
|
||||||
invalid values fall back to the default with a warning.
|
|
||||||
|
|
||||||
| Variable | Default | Effect |
|
|
||||||
|---|---:|---|
|
|
||||||
| `RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS` | `5000` | Remote-target health probe interval. Lowering it increases outbound probes. |
|
|
||||||
| `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS` | `10000` | Maximum periodic interval between MRF persistence flushes; 1,000 new entries also trigger a flush. |
|
|
||||||
| `RUSTFS_REPL_RESYNC_POLL_MAX_MS` | `60000` | Upper bound for randomized resync retry-poll sleep. |
|
|
||||||
| `RUSTFS_REPL_RESYNC_MAX_JOBS` | `2` | Concurrent resync jobs; values are bounded to `1..=32`. |
|
|
||||||
|
|
||||||
Transport-specific controls and target behavior are documented in
|
|
||||||
[Replication outbound transport](replication-outbound-transport.md). Validate a
|
|
||||||
new destination with [Replication target check](replication-check.md), and read
|
|
||||||
[Replication object size limits](replication-object-size-limits.md) before
|
|
||||||
moving large objects.
|
|
||||||
@@ -627,32 +627,6 @@ pub(crate) async fn cluster_replication_stats(bucket: &str, context: Option<Arc<
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload the bucket's metadata on every peer so a follow-up
|
|
||||||
/// `put-bucket-replication` on another node does not read a stale target.
|
|
||||||
///
|
|
||||||
/// Best effort, like every S3 bucket-config write path
|
|
||||||
/// (`app::bucket_usecase::notify_bucket_metadata_reload`): the target is
|
|
||||||
/// already persisted and live on this node, and the 15-minute refresh closes
|
|
||||||
/// the gap, so a peer that cannot be reached must not turn a completed write
|
|
||||||
/// into a failed request.
|
|
||||||
async fn notify_remote_target_metadata_reload(bucket: &str, context: Option<Arc<AppContext>>, action: &'static str) {
|
|
||||||
let Some(notification_system) = current_notification_system_for_context(context.as_deref()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(err) = notification_system.load_bucket_metadata(bucket).await {
|
|
||||||
warn!(
|
|
||||||
event = EVENT_ADMIN_REMOTE_TARGET_STATE,
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
|
||||||
action = action,
|
|
||||||
result = "peer_metadata_reload_failed",
|
|
||||||
bucket = %bucket,
|
|
||||||
error = ?err,
|
|
||||||
"admin remote target state"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_replication_peers(peer_clients: &[Option<PeerRestClient>]) -> (Vec<&PeerRestClient>, u32) {
|
fn unique_replication_peers(peer_clients: &[Option<PeerRestClient>]) -> (Vec<&PeerRestClient>, u32) {
|
||||||
let mut seen_grid_hosts = HashSet::new();
|
let mut seen_grid_hosts = HashSet::new();
|
||||||
let peers: Vec<_> = peer_clients
|
let peers: Vec<_> = peer_clients
|
||||||
@@ -725,7 +699,6 @@ pub struct SetRemoteTargetHandler {}
|
|||||||
impl Operation for SetRemoteTargetHandler {
|
impl Operation for SetRemoteTargetHandler {
|
||||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
let cred = validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
let cred = validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
||||||
let app_context = app_context_from_req(&req);
|
|
||||||
|
|
||||||
let queries = extract_query_params(&req.uri);
|
let queries = extract_query_params(&req.uri);
|
||||||
|
|
||||||
@@ -953,8 +926,6 @@ impl Operation for SetRemoteTargetHandler {
|
|||||||
.map_err(map_bucket_target_error)?;
|
.map_err(map_bucket_target_error)?;
|
||||||
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
||||||
let arn = persist_remote_target_write(bucket, remote_target, incarnation, mode).await?;
|
let arn = persist_remote_target_write(bucket, remote_target, incarnation, mode).await?;
|
||||||
drop(_targets_guard);
|
|
||||||
notify_remote_target_metadata_reload(bucket, app_context, "set_remote_target").await;
|
|
||||||
let arn_str = serde_json::to_string(&arn)
|
let arn_str = serde_json::to_string(&arn)
|
||||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize target ARN"))?;
|
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize target ARN"))?;
|
||||||
|
|
||||||
@@ -1035,7 +1006,6 @@ pub struct RemoveRemoteTargetHandler {}
|
|||||||
impl Operation for RemoveRemoteTargetHandler {
|
impl Operation for RemoveRemoteTargetHandler {
|
||||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
||||||
let app_context = app_context_from_req(&req);
|
|
||||||
|
|
||||||
debug!("remove remote target called");
|
debug!("remove remote target called");
|
||||||
let queries = extract_query_params(&req.uri);
|
let queries = extract_query_params(&req.uri);
|
||||||
@@ -1111,7 +1081,6 @@ impl Operation for RemoveRemoteTargetHandler {
|
|||||||
}
|
}
|
||||||
let json_targets = serde_json::to_vec(&targets)
|
let json_targets = serde_json::to_vec(&targets)
|
||||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets"))?;
|
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets"))?;
|
||||||
let notification_bucket = bucket.clone();
|
|
||||||
let bucket = bucket.clone();
|
let bucket = bucket.clone();
|
||||||
let arn = arn_str.clone();
|
let arn = arn_str.clone();
|
||||||
// The pool cancellation owns a detached task. Both outer guards must
|
// The pool cancellation owns a detached task. Both outer guards must
|
||||||
@@ -1132,8 +1101,6 @@ impl Operation for RemoveRemoteTargetHandler {
|
|||||||
S3Error::with_message(S3ErrorCode::InternalError, format!("remote target removal task failed: {error}"))
|
S3Error::with_message(S3ErrorCode::InternalError, format!("remote target removal task failed: {error}"))
|
||||||
})??;
|
})??;
|
||||||
|
|
||||||
notify_remote_target_metadata_reload(¬ification_bucket, app_context, "remove_remote_target").await;
|
|
||||||
|
|
||||||
Ok(S3Response::new((StatusCode::NO_CONTENT, Body::from("".to_string()))))
|
Ok(S3Response::new((StatusCode::NO_CONTENT, Body::from("".to_string()))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1820,25 +1787,6 @@ mod tests {
|
|||||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_target_writes_notify_peer_metadata_caches() {
|
|
||||||
let source = include_str!("replication.rs");
|
|
||||||
for (start, end) in [
|
|
||||||
("impl Operation for SetRemoteTargetHandler", "pub struct ListRemoteTargetHandler"),
|
|
||||||
("impl Operation for RemoveRemoteTargetHandler", "async fn cancel_active_resync_intent"),
|
|
||||||
] {
|
|
||||||
let body = source
|
|
||||||
.split(start)
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|rest| rest.split(end).next())
|
|
||||||
.expect(start);
|
|
||||||
assert!(
|
|
||||||
body.contains("notify_remote_target_metadata_reload"),
|
|
||||||
"{start} must notify every node before returning success"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_ops_parse_minio_query_contract() {
|
fn update_ops_parse_minio_query_contract() {
|
||||||
let ops = parse_remote_target_update_ops(&query_map(&[
|
let ops = parse_remote_target_update_ops(&query_map(&[
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1318,24 +1318,6 @@ impl Operation for ImportIam {
|
|||||||
failed,
|
failed,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The entities are already imported locally. A snapshot that cannot be
|
|
||||||
// scheduled is a convergence delay the reconcile pass still closes, so
|
|
||||||
// it must not turn a completed import into a failed request - the same
|
|
||||||
// best-effort contract every other site-replication hook here follows.
|
|
||||||
if let Err(err) =
|
|
||||||
crate::site_replication::enqueue_site_replication_iam_snapshot("iam import scheduled a full snapshot").await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_USER,
|
|
||||||
event = EVENT_ADMIN_USER_STATE,
|
|
||||||
action = "import_iam",
|
|
||||||
result = "site_replication_snapshot_not_scheduled",
|
|
||||||
error = ?err,
|
|
||||||
"admin user state"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = serde_json::to_vec(&ret).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e.to_string()))?;
|
let body = serde_json::to_vec(&ret).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e.to_string()))?;
|
||||||
|
|
||||||
let mut header = HeaderMap::new();
|
let mut header = HeaderMap::new();
|
||||||
@@ -1442,16 +1424,6 @@ mod tests {
|
|||||||
assert!(include_str!("user.rs").contains(mapper_call));
|
assert!(include_str!("user.rs").contains(mapper_call));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn import_iam_enqueues_a_site_replication_snapshot() {
|
|
||||||
let body = source_block(include_str!("user.rs"), "impl Operation for ImportIam");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
body.contains("enqueue_site_replication_iam_snapshot"),
|
|
||||||
"a successful IAM import must schedule a full IAM snapshot for every remote site"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_check_deny_only_for_regular_self_request() {
|
fn test_should_check_deny_only_for_regular_self_request() {
|
||||||
let cred = Credentials {
|
let cred = Credentials {
|
||||||
|
|||||||
@@ -409,26 +409,6 @@ fn transfer_summaries(stats: &InternalReplicationStats) -> (XferSummaryWire, Tar
|
|||||||
(summary, per_target)
|
(summary, per_target)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Node-level failure counters for `errors`. The sibling `retries` field
|
|
||||||
/// stays zero on purpose: it means redeliveries in the minio-go shape, and a
|
|
||||||
/// failed object is not retried by an event today (it waits for the scanner's
|
|
||||||
/// heal pass), so reporting failures there would claim a redelivery that
|
|
||||||
/// never happened.
|
|
||||||
fn failure_counters(stats: &InternalReplicationStats) -> CounterSummaryWire {
|
|
||||||
let (total, last1m, last1hr) = stats.stats.values().fold((0i64, 0i64, 0i64), |acc, stat| {
|
|
||||||
(
|
|
||||||
acc.0.saturating_add(stat.fail_stats.count),
|
|
||||||
acc.1.saturating_add(stat.fail_stats.last_minute.count),
|
|
||||||
acc.2.saturating_add(stat.fail_stats.last_hour.count),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
CounterSummaryWire {
|
|
||||||
total: u64::try_from(total.max(0)).unwrap_or_default(),
|
|
||||||
last1m: u64::try_from(last1m.max(0)).unwrap_or_default(),
|
|
||||||
last1hr: u64::try_from(last1hr.max(0)).unwrap_or_default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MetricsV2Wire {
|
impl MetricsV2Wire {
|
||||||
/// Project the aggregated internal stats onto the `MetricsV2` shape.
|
/// Project the aggregated internal stats onto the `MetricsV2` shape.
|
||||||
///
|
///
|
||||||
@@ -438,7 +418,6 @@ impl MetricsV2Wire {
|
|||||||
/// `queueStats.nodes` and treats an empty list as "no data".
|
/// `queueStats.nodes` and treats an empty list as "no data".
|
||||||
pub(crate) fn from_stats(bucket_stats: &BucketStats, node_name: &str) -> Self {
|
pub(crate) fn from_stats(bucket_stats: &BucketStats, node_name: &str) -> Self {
|
||||||
let (xfer_stats, tgt_xfer_stats) = transfer_summaries(&bucket_stats.replication_stats);
|
let (xfer_stats, tgt_xfer_stats) = transfer_summaries(&bucket_stats.replication_stats);
|
||||||
let failed = failure_counters(&bucket_stats.replication_stats);
|
|
||||||
let mut nodes: Vec<ReplQNodeStatsWire> = bucket_stats
|
let mut nodes: Vec<ReplQNodeStatsWire> = bucket_stats
|
||||||
.queue_stats
|
.queue_stats
|
||||||
.nodes
|
.nodes
|
||||||
@@ -457,7 +436,6 @@ impl MetricsV2Wire {
|
|||||||
q_stats: InQueueMetricWire::from(&bucket_stats.replication_stats.q_stat),
|
q_stats: InQueueMetricWire::from(&bucket_stats.replication_stats.q_stat),
|
||||||
xfer_stats: xfer_stats.clone(),
|
xfer_stats: xfer_stats.clone(),
|
||||||
tgt_xfer_stats: tgt_xfer_stats.clone(),
|
tgt_xfer_stats: tgt_xfer_stats.clone(),
|
||||||
errors: failed,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -466,7 +444,6 @@ impl MetricsV2Wire {
|
|||||||
if let Some(first) = nodes.first_mut() {
|
if let Some(first) = nodes.first_mut() {
|
||||||
first.xfer_stats = xfer_stats.clone();
|
first.xfer_stats = xfer_stats.clone();
|
||||||
first.tgt_xfer_stats = tgt_xfer_stats.clone();
|
first.tgt_xfer_stats = tgt_xfer_stats.clone();
|
||||||
first.errors = failed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,12 +478,6 @@ mod tests {
|
|||||||
target.replicated_size = 4096;
|
target.replicated_size = 4096;
|
||||||
target.failed.count = 3;
|
target.failed.count = 3;
|
||||||
target.failed.size = 900;
|
target.failed.size = 900;
|
||||||
target.fail_stats.count = 3;
|
|
||||||
target.fail_stats.size = 900;
|
|
||||||
target.fail_stats.last_minute.count = 2;
|
|
||||||
target.fail_stats.last_minute.size = 600;
|
|
||||||
target.fail_stats.last_hour.count = 3;
|
|
||||||
target.fail_stats.last_hour.size = 900;
|
|
||||||
target.bandwidth_limit_bytes_per_sec = 1024;
|
target.bandwidth_limit_bytes_per_sec = 1024;
|
||||||
target.current_bandwidth_bytes_per_sec = 512.5;
|
target.current_bandwidth_bytes_per_sec = 512.5;
|
||||||
stats
|
stats
|
||||||
@@ -566,10 +537,6 @@ mod tests {
|
|||||||
assert_eq!(node["queueStats"]["peak"], node["queueStats"]["max"]);
|
assert_eq!(node["queueStats"]["peak"], node["queueStats"]["max"]);
|
||||||
assert!(node["activeWorkers"].get("curr").is_some());
|
assert!(node["activeWorkers"].get("curr").is_some());
|
||||||
assert!(node["transferSummary"].get("Total").is_some());
|
assert!(node["transferSummary"].get("Total").is_some());
|
||||||
assert_eq!(node["errors"]["total"], 3);
|
|
||||||
assert_eq!(node["errors"]["last1m"], 2);
|
|
||||||
assert_eq!(node["errors"]["last1hr"], 3);
|
|
||||||
assert_eq!(node["retries"]["total"], 0, "failures are not redeliveries; retries must not claim one");
|
|
||||||
assert_eq!(json["downtimeInfo"], serde_json::json!({}));
|
assert_eq!(json["downtimeInfo"], serde_json::json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,26 +217,6 @@ pub(crate) fn settle_observed_site_replication_retry_event(
|
|||||||
before.saturating_sub(queue.len())
|
before.saturating_sub(queue.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make sure `peer` has a collapsed entry for `path` without counting the
|
|
||||||
/// call as a delivery failure. A bulk local mutation (`import-iam`) needs the
|
|
||||||
/// entry to exist so the next drain sends the snapshot; routing it through
|
|
||||||
/// [`upsert_site_replication_retry_event`] would raise `retry_count` on every
|
|
||||||
/// import and escalate a healthy peer to `failed` after
|
|
||||||
/// [`SITE_REPLICATION_RETRY_FAILED_AFTER`] of them, with the scheduling note
|
|
||||||
/// shown to operators as `lastError`.
|
|
||||||
pub(crate) fn ensure_site_replication_retry_event(
|
|
||||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
|
||||||
peer: &PeerInfo,
|
|
||||||
path: &str,
|
|
||||||
reason: &str,
|
|
||||||
) -> S3Result<Vec<SiteReplicationRetryEvent>> {
|
|
||||||
let path = collapsed_retry_queue_path(path).unwrap_or(path);
|
|
||||||
if queue.iter().any(|event| retry_event_matches(event, peer, path)) {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
push_site_replication_retry_event(queue, peer, path, summarize_peer_error_detail(reason), false, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn upsert_site_replication_retry_event(
|
pub(crate) fn upsert_site_replication_retry_event(
|
||||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||||
peer: &PeerInfo,
|
peer: &PeerInfo,
|
||||||
@@ -264,17 +244,6 @@ pub(crate) fn upsert_site_replication_retry_event(
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
push_site_replication_retry_event(queue, peer, path, detail, peer_unreachable, generation)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_site_replication_retry_event(
|
|
||||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
|
||||||
peer: &PeerInfo,
|
|
||||||
path: &str,
|
|
||||||
detail: String,
|
|
||||||
peer_unreachable: bool,
|
|
||||||
generation: Option<u64>,
|
|
||||||
) -> S3Result<Vec<SiteReplicationRetryEvent>> {
|
|
||||||
let slots_needed = queue
|
let slots_needed = queue
|
||||||
.len()
|
.len()
|
||||||
.saturating_add(1)
|
.saturating_add(1)
|
||||||
@@ -305,7 +274,7 @@ fn push_site_replication_retry_event(
|
|||||||
retry_count: 1,
|
retry_count: 1,
|
||||||
failed: false,
|
failed: false,
|
||||||
last_error: detail,
|
last_error: detail,
|
||||||
updated_at: Some(OffsetDateTime::now_utc()),
|
updated_at: Some(now),
|
||||||
edit_generation: generation,
|
edit_generation: generation,
|
||||||
peer_unreachable,
|
peer_unreachable,
|
||||||
deletions_recorded: false,
|
deletions_recorded: false,
|
||||||
@@ -396,60 +365,6 @@ pub(crate) async fn enqueue_site_replication_retry_event_for_generation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the number of peers whose snapshot entry is escalated and therefore
|
|
||||||
/// will not carry this scheduling: the marker records a deletion that a
|
|
||||||
/// snapshot cannot replay, and only a repair settles it, so clearing it to make
|
|
||||||
/// the entry drainable again would drop that liability.
|
|
||||||
pub(crate) fn record_iam_snapshot_retries(
|
|
||||||
state: &mut SiteReplicationState,
|
|
||||||
local_peer: &PeerInfo,
|
|
||||||
reason: &str,
|
|
||||||
) -> S3Result<usize> {
|
|
||||||
let peers = state
|
|
||||||
.peers
|
|
||||||
.values()
|
|
||||||
.filter(|peer| {
|
|
||||||
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut escalated = 0usize;
|
|
||||||
for peer in peers {
|
|
||||||
if state.retry_queue.iter().any(|event| {
|
|
||||||
retry_event_matches(event, &peer, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH)
|
|
||||||
&& event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER
|
|
||||||
}) {
|
|
||||||
escalated += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ensure_site_replication_retry_event(&mut state.retry_queue, &peer, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, reason)?;
|
|
||||||
}
|
|
||||||
Ok(escalated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schedule one collapsed full-IAM snapshot per remote peer after a bulk
|
|
||||||
/// local mutation such as `import-iam`.
|
|
||||||
pub(crate) async fn enqueue_site_replication_iam_snapshot(reason: &str) -> S3Result<()> {
|
|
||||||
let state = load_site_replication_state().await?;
|
|
||||||
if !state.enabled() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let local_peer = current_local_runtime_peer(&state);
|
|
||||||
let reason = reason.to_string();
|
|
||||||
let escalated = update_site_replication_state(move |state| record_iam_snapshot_retries(state, &local_peer, &reason)).await?;
|
|
||||||
if escalated > 0 {
|
|
||||||
warn!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
|
||||||
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
|
||||||
escalated,
|
|
||||||
result = "iam_snapshot_not_scheduled_for_escalated_peer",
|
|
||||||
"site replication peers hold an escalated IAM entry; the snapshot waits for a repair"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) const SITE_REPLICATION_PEER_IAM_ITEM_WIRE_PATH: &str = "/rustfs/admin/v3/site-replication/peer/iam-item";
|
pub(crate) const SITE_REPLICATION_PEER_IAM_ITEM_WIRE_PATH: &str = "/rustfs/admin/v3/site-replication/peer/iam-item";
|
||||||
|
|
||||||
/// Per-peer cap on recorded deletion bodies. Beyond it the peer's collapsed
|
/// Per-peer cap on recorded deletion bodies. Beyond it the peer's collapsed
|
||||||
|
|||||||
@@ -168,8 +168,6 @@ mod rfc3339_map {
|
|||||||
pub(crate) struct PendingEndpointRefresh {
|
pub(crate) struct PendingEndpointRefresh {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
pub(crate) peer: PeerInfo,
|
pub(crate) peer: PeerInfo,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub(crate) ilm_expiry_override: Option<bool>,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub(crate) remote_peers: BTreeMap<String, PeerInfo>,
|
pub(crate) remote_peers: BTreeMap<String, PeerInfo>,
|
||||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
|
|||||||
@@ -693,122 +693,6 @@ fn test_record_iam_deletion_marks_newest_wins_and_expires_by_age_only() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scheduling a snapshot is not a delivery failure. Repeated imports - the
|
|
||||||
/// normal way a bulk IAM migration is done, one archive at a time - must not
|
|
||||||
/// walk the peer's entry up to the escalation threshold and report a healthy
|
|
||||||
/// site as `retryStats.failed` with the scheduling note as its `lastError`.
|
|
||||||
#[test]
|
|
||||||
fn repeated_iam_import_snapshots_do_not_escalate_a_healthy_peer() {
|
|
||||||
let local = PeerInfo {
|
|
||||||
deployment_id: "local-dep".to_string(),
|
|
||||||
..peer("local", "https://local.example.com")
|
|
||||||
};
|
|
||||||
let remote = PeerInfo {
|
|
||||||
deployment_id: "remote-a".to_string(),
|
|
||||||
..peer("remote-a", "https://a.example.com")
|
|
||||||
};
|
|
||||||
let mut state = SiteReplicationState {
|
|
||||||
peers: BTreeMap::from([
|
|
||||||
(local.deployment_id.clone(), local.clone()),
|
|
||||||
(remote.deployment_id.clone(), remote),
|
|
||||||
]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
for _ in 0..(SITE_REPLICATION_RETRY_FAILED_AFTER + 2) {
|
|
||||||
record_iam_snapshot_retries(&mut state, &local, "iam import scheduled a full snapshot").expect("record snapshot");
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(state.retry_queue.len(), 1);
|
|
||||||
let event = &state.retry_queue[0];
|
|
||||||
assert_eq!(event.retry_count, 1, "a schedule must not count as a delivery attempt");
|
|
||||||
assert!(!event.failed, "a scheduled snapshot must not report as an escalated failure");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An escalated entry records a deletion a snapshot cannot replay: only a
|
|
||||||
/// repair settles it. Scheduling an import snapshot must not clear that
|
|
||||||
/// marker to make the entry drainable again, and the peer it skips has to be
|
|
||||||
/// reported rather than silently left behind.
|
|
||||||
#[test]
|
|
||||||
fn an_escalated_peer_keeps_its_marker_and_is_reported() {
|
|
||||||
let local = PeerInfo {
|
|
||||||
deployment_id: "local-dep".to_string(),
|
|
||||||
..peer("local", "https://local.example.com")
|
|
||||||
};
|
|
||||||
let remote = PeerInfo {
|
|
||||||
deployment_id: "remote-a".to_string(),
|
|
||||||
..peer("remote-a", "https://a.example.com")
|
|
||||||
};
|
|
||||||
let mut state = SiteReplicationState {
|
|
||||||
peers: BTreeMap::from([
|
|
||||||
(local.deployment_id.clone(), local.clone()),
|
|
||||||
(remote.deployment_id.clone(), remote.clone()),
|
|
||||||
]),
|
|
||||||
retry_queue: vec![SiteReplicationRetryEvent {
|
|
||||||
id: "escalated".to_string(),
|
|
||||||
peer_deployment_id: remote.deployment_id.clone(),
|
|
||||||
peer_endpoint: remote.endpoint,
|
|
||||||
path: SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH.to_string(),
|
|
||||||
retry_count: SITE_REPLICATION_RETRY_FAILED_AFTER,
|
|
||||||
failed: true,
|
|
||||||
last_error: SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER.to_string(),
|
|
||||||
deletions_recorded: true,
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let escalated =
|
|
||||||
record_iam_snapshot_retries(&mut state, &local, "iam import scheduled a full snapshot").expect("record snapshot retries");
|
|
||||||
|
|
||||||
assert_eq!(escalated, 1);
|
|
||||||
assert_eq!(state.retry_queue.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
state.retry_queue[0].last_error, SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER,
|
|
||||||
"the unreplayable-deletion marker must survive a snapshot schedule"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iam_import_snapshot_retry_is_recorded_once_per_remote_peer() {
|
|
||||||
let local = PeerInfo {
|
|
||||||
deployment_id: "local-dep".to_string(),
|
|
||||||
..peer("local", "https://local.example.com")
|
|
||||||
};
|
|
||||||
let remote_a = PeerInfo {
|
|
||||||
deployment_id: "remote-a".to_string(),
|
|
||||||
..peer("remote-a", "https://a.example.com")
|
|
||||||
};
|
|
||||||
let remote_b = PeerInfo {
|
|
||||||
deployment_id: "remote-b".to_string(),
|
|
||||||
..peer("remote-b", "https://b.example.com")
|
|
||||||
};
|
|
||||||
let mut state = SiteReplicationState {
|
|
||||||
peers: BTreeMap::from([
|
|
||||||
(local.deployment_id.clone(), local.clone()),
|
|
||||||
(remote_a.deployment_id.clone(), remote_a),
|
|
||||||
(remote_b.deployment_id.clone(), remote_b),
|
|
||||||
]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
record_iam_snapshot_retries(&mut state, &local, "IAM import snapshot pending").expect("record snapshot retries");
|
|
||||||
|
|
||||||
assert_eq!(state.retry_queue.len(), 2);
|
|
||||||
assert!(
|
|
||||||
state
|
|
||||||
.retry_queue
|
|
||||||
.iter()
|
|
||||||
.all(|event| event.path == SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
state
|
|
||||||
.retry_queue
|
|
||||||
.iter()
|
|
||||||
.all(|event| event.peer_deployment_id != local.deployment_id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A failed deletion delivery persists a replay record next to the collapsed
|
/// A failed deletion delivery persists a replay record next to the collapsed
|
||||||
/// retry entry; a fresh entry is stamped `deletions_recorded` so a later
|
/// retry entry; a fresh entry is stamped `deletions_recorded` so a later
|
||||||
/// replay can settle it, and a repeated deletion of the same entity keeps the
|
/// replay can settle it, and a repeated deletion of the same entity keeps the
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use rand::RngExt as _;
|
use rand::RngExt as _;
|
||||||
use rustfs_storage_api as storage_contracts;
|
use rustfs_storage_api as storage_contracts;
|
||||||
@@ -837,39 +836,6 @@ impl StorageReplicationStatsHandle {
|
|||||||
|
|
||||||
pub(crate) async fn site_metrics_snapshot(&self) -> ReplicationSiteMetricsSnapshot {
|
pub(crate) async fn site_metrics_snapshot(&self) -> ReplicationSiteMetricsSnapshot {
|
||||||
let metrics = self.inner.get_sr_metrics_for_node().await;
|
let metrics = self.inner.get_sr_metrics_for_node().await;
|
||||||
// Aggregate under the read lock rather than through `get_all`: that
|
|
||||||
// clones every bucket's stats, and `FailStats.recent` is bounded only
|
|
||||||
// by the one-hour window, so an unreachable target under load - the
|
|
||||||
// very case an operator polls this for - makes the copy large. The
|
|
||||||
// windows come from the live samples; the serialized `last_minute` /
|
|
||||||
// `last_hour` snapshots are stamped onto per-bucket clones elsewhere
|
|
||||||
// and stay zero in this node-local cache.
|
|
||||||
let (
|
|
||||||
failed_count,
|
|
||||||
failed_bytes,
|
|
||||||
failed_last_minute_count,
|
|
||||||
failed_last_minute_bytes,
|
|
||||||
failed_last_hour_count,
|
|
||||||
failed_last_hour_bytes,
|
|
||||||
) = {
|
|
||||||
let cache = self.inner.cache.read().await;
|
|
||||||
cache
|
|
||||||
.values()
|
|
||||||
.flat_map(|bucket| bucket.stats.values())
|
|
||||||
.fold((0i64, 0i64, 0i64, 0i64, 0i64, 0i64), |totals, stat| {
|
|
||||||
let (minute, hour) = stat
|
|
||||||
.fail_stats
|
|
||||||
.recent_windows(Duration::from_secs(60), Duration::from_secs(3600));
|
|
||||||
(
|
|
||||||
totals.0.saturating_add(stat.fail_stats.count),
|
|
||||||
totals.1.saturating_add(stat.fail_stats.size),
|
|
||||||
totals.2.saturating_add(minute.count),
|
|
||||||
totals.3.saturating_add(minute.size),
|
|
||||||
totals.4.saturating_add(hour.count),
|
|
||||||
totals.5.saturating_add(hour.size),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
ReplicationSiteMetricsSnapshot {
|
ReplicationSiteMetricsSnapshot {
|
||||||
uptime: metrics.uptime,
|
uptime: metrics.uptime,
|
||||||
queued_curr_count: metrics.queued.curr.count,
|
queued_curr_count: metrics.queued.curr.count,
|
||||||
@@ -893,12 +859,6 @@ impl StorageReplicationStatsHandle {
|
|||||||
proxy_delete_tag_failed: metrics.proxied.delete_tag_failed,
|
proxy_delete_tag_failed: metrics.proxied.delete_tag_failed,
|
||||||
replica_size: metrics.replica_size,
|
replica_size: metrics.replica_size,
|
||||||
replica_count: metrics.replica_count,
|
replica_count: metrics.replica_count,
|
||||||
failed_count,
|
|
||||||
failed_bytes,
|
|
||||||
failed_last_minute_count,
|
|
||||||
failed_last_minute_bytes,
|
|
||||||
failed_last_hour_count,
|
|
||||||
failed_last_hour_bytes,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,12 +899,6 @@ pub(crate) struct ReplicationSiteMetricsSnapshot {
|
|||||||
pub(crate) proxy_delete_tag_failed: i64,
|
pub(crate) proxy_delete_tag_failed: i64,
|
||||||
pub(crate) replica_size: i64,
|
pub(crate) replica_size: i64,
|
||||||
pub(crate) replica_count: i64,
|
pub(crate) replica_count: i64,
|
||||||
pub(crate) failed_count: i64,
|
|
||||||
pub(crate) failed_bytes: i64,
|
|
||||||
pub(crate) failed_last_minute_count: i64,
|
|
||||||
pub(crate) failed_last_minute_bytes: i64,
|
|
||||||
pub(crate) failed_last_hour_count: i64,
|
|
||||||
pub(crate) failed_last_hour_bytes: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_local_server_property() -> rustfs_madmin::ServerProperties {
|
pub(crate) async fn get_local_server_property() -> rustfs_madmin::ServerProperties {
|
||||||
@@ -2089,32 +2043,13 @@ pub(crate) async fn init_compression_total_memory_from_backend(store: Arc<ECStor
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BUCKET_RESYNC_LOCK_RETRY_MAX_MS, StorageReplicationStatsHandle, apply_active_resync_intents,
|
BUCKET_RESYNC_LOCK_RETRY_MAX_MS, apply_active_resync_intents, bucket_resync_transaction_lock_retry_ceiling_ms,
|
||||||
bucket_resync_transaction_lock_retry_ceiling_ms, bucket_resync_transaction_lock_retry_delay,
|
bucket_resync_transaction_lock_retry_delay, bucket_resync_transaction_lock_retry_reason,
|
||||||
bucket_resync_transaction_lock_retry_reason, bucket_targets_metadata_lock_shard, ecstore_bucket,
|
bucket_targets_metadata_lock_shard, ecstore_bucket, lock_bucket_targets_metadata, new_instance_ctx,
|
||||||
lock_bucket_targets_metadata, new_instance_ctx, retry_bucket_resync_transaction_lock, scanner_maintenance_config_file,
|
retry_bucket_resync_transaction_lock, scanner_maintenance_config_file,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn site_metrics_snapshot_includes_live_failure_windows() {
|
|
||||||
let stats = StorageReplicationStatsHandle::new();
|
|
||||||
let mut target = ecstore_bucket::replication::BucketReplicationStat::default();
|
|
||||||
target.fail_stats.add_size(2048, None::<&std::io::Error>);
|
|
||||||
let mut bucket = ecstore_bucket::replication::BucketReplicationStats::new();
|
|
||||||
bucket.stats.insert("arn:replication::remote:photos".to_string(), target);
|
|
||||||
stats.inner.cache.write().await.insert("photos".to_string(), bucket);
|
|
||||||
|
|
||||||
let snapshot = stats.site_metrics_snapshot().await;
|
|
||||||
|
|
||||||
assert_eq!(snapshot.failed_count, 1);
|
|
||||||
assert_eq!(snapshot.failed_bytes, 2048);
|
|
||||||
assert_eq!(snapshot.failed_last_minute_count, 1);
|
|
||||||
assert_eq!(snapshot.failed_last_minute_bytes, 2048);
|
|
||||||
assert_eq!(snapshot.failed_last_hour_count, 1);
|
|
||||||
assert_eq!(snapshot.failed_last_hour_bytes, 2048);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bucket_target_metadata_locks_serialize_only_matching_shards() {
|
async fn bucket_target_metadata_locks_serialize_only_matching_shards() {
|
||||||
let bucket = "bucket-target-lock";
|
let bucket = "bucket-target-lock";
|
||||||
|
|||||||
@@ -55,11 +55,8 @@ cd "$(dirname "$0")/.."
|
|||||||
# now reports an unreadable configuration as a plain string instead of raising
|
# now reports an unreadable configuration as a plain string instead of raising
|
||||||
# an S3 error per arm (24 invocation lines removed from
|
# an S3 error per arm (24 invocation lines removed from
|
||||||
# rustfs/src/admin/handlers/bucket_meta.rs; measured after merging the two).
|
# rustfs/src/admin/handlers/bucket_meta.rs; measured after merging the two).
|
||||||
# 1589 -> 1588 on 2026-09-08: the GA blocker set (rustfs/backlog#2366) added
|
|
||||||
# three invocation lines to the endpoint-refresh paths and folded the five
|
|
||||||
# copies of the concurrent-change error into one constructor, netting -1.
|
|
||||||
S3S_IMPORT_FILES_BASELINE=213
|
S3S_IMPORT_FILES_BASELINE=213
|
||||||
S3_ERROR_LINES_BASELINE=1588
|
S3_ERROR_LINES_BASELINE=1589
|
||||||
# ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not
|
# ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not
|
||||||
# know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming*
|
# know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming*
|
||||||
# client was extracted to crates/s3-client, where s3s usage is legitimate;
|
# client was extracted to crates/s3-client, where s3s usage is legitimate;
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Build an identified E2E server and verify it around one test invocation."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from contextlib import contextmanager
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import stat
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
RECEIPT_ENV = "RUSTFS_E2E_BINARY_RECEIPT"
|
|
||||||
|
|
||||||
|
|
||||||
def feature_set(value):
|
|
||||||
return sorted(set(part.strip() for part in value.split(",") if part.strip()))
|
|
||||||
|
|
||||||
|
|
||||||
def file_hash(path):
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
with path.open("rb") as source:
|
|
||||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def source_identity():
|
|
||||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
|
|
||||||
tracked = subprocess.check_output(["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT)
|
|
||||||
paths = set(tracked.decode("utf-8").rstrip("\0").split("\0")) - {""}
|
|
||||||
# RustEmbed consumes ignored console assets as well as tracked Rust sources.
|
|
||||||
static_dir = ROOT / "rustfs/static"
|
|
||||||
if static_dir.is_symlink():
|
|
||||||
raise ValueError("The embedded static directory must not be a symlink")
|
|
||||||
if static_dir.is_dir():
|
|
||||||
for path in static_dir.rglob("*"):
|
|
||||||
if path.is_symlink() and path.is_dir():
|
|
||||||
raise ValueError(f"Unsupported embedded directory symlink: {path}")
|
|
||||||
if not path.is_dir():
|
|
||||||
paths.add(str(path.relative_to(ROOT)))
|
|
||||||
elif static_dir.exists():
|
|
||||||
paths.add("rustfs/static")
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
digest.update(b"static-present\0" if static_dir.is_dir() else b"static-absent\0")
|
|
||||||
for name in sorted(paths):
|
|
||||||
path = ROOT / name
|
|
||||||
digest.update(name.encode("utf-8") + b"\0")
|
|
||||||
try:
|
|
||||||
metadata = path.lstat()
|
|
||||||
except FileNotFoundError:
|
|
||||||
digest.update(b"deleted\0")
|
|
||||||
continue
|
|
||||||
if stat.S_ISLNK(metadata.st_mode):
|
|
||||||
digest.update(b"symlink\0" + os.fsencode(os.readlink(path)) + b"\0")
|
|
||||||
if path.is_dir():
|
|
||||||
target = path.resolve()
|
|
||||||
if ROOT not in target.parents:
|
|
||||||
raise ValueError(f"Directory link escapes the source inventory: {name}")
|
|
||||||
# Directory aliases such as .claude/skills share already-hashed inputs.
|
|
||||||
for child in target.rglob("*"):
|
|
||||||
if child.is_dir() and not child.is_symlink():
|
|
||||||
continue
|
|
||||||
if child.is_dir() or str(child.relative_to(ROOT)) not in paths:
|
|
||||||
raise ValueError(f"Directory link contains an unrecorded input: {child}")
|
|
||||||
digest.update(b"directory\0" + str(target.relative_to(ROOT)).encode("utf-8") + b"\0")
|
|
||||||
continue
|
|
||||||
elif not stat.S_ISREG(metadata.st_mode):
|
|
||||||
raise ValueError(f"Unsupported build input: {name}")
|
|
||||||
digest.update(str(metadata.st_mode & 0o111).encode() + b"\0")
|
|
||||||
digest.update(file_hash(path).encode() + b"\0")
|
|
||||||
return {"head": head, "sha256": digest.hexdigest()}
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_path(binary):
|
|
||||||
return binary.with_name(binary.name + ".e2e.json")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_target_directory(target_dir):
|
|
||||||
if target_dir == ROOT or target_dir in ROOT.parents:
|
|
||||||
raise ValueError("CARGO_TARGET_DIR must not contain the source workspace")
|
|
||||||
if ROOT in target_dir.parents:
|
|
||||||
ignored = subprocess.run(["git", "check-ignore", "--quiet", "--no-index", str(target_dir.relative_to(ROOT))], cwd=ROOT)
|
|
||||||
if ignored.returncode != 0:
|
|
||||||
raise ValueError("An in-workspace CARGO_TARGET_DIR must be Git-ignored; use target/ or an external directory")
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def exclusive_binary(binary):
|
|
||||||
marker = binary.with_name(binary.name + ".e2e.lock")
|
|
||||||
try:
|
|
||||||
descriptor = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
||||||
except FileExistsError as error:
|
|
||||||
raise ValueError(f"Another E2E build/run owns {marker}; do not share a target directory between concurrent runs") from error
|
|
||||||
try:
|
|
||||||
identity = os.fstat(descriptor)
|
|
||||||
with os.fdopen(descriptor, "w") as lock:
|
|
||||||
lock.write(f"pid={os.getpid()}\n")
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
current = marker.stat()
|
|
||||||
if (current.st_dev, current.st_ino) != (identity.st_dev, identity.st_ino):
|
|
||||||
raise ValueError("The E2E ownership marker changed during the command")
|
|
||||||
marker.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def terminate_command(process):
|
|
||||||
if process.poll() is not None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
os.killpg(process.pid, signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
process.wait()
|
|
||||||
|
|
||||||
|
|
||||||
def build(binary, target_dir, profile, requested, all_bins):
|
|
||||||
sidecar = sidecar_path(binary)
|
|
||||||
sidecar.unlink(missing_ok=True)
|
|
||||||
before = source_identity()
|
|
||||||
command = ["cargo", "build", "--locked", "-p", "rustfs", "--target-dir", str(target_dir), "--message-format=json-render-diagnostics"]
|
|
||||||
command.extend(["--bins"] if all_bins else ["--bin", "rustfs"])
|
|
||||||
if requested:
|
|
||||||
command.extend(["--features", ",".join(requested)])
|
|
||||||
if profile == "release":
|
|
||||||
command.append("--release")
|
|
||||||
artifact = None
|
|
||||||
with subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, text=True, start_new_session=True) as process:
|
|
||||||
try:
|
|
||||||
for line in process.stdout:
|
|
||||||
message = json.loads(line)
|
|
||||||
if message.get("reason") == "compiler-message":
|
|
||||||
print(message["message"].get("rendered", ""), end="", file=sys.stderr)
|
|
||||||
if message.get("reason") == "compiler-artifact" and message.get("target", {}).get("name") == "rustfs" and "bin" in message.get("target", {}).get("kind", []):
|
|
||||||
artifact = message
|
|
||||||
if process.wait() != 0:
|
|
||||||
raise ValueError("RustFS build failed; no E2E identity was recorded")
|
|
||||||
except BaseException:
|
|
||||||
terminate_command(process)
|
|
||||||
raise
|
|
||||||
if not artifact or Path(artifact.get("executable", "")).resolve() != binary:
|
|
||||||
raise ValueError("Cargo did not produce the requested RustFS executable")
|
|
||||||
if source_identity() != before:
|
|
||||||
raise ValueError("Build inputs changed during compilation; finish preparing embedded assets and rebuild in an isolated worktree")
|
|
||||||
record = {
|
|
||||||
"schema": 1,
|
|
||||||
"source": before,
|
|
||||||
"requested_features": requested,
|
|
||||||
"features": sorted(artifact["features"]),
|
|
||||||
"profile": profile,
|
|
||||||
"rustc": subprocess.check_output(["rustc", "-Vv"], text=True),
|
|
||||||
"binary_sha256": file_hash(binary),
|
|
||||||
}
|
|
||||||
sidecar.write_text(json.dumps(record, sort_keys=True) + "\n")
|
|
||||||
print(f"Built E2E server: {binary}\nIdentity: {sidecar}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def verify(binary, profile, requested):
|
|
||||||
record = json.loads(sidecar_path(binary).read_text())
|
|
||||||
if not isinstance(record, dict) or set(record) != {"schema", "source", "requested_features", "features", "profile", "rustc", "binary_sha256"} or type(record["schema"]) is not int or record["schema"] != 1:
|
|
||||||
raise ValueError("Missing or unsupported E2E binary identity; run the build command")
|
|
||||||
if not isinstance(record["rustc"], str) or not record["rustc"].strip():
|
|
||||||
raise ValueError("Missing E2E build toolchain identity")
|
|
||||||
if record["requested_features"] != requested or record["profile"] != profile:
|
|
||||||
raise ValueError("E2E binary build features/profile differ from this test invocation")
|
|
||||||
if not isinstance(record["features"], list) or not all(isinstance(item, str) for item in record["features"]) or not set(requested) <= set(record["features"]):
|
|
||||||
raise ValueError("Invalid resolved E2E binary features")
|
|
||||||
if record["source"] != source_identity():
|
|
||||||
raise ValueError("E2E binary was built from different inputs; rebuild before testing")
|
|
||||||
if record["binary_sha256"] != file_hash(binary):
|
|
||||||
raise ValueError("E2E binary content differs from its build identity")
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
def run(binary, profile, requested, command):
|
|
||||||
if not command:
|
|
||||||
raise ValueError("run requires a test command after --")
|
|
||||||
override = os.environ.get("CARGO_BIN_EXE_rustfs")
|
|
||||||
if override and Path(override).resolve() != binary:
|
|
||||||
raise ValueError("CARGO_BIN_EXE_rustfs selects a different server; use --binary explicitly")
|
|
||||||
record = verify(binary, profile, requested)
|
|
||||||
metadata = binary.stat()
|
|
||||||
with tempfile.TemporaryDirectory(prefix="rustfs-e2e-receipt-") as directory:
|
|
||||||
receipt = Path(directory) / "receipt.json"
|
|
||||||
receipt.write_text(json.dumps({
|
|
||||||
"schema": 1,
|
|
||||||
"workspace": str(ROOT),
|
|
||||||
"binary": str(binary),
|
|
||||||
"size": metadata.st_size,
|
|
||||||
"modified_ns": metadata.st_mtime_ns,
|
|
||||||
"features": record["features"],
|
|
||||||
}))
|
|
||||||
env = dict(os.environ, CARGO_BIN_EXE_rustfs=str(binary), RUSTFS_BUILD_FEATURES=",".join(record["features"]))
|
|
||||||
env[RECEIPT_ENV] = str(receipt)
|
|
||||||
with subprocess.Popen(command, cwd=ROOT, env=env, start_new_session=True) as process:
|
|
||||||
try:
|
|
||||||
status = process.wait()
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
|
||||||
terminate_command(process)
|
|
||||||
raise
|
|
||||||
try:
|
|
||||||
if verify(binary, profile, requested) != record:
|
|
||||||
raise ValueError("E2E build identity changed during testing")
|
|
||||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
|
||||||
print(f"E2E validation invalidated: {error}", file=sys.stderr)
|
|
||||||
return status if status else 1
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("mode", choices=("build", "run"))
|
|
||||||
parser.add_argument("--features", default="", help="additional Cargo features; defaults remain enabled")
|
|
||||||
parser.add_argument("--profile", choices=("debug", "release"), default="debug")
|
|
||||||
parser.add_argument("--binary", type=Path, help="prebuilt server path for run")
|
|
||||||
parser.add_argument("--bins", action="store_true", help="build all RustFS binary targets, preserving the CI build matrix")
|
|
||||||
# Parse the child command separately so its options are never interpreted here.
|
|
||||||
args = sys.argv[1:]
|
|
||||||
separator = args.index("--") if "--" in args else len(args)
|
|
||||||
command = args[separator + 1:] if separator < len(args) else []
|
|
||||||
options = parser.parse_args(args[:separator])
|
|
||||||
target_dir = Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target")).resolve()
|
|
||||||
binary = (options.binary or target_dir / options.profile / ("rustfs.exe" if os.name == "nt" else "rustfs")).resolve()
|
|
||||||
try:
|
|
||||||
validate_target_directory(target_dir)
|
|
||||||
requested = feature_set(options.features)
|
|
||||||
if options.mode == "build":
|
|
||||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with exclusive_binary(binary):
|
|
||||||
if options.mode == "build":
|
|
||||||
if options.binary or command:
|
|
||||||
raise ValueError("build does not accept --binary or a child command")
|
|
||||||
build(binary, target_dir, options.profile, requested, options.bins)
|
|
||||||
return 0
|
|
||||||
if options.bins:
|
|
||||||
raise ValueError("--bins is a build option")
|
|
||||||
return run(binary, options.profile, requested, command)
|
|
||||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
|
||||||
print(f"E2E prerequisite failed: {error}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
10|crates/ecstore/src/cluster/rpc/remote_disk.rs
|
10|crates/ecstore/src/cluster/rpc/remote_disk.rs
|
||||||
6|crates/ecstore/src/config/com.rs
|
6|crates/ecstore/src/config/com.rs
|
||||||
14|crates/ecstore/src/config/storageclass.rs
|
14|crates/ecstore/src/config/storageclass.rs
|
||||||
178|crates/ecstore/src/core/pools.rs
|
180|crates/ecstore/src/core/pools.rs
|
||||||
7|crates/ecstore/src/data_movement/mod.rs
|
7|crates/ecstore/src/data_movement/mod.rs
|
||||||
2|crates/ecstore/src/data_usage/local_snapshot.rs
|
2|crates/ecstore/src/data_usage/local_snapshot.rs
|
||||||
12|crates/ecstore/src/data_usage/mod.rs
|
12|crates/ecstore/src/data_usage/mod.rs
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$PROJECT_ROOT/target}"
|
TARGET_DIR="$PROJECT_ROOT/target/debug"
|
||||||
if [[ "$CARGO_TARGET_DIR" != /* ]]; then
|
|
||||||
CARGO_TARGET_DIR="$PROJECT_ROOT/$CARGO_TARGET_DIR"
|
|
||||||
fi
|
|
||||||
export CARGO_TARGET_DIR
|
|
||||||
TARGET_DIR="$CARGO_TARGET_DIR/debug"
|
|
||||||
RUSTFS_BINARY="$TARGET_DIR/rustfs"
|
RUSTFS_BINARY="$TARGET_DIR/rustfs"
|
||||||
DATA_DIR="$TARGET_DIR/rustfs_test_data"
|
DATA_DIR="$TARGET_DIR/rustfs_test_data"
|
||||||
RUSTFS_PID=""
|
RUSTFS_PID=""
|
||||||
@@ -99,7 +94,7 @@ build_rustfs() {
|
|||||||
print_info "Building RustFS..."
|
print_info "Building RustFS..."
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
if ! python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"; then
|
if ! cargo build --bin rustfs --features "$RUSTFS_BUILD_FEATURES"; then
|
||||||
print_error "Failed to build RustFS"
|
print_error "Failed to build RustFS"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -120,10 +115,6 @@ check_dependencies() {
|
|||||||
missing_tools+=("curl")
|
missing_tools+=("curl")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
|
||||||
missing_tools+=("python3")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
missing_tools+=("cargo")
|
missing_tools+=("cargo")
|
||||||
fi
|
fi
|
||||||
@@ -212,7 +203,7 @@ run_tests() {
|
|||||||
|
|
||||||
print_info "Test command: ${test_cmd[*]}"
|
print_info "Test command: ${test_cmd[*]}"
|
||||||
|
|
||||||
if python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- "${test_cmd[@]}"; then
|
if "${test_cmd[@]}"; then
|
||||||
print_success "All tests passed!"
|
print_success "All tests passed!"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -243,10 +243,9 @@ run_quick_e2e_steps() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_step "build-e2e-server" python3 scripts/e2e_binary.py build
|
run_step "e2e-reliability-disk-fault" cargo test --package e2e_test reliability_disk_fault_test -- --nocapture
|
||||||
run_step "e2e-reliability-disk-fault" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test reliability_disk_fault_test -- --nocapture
|
run_step "e2e-heal-erasure-disk-rebuild" cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture
|
||||||
run_step "e2e-heal-erasure-disk-rebuild" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture
|
run_step "e2e-namespace-lock-quorum" cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture
|
||||||
run_step "e2e-namespace-lock-quorum" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run_quick_profile() {
|
run_quick_profile() {
|
||||||
@@ -314,15 +313,15 @@ write_blackbox_matrix() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
printf 'profile\tscenario\tgate\tcommand\tfixture_env\tstatus\n'
|
printf 'profile\tscenario\tgate\tcommand\tfixture_env\tstatus\n'
|
||||||
printf 'quick\tsingle-node disk fault read/write\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test reliability_disk_fault_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\tsingle-node disk fault read/write\tblack-box\tcargo test --package e2e_test reliability_disk_fault_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tcargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tcargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
|
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
|
||||||
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
|
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
|
||||||
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
|
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
|
||||||
printf 'destructive\tdistributed cluster concurrency\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tdistributed cluster concurrency\tblack-box\tcargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tcargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
printf 'destructive\tdelete marker migration semantics\tblack-box\tpython3 scripts/e2e_binary.py run -- cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tdelete marker migration semantics\tblack-box\tcargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
} >"$BLACKBOX_MATRIX"
|
} >"$BLACKBOX_MATRIX"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,9 +566,9 @@ run_destructive_profile() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_step "e2e-cluster-concurrency" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test cluster_concurrency_test -- --nocapture
|
run_step "e2e-cluster-concurrency" cargo test --package e2e_test cluster_concurrency_test -- --nocapture
|
||||||
run_step "e2e-stale-multipart-cleanup-cluster" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture
|
run_step "e2e-stale-multipart-cleanup-cluster" cargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture
|
||||||
run_step "e2e-delete-marker-migration-semantics" python3 scripts/e2e_binary.py run -- cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture
|
run_step "e2e-delete-marker-migration-semantics" cargo test --package e2e_test delete_marker_migration_semantics_test -- --nocapture
|
||||||
}
|
}
|
||||||
|
|
||||||
run_fuzz_profile() {
|
run_fuzz_profile() {
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Exercise the E2E build/run boundary without compiling RustFS."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
class BinaryProvenanceTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.temp.cleanup)
|
|
||||||
self.root = Path(self.temp.name)
|
|
||||||
(self.root / "scripts").mkdir()
|
|
||||||
shutil.copy(Path(__file__).with_name("e2e_binary.py"), self.root / "scripts/e2e_binary.py")
|
|
||||||
(self.root / "Cargo.toml").write_text("[workspace]\n")
|
|
||||||
(self.root / "source.rs").write_text("original source\n")
|
|
||||||
(self.root / ".gitignore").write_text("/target/\n/rustfs/static/\n")
|
|
||||||
(self.root / ".agents/skills").mkdir(parents=True)
|
|
||||||
(self.root / ".agents/skills/SKILL.md").write_text("tracked instructions\n")
|
|
||||||
(self.root / ".claude").mkdir()
|
|
||||||
(self.root / ".claude/skills").symlink_to("../.agents/skills", target_is_directory=True)
|
|
||||||
subprocess.run(["git", "init", "-q", str(self.root)], check=True)
|
|
||||||
for args in (["add", "."], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"]):
|
|
||||||
subprocess.run(["git", "-C", str(self.root), *args], check=True)
|
|
||||||
self.commands = self.root / "target/commands"
|
|
||||||
self.commands.mkdir(parents=True)
|
|
||||||
cargo = self.commands / "cargo"
|
|
||||||
cargo.write_text(f"#!{sys.executable}\n" + '''import json, os, pathlib, sys
|
|
||||||
if os.environ.get("FAKE_BUILD_FAIL"):
|
|
||||||
raise SystemExit(23)
|
|
||||||
args = sys.argv[1:]
|
|
||||||
if args[:2] == ["nextest", "run"]:
|
|
||||||
receipt = json.loads(pathlib.Path(os.environ["RUSTFS_E2E_BINARY_RECEIPT"]).read_text())
|
|
||||||
assert pathlib.Path(receipt["binary"]) == pathlib.Path(os.environ["CARGO_BIN_EXE_rustfs"]).resolve()
|
|
||||||
if os.environ.get("RUSTFS_E2E_STARTUP_CAS_BINARY"):
|
|
||||||
assert pathlib.Path(receipt["binary"]) == pathlib.Path(os.environ["RUSTFS_E2E_STARTUP_CAS_BINARY"]).resolve()
|
|
||||||
pathlib.Path("target/nextest-command.json").write_text(json.dumps(args))
|
|
||||||
raise SystemExit(int(os.environ.get("FAKE_TEST_EXIT", "0")))
|
|
||||||
target = pathlib.Path(args[args.index("--target-dir") + 1])
|
|
||||||
binary = target / ("release" if "--release" in args else "debug") / "rustfs"
|
|
||||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
binary.write_text("#!/bin/sh\\nexit 0\\n")
|
|
||||||
binary.chmod(0o755)
|
|
||||||
features = ["default", "ftps", "webdav"]
|
|
||||||
if "--features" in args:
|
|
||||||
features.extend(args[args.index("--features") + 1].split(","))
|
|
||||||
if "full" in features:
|
|
||||||
features.extend(["sftp", "swift", "metrics-gpu", "pyroscope"])
|
|
||||||
print(json.dumps({"reason": "compiler-artifact", "target": {"name": "rustfs", "kind": ["bin"]}, "executable": str(binary), "features": sorted(set(features))}))
|
|
||||||
if os.environ.get("FAKE_BUILD_MUTATE"):
|
|
||||||
pathlib.Path("source.rs").write_text("changed during build")
|
|
||||||
''')
|
|
||||||
cargo.chmod(0o755)
|
|
||||||
rustc = self.commands / "rustc"
|
|
||||||
rustc.write_text("#!/bin/sh\nprintf 'rustc fixture\\nhost: fixture\\n'\n")
|
|
||||||
rustc.chmod(0o755)
|
|
||||||
self.env = dict(os.environ, PATH=f"{self.commands}{os.pathsep}{os.environ['PATH']}")
|
|
||||||
for name in ("CARGO_TARGET_DIR", "CARGO_BIN_EXE_rustfs", "RUSTFS_BUILD_FEATURES", "RUSTFS_E2E_BINARY_RECEIPT"):
|
|
||||||
self.env.pop(name, None)
|
|
||||||
self.binary = self.root / "target/debug/rustfs"
|
|
||||||
self.sidecar = self.binary.with_name("rustfs.e2e.json")
|
|
||||||
|
|
||||||
def invoke(self, *args, env=None):
|
|
||||||
return subprocess.run([sys.executable, str(self.root / "scripts/e2e_binary.py"), *args], cwd=self.root, env=env or self.env, text=True, capture_output=True)
|
|
||||||
|
|
||||||
def build(self, features=""):
|
|
||||||
result = self.invoke("build", "--features", features)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
|
|
||||||
def run_code(self, code="pass", features="", env=None):
|
|
||||||
return self.invoke("run", "--features", features, "--", sys.executable, "-c", code, env=env)
|
|
||||||
|
|
||||||
def test_build_run_and_receipt_cleanup(self):
|
|
||||||
self.build("full,e2e-test-hooks")
|
|
||||||
result = self.run_code("import os,pathlib; print(os.environ['RUSTFS_E2E_BINARY_RECEIPT']); assert pathlib.Path(os.environ['CARGO_BIN_EXE_rustfs']).is_file(); assert 'sftp' in os.environ['RUSTFS_BUILD_FEATURES']", "e2e-test-hooks,full")
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
self.assertFalse(Path(result.stdout.strip()).exists(), "run receipts must not survive their command")
|
|
||||||
self.assertIn("sftp", json.loads(self.sidecar.read_text())["features"])
|
|
||||||
|
|
||||||
def test_source_changes_are_not_hidden_by_timestamps_or_head(self):
|
|
||||||
self.build()
|
|
||||||
path = self.root / "source.rs"
|
|
||||||
old = path.stat()
|
|
||||||
path.write_text("different bytes\n")
|
|
||||||
os.utime(path, ns=(old.st_atime_ns, old.st_mtime_ns))
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
|
|
||||||
def test_deleted_untracked_and_ignored_embedded_inputs(self):
|
|
||||||
for mutation in ("delete", "untracked", "static"):
|
|
||||||
with self.subTest(mutation=mutation):
|
|
||||||
self.build()
|
|
||||||
path = self.root / "source.rs"
|
|
||||||
if mutation == "delete":
|
|
||||||
path.unlink()
|
|
||||||
elif mutation == "untracked":
|
|
||||||
(self.root / "new.rs").write_text("new source")
|
|
||||||
else:
|
|
||||||
static = self.root / "rustfs/static"
|
|
||||||
static.mkdir(parents=True)
|
|
||||||
(static / "index.html").write_text("embedded content")
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
path.write_text("original source\n")
|
|
||||||
|
|
||||||
def test_wrong_binary_features_and_manifest_fail_closed(self):
|
|
||||||
self.build("sftp")
|
|
||||||
self.assertNotEqual(self.run_code(features="webdav").returncode, 0)
|
|
||||||
self.binary.write_text("old server")
|
|
||||||
self.assertNotEqual(self.run_code(features="sftp").returncode, 0)
|
|
||||||
self.sidecar.write_text("{}")
|
|
||||||
self.assertNotEqual(self.run_code(features="sftp").returncode, 0)
|
|
||||||
self.sidecar.unlink()
|
|
||||||
self.assertNotEqual(self.run_code(features="sftp").returncode, 0)
|
|
||||||
|
|
||||||
def test_build_failure_or_source_race_does_not_leave_a_receipt(self):
|
|
||||||
for failure in ("FAKE_BUILD_FAIL", "FAKE_BUILD_MUTATE"):
|
|
||||||
self.build()
|
|
||||||
result = self.invoke("build", env=dict(self.env, **{failure: "1"}))
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
self.assertFalse(self.sidecar.exists())
|
|
||||||
|
|
||||||
def test_child_failure_and_changes_during_run_fail(self):
|
|
||||||
self.build()
|
|
||||||
failed = self.run_code("raise SystemExit(37)")
|
|
||||||
self.assertEqual(failed.returncode, 37, failed.stderr)
|
|
||||||
for code in ("import pathlib; pathlib.Path('source.rs').write_text('changed while testing')", "import pathlib; pathlib.Path('target/debug/rustfs').write_text('different server')"):
|
|
||||||
self.build()
|
|
||||||
self.assertNotEqual(self.run_code(code).returncode, 0)
|
|
||||||
|
|
||||||
def test_override_cannot_select_an_unverified_server(self):
|
|
||||||
self.build()
|
|
||||||
result = self.run_code(env=dict(self.env, CARGO_BIN_EXE_rustfs="/some/old/server"))
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
|
|
||||||
def test_artifact_moves_between_clean_checkouts(self):
|
|
||||||
self.build()
|
|
||||||
with tempfile.TemporaryDirectory() as destination:
|
|
||||||
clone = Path(destination) / "clone"
|
|
||||||
subprocess.run(["git", "clone", "-q", str(self.root), str(clone)], check=True)
|
|
||||||
(clone / "target/debug").mkdir(parents=True)
|
|
||||||
shutil.copy2(self.binary, clone / "target/debug/rustfs")
|
|
||||||
shutil.copy2(self.sidecar, clone / "target/debug/rustfs.e2e.json")
|
|
||||||
result = subprocess.run([sys.executable, str(clone / "scripts/e2e_binary.py"), "run", "--", sys.executable, "-c", "pass"], cwd=clone, env=self.env, text=True, capture_output=True)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
|
|
||||||
def test_ci_build_preserves_both_manifests_and_runs_the_copied_server(self):
|
|
||||||
from check_test_wiring import yaml_block
|
|
||||||
from test_security_workflow import named_steps, shell_body
|
|
||||||
|
|
||||||
source = (Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml").read_text().splitlines()
|
|
||||||
build_steps = named_steps(yaml_block(source, "build-rustfs-debug-binary", 2))
|
|
||||||
run_steps = named_steps(yaml_block(source, "e2e-full", 2))
|
|
||||||
(self.root / "Cargo.lock").write_text("fixture lock\n")
|
|
||||||
subprocess.run(["git", "add", "Cargo.lock"], cwd=self.root, check=True)
|
|
||||||
subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "lock"], cwd=self.root, check=True)
|
|
||||||
copied = self.root / "target/startup-cas-input/rustfs"
|
|
||||||
env = dict(self.env, STARTUP_CAS_INPUT=str(copied.parent), RUSTFS_E2E_STARTUP_CAS_BINARY=str(copied))
|
|
||||||
for step in (build_steps["Build debug binary"], run_steps["Preserve startup CAS binary input"]):
|
|
||||||
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", shell_body(step)], cwd=self.root, env=env, capture_output=True, text=True)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
for name in ("rustfs.e2e.json", "rustfs.e2e-startup-cas-build.json"):
|
|
||||||
self.assertIn(" target/debug/" + name, build_steps["Upload debug binary"])
|
|
||||||
self.assertEqual((self.binary.parent / name).read_bytes(), (copied.parent / name).read_bytes())
|
|
||||||
manifest = json.loads(copied.with_name("rustfs.e2e-startup-cas-build.json").read_text())
|
|
||||||
self.assertEqual(manifest["argv"], ["python3", "scripts/e2e_binary.py", "build", "--bins", "--features", "e2e-test-hooks"])
|
|
||||||
self.assertTrue(manifest["clean_before"] and manifest["clean_after"])
|
|
||||||
body = next(line.removeprefix(" run: ") for line in run_steps["Run e2e full suite"] if line.startswith(" run: "))
|
|
||||||
for status in (0, 23):
|
|
||||||
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], cwd=self.root, env=dict(env, FAKE_TEST_EXIT=str(status)), capture_output=True, text=True)
|
|
||||||
self.assertEqual(result.returncode, status, result.stderr)
|
|
||||||
copied.write_text("replaced preserved binary")
|
|
||||||
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], cwd=self.root, env=env, capture_output=True, text=True)
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
|
|
||||||
def test_distributed_workflow_runs_both_filter_branches_with_receipts(self):
|
|
||||||
from check_test_wiring import yaml_block
|
|
||||||
from test_security_workflow import named_steps, shell_body
|
|
||||||
|
|
||||||
source = (Path(__file__).resolve().parents[1] / ".github/workflows/e2e-distributed.yml").read_text().splitlines()
|
|
||||||
steps = named_steps(yaml_block(source, "distributed", 2))
|
|
||||||
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", shell_body(steps["Build rustfs binary"])], cwd=self.root, env=self.env, capture_output=True, text=True)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
for selected in ("", "test(distributed::s3_basic)"):
|
|
||||||
for status in (0, 23):
|
|
||||||
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", shell_body(steps["Run distributed 4-node e2e suite"])], cwd=self.root, env=dict(self.env, FILTER=selected, FAKE_TEST_EXIT=str(status)), capture_output=True, text=True)
|
|
||||||
self.assertEqual(result.returncode, status, result.stderr)
|
|
||||||
argv = json.loads((self.root / "target/nextest-command.json").read_text())
|
|
||||||
self.assertEqual(argv, ["nextest", "run", "--profile", "e2e-distributed", "-p", "e2e_test", *(["-E", selected] if selected else ["--no-tests=fail"])])
|
|
||||||
|
|
||||||
def test_target_directory_and_profile_are_explicit(self):
|
|
||||||
env = dict(self.env, CARGO_TARGET_DIR="target/custom")
|
|
||||||
built = self.invoke("build", "--profile", "release", env=env)
|
|
||||||
self.assertEqual(built.returncode, 0, built.stderr)
|
|
||||||
run = self.invoke("run", "--profile", "release", "--", sys.executable, "-c", "pass", env=env)
|
|
||||||
self.assertEqual(run.returncode, 0, run.stderr)
|
|
||||||
self.assertNotEqual(self.invoke("run", "--", sys.executable, "-c", "pass", env=env).returncode, 0)
|
|
||||||
|
|
||||||
def test_target_directory_cannot_hide_source_inputs(self):
|
|
||||||
for target in (str(self.root), str(self.root / "crates"), str(self.root.parent)):
|
|
||||||
with self.subTest(target=target):
|
|
||||||
result = self.invoke("build", env=dict(self.env, CARGO_TARGET_DIR=target))
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
self.assertIn("CARGO_TARGET_DIR", result.stderr)
|
|
||||||
tracked = self.root / "target/tracked.rs"
|
|
||||||
tracked.write_text("tracked build input")
|
|
||||||
subprocess.run(["git", "add", "-f", "target/tracked.rs"], cwd=self.root, check=True)
|
|
||||||
self.build()
|
|
||||||
tracked.write_text("changed tracked build input")
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
|
|
||||||
def test_unsupported_embedded_directory_links_fail_closed(self):
|
|
||||||
self.build()
|
|
||||||
destination = self.root / "target/embedded-assets"
|
|
||||||
destination.mkdir()
|
|
||||||
(destination / "index.html").write_text("untracked embedded input")
|
|
||||||
static = self.root / "rustfs/static"
|
|
||||||
static.mkdir(parents=True)
|
|
||||||
(static / "linked-assets").symlink_to(destination, target_is_directory=True)
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
|
|
||||||
def test_directory_aliases_cannot_hide_unrecorded_inputs(self):
|
|
||||||
self.build()
|
|
||||||
target = self.root / ".agents/skills/SKILL.md"
|
|
||||||
target.write_text("changed instructions\n")
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
self.build()
|
|
||||||
(target.parent / ".gitignore").write_text("hidden.rs\n")
|
|
||||||
(target.parent / "hidden.rs").write_text("ignored build input\n")
|
|
||||||
result = self.invoke("build")
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
self.assertIn("unrecorded input", result.stderr)
|
|
||||||
alias = self.root / ".claude/skills"
|
|
||||||
alias.unlink()
|
|
||||||
with tempfile.TemporaryDirectory() as external:
|
|
||||||
alias.symlink_to(external, target_is_directory=True)
|
|
||||||
result = self.invoke("build")
|
|
||||||
self.assertNotEqual(result.returncode, 0)
|
|
||||||
self.assertIn("escapes the source inventory", result.stderr)
|
|
||||||
|
|
||||||
def test_directory_alias_indirection_is_part_of_the_identity(self):
|
|
||||||
for name in ("first", "second"):
|
|
||||||
directory = self.root / name
|
|
||||||
directory.mkdir()
|
|
||||||
(directory / "input.rs").write_text(name)
|
|
||||||
selection = self.root / "target/selection"
|
|
||||||
selection.symlink_to(self.root / "first", target_is_directory=True)
|
|
||||||
(self.root / "source-alias").symlink_to("target/selection", target_is_directory=True)
|
|
||||||
self.build()
|
|
||||||
selection.unlink()
|
|
||||||
selection.symlink_to(self.root / "second", target_is_directory=True)
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
|
|
||||||
def test_existing_embedded_files_and_symlink_targets_are_hashed(self):
|
|
||||||
static = self.root / "rustfs/static"
|
|
||||||
static.mkdir(parents=True)
|
|
||||||
index = static / "index.html"
|
|
||||||
index.write_text("embedded version one")
|
|
||||||
external = self.root / "target/embedded-file"
|
|
||||||
external.write_text("linked version one")
|
|
||||||
(static / "linked.html").symlink_to(external)
|
|
||||||
self.build()
|
|
||||||
index.write_text("embedded version two")
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
self.build()
|
|
||||||
external.write_text("linked version two")
|
|
||||||
self.assertNotEqual(self.run_code().returncode, 0)
|
|
||||||
|
|
||||||
def test_each_run_hashes_binary_twice_and_never_calls_cargo(self):
|
|
||||||
script = self.root / "scripts/e2e_binary.py"
|
|
||||||
script.write_text(script.read_text().replace("def file_hash(path):\n", "def file_hash(path):\n if path.name == 'rustfs':\n with (ROOT / 'target/hash-count').open('a') as count:\n count.write('hash\\n')\n"))
|
|
||||||
self.build()
|
|
||||||
count = self.root / "target/hash-count"
|
|
||||||
count.write_text("")
|
|
||||||
result = self.run_code(env=dict(self.env, FAKE_BUILD_FAIL="1"))
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
self.assertEqual(count.read_text().splitlines(), ["hash", "hash"])
|
|
||||||
|
|
||||||
def test_concurrent_build_or_run_is_rejected(self):
|
|
||||||
self.build()
|
|
||||||
command = [sys.executable, str(self.root / "scripts/e2e_binary.py"), "run", "--", sys.executable, "-c", "print('ready', flush=True); input()"]
|
|
||||||
with subprocess.Popen(command, cwd=self.root, env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as process:
|
|
||||||
self.assertEqual(process.stdout.readline().strip(), "ready")
|
|
||||||
try:
|
|
||||||
for args in (("build", "--features", "sftp"), ("run", "--", sys.executable, "-c", "pass")):
|
|
||||||
rejected = self.invoke(*args)
|
|
||||||
self.assertNotEqual(rejected.returncode, 0)
|
|
||||||
self.assertIn("Another E2E build/run", rejected.stderr)
|
|
||||||
finally:
|
|
||||||
output, error = process.communicate("\n", timeout=10)
|
|
||||||
self.assertEqual(process.returncode, 0, error + output)
|
|
||||||
self.assertFalse(self.binary.with_name("rustfs.e2e.lock").exists())
|
|
||||||
|
|
||||||
def test_interruption_cleans_receipt_and_releases_ownership(self):
|
|
||||||
self.build()
|
|
||||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
command = [sys.executable, str(self.root / "scripts/e2e_binary.py"), "run", "--", sys.executable, "-c", "import os; print(os.environ['RUSTFS_E2E_BINARY_RECEIPT'], flush=True); input()"]
|
|
||||||
with subprocess.Popen(command, cwd=self.root, env=self.env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as process:
|
|
||||||
receipt = Path(process.stdout.readline().strip())
|
|
||||||
self.assertTrue(receipt.is_file())
|
|
||||||
process.send_signal(signum)
|
|
||||||
process.communicate(timeout=10)
|
|
||||||
self.assertNotEqual(process.returncode, 0)
|
|
||||||
self.assertFalse(receipt.exists())
|
|
||||||
self.assertFalse(self.binary.with_name("rustfs.e2e.lock").exists())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -837,55 +837,6 @@ emit_step_result() {
|
|||||||
self.assertIn(value, contents)
|
self.assertIn(value, contents)
|
||||||
self.assertNotIn("OLD RUN EVIDENCE", contents)
|
self.assertNotIn("OLD RUN EVIDENCE", contents)
|
||||||
|
|
||||||
def test_performance_commands_bind_runner_selection_and_preserve_failures(self) -> None:
|
|
||||||
self.prepare("performance")
|
|
||||||
source = self.source.splitlines()
|
|
||||||
job = yaml_block(source, "performance-test", 2)
|
|
||||||
runner = WorkflowSteps()
|
|
||||||
runner.directory = self.directory / "workspace with spaces"
|
|
||||||
scripts = runner.directory / "auto-testing"
|
|
||||||
scripts.mkdir(parents=True)
|
|
||||||
wrapper = scripts / "rustfs_performance_test.sh"
|
|
||||||
wrapper.write_text(f"#!{sys.executable}\nimport json, os, sys\n" +
|
|
||||||
"print(json.dumps({'args': sys.argv[1:], 'env': {key: os.environ.get(key) for key in " +
|
|
||||||
"('RUSTFS_BENCH_SCRIPT', 'RUSTFS_WARP_METHODS', 'RUSTFS_WARP_SIZES', " +
|
|
||||||
"'RUSTFS_WARP_DURATION', 'RUSTFS_WARP_CONCURRENCY', 'WARP_METHODS', " +
|
|
||||||
"'WARP_SIZES', 'WARP_DURATION', 'WARP_CONCURRENCY')}}))\n" +
|
|
||||||
"sys.exit(int(os.environ['FAKE_BENCH_EXIT']))\n")
|
|
||||||
wrapper.chmod(0o755)
|
|
||||||
runner.steps = named_steps(job)
|
|
||||||
for methods, sizes, duration, concurrency in (
|
|
||||||
("get", "1KiB", "1s", "7"), ("all", "all", "5m", "64"), ("", "", "5m", "64")
|
|
||||||
):
|
|
||||||
runner.context = {"github.workspace": str(runner.directory), "inputs.test_method": methods,
|
|
||||||
"inputs.object_size": sizes, "inputs.warp_duration || '5m'": duration,
|
|
||||||
"inputs.warp_concurrency || '64'": concurrency}
|
|
||||||
runner.env = {**self.env, "RUSTFS_BENCH_SCRIPT": "/unverified/home-script.sh",
|
|
||||||
"RUSTFS_WARP_METHODS": "put", "RUSTFS_WARP_SIZES": "64MiB",
|
|
||||||
"RUSTFS_WARP_DURATION": "99h", "RUSTFS_WARP_CONCURRENCY": "2",
|
|
||||||
"WARP_DURATION": "88h", "WARP_CONCURRENCY": "3", "WARP_METHODS": "mixed", "WARP_SIZES": "32MiB",
|
|
||||||
"LOG_FILE": str(self.directory / "suite.log")}
|
|
||||||
runner.env.update(runner.step_env(job, indent=4))
|
|
||||||
for step, number in (("Run benchmark (GET/PUT/MIXED)", "5"), ("Analyze results", "6")):
|
|
||||||
for code in (0, 42):
|
|
||||||
with self.subTest(methods=methods, sizes=sizes, step=step, exit=code):
|
|
||||||
runner.env["FAKE_BENCH_EXIT"] = str(code)
|
|
||||||
result = runner.run_step(step)
|
|
||||||
self.assertEqual(result.returncode, code, result.stderr)
|
|
||||||
invocation = json.loads(result.stdout)
|
|
||||||
expected = ["--step", number, "-y", "--log-file", runner.env["LOG_FILE"]]
|
|
||||||
self.assertEqual(invocation["args"], expected)
|
|
||||||
self.assertEqual(invocation["env"]["RUSTFS_BENCH_SCRIPT"], str(scripts / "rustfs_performance_testing.sh"))
|
|
||||||
self.assertEqual(invocation["env"]["RUSTFS_WARP_METHODS"], methods)
|
|
||||||
self.assertEqual(invocation["env"]["RUSTFS_WARP_SIZES"], sizes)
|
|
||||||
self.assertEqual(invocation["env"]["RUSTFS_WARP_DURATION"], duration)
|
|
||||||
self.assertEqual(invocation["env"]["RUSTFS_WARP_CONCURRENCY"], concurrency)
|
|
||||||
if number == "6":
|
|
||||||
self.assertEqual(invocation["env"]["WARP_METHODS"], methods)
|
|
||||||
self.assertEqual(invocation["env"]["WARP_SIZES"], sizes)
|
|
||||||
self.assertEqual(invocation["env"]["WARP_DURATION"], duration)
|
|
||||||
self.assertEqual(invocation["env"]["WARP_CONCURRENCY"], concurrency)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user