mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 21:25:59 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a722fa80d5 | |||
| 9ecb500cbf | |||
| 8462b3492b | |||
| ac44f8968e | |||
| 46907c05cf | |||
| 73957d0faf |
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||
@echo "🧪 Checking test wiring..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
|
||||
|
||||
.PHONY: log-analyzer-rules-check
|
||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||
|
||||
@@ -39,6 +39,7 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||
|
||||
@@ -111,10 +111,6 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
shell: bash
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
shell: bash
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Reports the existing required checks for paths excluded by ci.yml.
|
||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||
|
||||
name: Continuous Integration (docs only)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened ]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "deploy/**"
|
||||
- "scripts/dev_*.sh"
|
||||
- "scripts/probe.sh"
|
||||
- "LICENSE*"
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
- "README*"
|
||||
- "**/*.png"
|
||||
- "**/*.jpg"
|
||||
- "**/*.svg"
|
||||
- ".github/workflows/build.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
- ".github/workflows/audit.yml"
|
||||
- "flake.lock"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
||||
# planning-type document could be slipped in (git add -f bypasses
|
||||
# .gitignore). Run the guard here so the required "Test and Lint" check
|
||||
# stays meaningful for docs-only changes.
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Satisfy required check for docs-only changes
|
||||
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
|
||||
+77
-74
@@ -37,25 +37,6 @@ on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
branches: [ main ]
|
||||
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
|
||||
# reports the required "Test and Lint" check for PRs skipped here.
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "deploy/**"
|
||||
- "scripts/dev_*.sh"
|
||||
- "scripts/probe.sh"
|
||||
- "LICENSE*"
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
- "README*"
|
||||
- "**/*.png"
|
||||
- "**/*.jpg"
|
||||
- "**/*.svg"
|
||||
- ".github/workflows/build.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
- ".github/workflows/audit.yml"
|
||||
- "flake.lock"
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
schedule:
|
||||
@@ -88,6 +69,32 @@ jobs:
|
||||
- name: Explain cancellation run
|
||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||
|
||||
classify-changes:
|
||||
name: Select CI scope
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
mode: ${{ steps.scope.outputs.mode }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Select scope using the base revision's policy
|
||||
id: scope
|
||||
env:
|
||||
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
|
||||
else
|
||||
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||
echo "Base CI policy unavailable; running the full matrix."
|
||||
fi
|
||||
|
||||
typos:
|
||||
name: Typos
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -100,7 +107,7 @@ jobs:
|
||||
- name: Typos check with custom config file
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||
|
||||
# Fail early with compile-free checks shared with docs-only CI.
|
||||
# Fail early with compile-free checks for every pull request.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -116,9 +123,9 @@ jobs:
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
name: Workspace Test and Lint
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -289,45 +296,6 @@ jobs:
|
||||
- name: Run rebalance/decommission migration proofs
|
||||
run: ./scripts/check_migration_gate_count.sh
|
||||
|
||||
# Record the reason before this job completes as FAILURE. A separate
|
||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
||||
# required check's failure verdict.
|
||||
- name: Annotate early-stop reason
|
||||
if: >-
|
||||
failure() && github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
{
|
||||
echo "## CI early-stop"
|
||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
||||
cancel-after-test-and-lint-failure:
|
||||
name: Cancel siblings after Test and Lint failure
|
||||
if: >-
|
||||
failure() && needs.test-and-lint.result == 'failure'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: [ test-and-lint ]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Cancel remaining jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||
@@ -340,8 +308,8 @@ jobs:
|
||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||
test-ilm-integration-serial:
|
||||
name: ILM Integration (serial)
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -408,8 +376,8 @@ jobs:
|
||||
|
||||
test-and-lint-rio-v2:
|
||||
name: Test and Lint (rio-v2)
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -449,8 +417,8 @@ jobs:
|
||||
|
||||
connect-short-credential-boundary:
|
||||
name: Connect Short Credential Boundary
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -507,8 +475,8 @@ jobs:
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
@@ -561,8 +529,8 @@ jobs:
|
||||
|
||||
build-rustfs-debug-binary:
|
||||
name: Build RustFS Debug Binary
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
@@ -684,8 +652,8 @@ jobs:
|
||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||
@@ -1212,9 +1180,44 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
required-checks:
|
||||
name: Test and Lint
|
||||
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs:
|
||||
- classify-changes
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- connect-short-credential-boundary
|
||||
- test-and-lint-protocols
|
||||
- build-rustfs-debug-binary
|
||||
- uring-integration
|
||||
- e2e-tests
|
||||
- s3-implemented-tests
|
||||
- s3-lifecycle-behavior-tests
|
||||
- build-rustfs-debug-binary-rio-v2
|
||||
- e2e-tests-rio-v2
|
||||
- e2e-full
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require the expected result of every CI lane
|
||||
env:
|
||||
CI_NEEDS: ${{ toJSON(needs) }}
|
||||
shell: bash
|
||||
run: python3 scripts/ci_gate.py verify
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs:
|
||||
- classify-changes
|
||||
- connect-short-credential-boundary
|
||||
- required-checks
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
|
||||
@@ -82,6 +82,12 @@ jobs:
|
||||
performance-test:
|
||||
runs-on: pf-testing
|
||||
timeout-minutes: 900
|
||||
env:
|
||||
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
|
||||
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
|
||||
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
|
||||
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
|
||||
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
|
||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||
# Skipped when nightly failed.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
@@ -158,19 +164,15 @@ jobs:
|
||||
- name: Run benchmark (GET/PUT/MIXED)
|
||||
id: benchmark
|
||||
run: |
|
||||
# Empty on automatic (workflow_run) runs -> full 30 rounds.
|
||||
# Manual dispatch can restrict method(s)/size(s).
|
||||
export WARP_METHODS="${{ inputs.test_method }}"
|
||||
export WARP_SIZES="${{ inputs.object_size }}"
|
||||
./auto-testing/rustfs_performance_test.sh \
|
||||
--step 5 -y \
|
||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Analyze results
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
run: |
|
||||
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
|
||||
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
|
||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Collect RustFS version info
|
||||
|
||||
@@ -52,6 +52,7 @@ docs
|
||||
__pycache__/
|
||||
!docs/
|
||||
docs/*
|
||||
!docs/README.md
|
||||
!docs/architecture/
|
||||
!docs/architecture/**
|
||||
!docs/operations/
|
||||
|
||||
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Replication
|
||||
|
||||
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
|
||||
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
|
||||
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
|
||||
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
||||
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
||||
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
||||
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
||||
|
||||
### Security
|
||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||
|
||||
|
||||
@@ -923,7 +923,7 @@ mod tests {
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
versions_observed |= versions_scanned > 0;
|
||||
observations.push(format!(
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, status={status}",
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
||||
metrics["current_cycle"],
|
||||
metrics["current_cycle_active"],
|
||||
metrics["leader_lock_state"],
|
||||
@@ -1047,8 +1047,6 @@ mod tests {
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||
// Capture physical baselines after the PUT rename fanout has drained.
|
||||
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||
// Heal control uses the first lexicographically sorted grid host.
|
||||
// Keep that coordinator distinct from the remote target at index 1.
|
||||
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
||||
|
||||
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
|
||||
let miss = env.raw_get(bucket, miss_key).await?;
|
||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||
}
|
||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
||||
let (listed, _, _) = tokio::try_join!(
|
||||
env.wait_local_listed(bucket, hit_key, SETTLE),
|
||||
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
|
||||
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
|
||||
)?;
|
||||
assert!(listed);
|
||||
|
||||
let status = env.status_json(bucket).await?;
|
||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||
|
||||
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-and-tags</ID>
|
||||
<Priority>135</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
|
||||
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-disabled</ID>
|
||||
<Priority>140</Priority>
|
||||
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
"matrix-prefix",
|
||||
"matrix-tag",
|
||||
"matrix-disabled",
|
||||
"matrix-and-tags",
|
||||
"matrix-priority-high",
|
||||
"Priority>200",
|
||||
"<Status>Disabled</Status>",
|
||||
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||
|
||||
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
|
||||
// the required tags is not admitted. Matching any single tag would push
|
||||
// data to a destination the rule never selected (backlog#2366 P1-1), and
|
||||
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
|
||||
// writes, so a single-tag rule passing is not evidence for this.
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("and-tags/partial.txt")
|
||||
.tagging("env=prod")
|
||||
.body(ByteStream::from_static(b"one of two tags"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("and-tags/full.txt")
|
||||
.tagging("env=prod&tier=gold")
|
||||
.body(ByteStream::from_static(b"both tags"))
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
|
||||
@@ -840,15 +840,21 @@ fn is_decommission_start_active_pool(pool: &PoolStatus) -> bool {
|
||||
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<()> {
|
||||
match state {
|
||||
DecommissionStartPoolState::Missing => Err(Error::other("failed to start decommission: target pool was not found")),
|
||||
DecommissionStartPoolState::Missing => {
|
||||
Err(invalid_decommission_request("failed to start decommission: target pool was not found"))
|
||||
}
|
||||
DecommissionStartPoolState::Active | DecommissionStartPoolState::Retryable => Ok(()),
|
||||
DecommissionStartPoolState::Decommissioning => Err(StorageError::DecommissionAlreadyRunning),
|
||||
DecommissionStartPoolState::Decommissioned => {
|
||||
Err(Error::other("failed to start decommission: target pool is already decommissioned"))
|
||||
}
|
||||
DecommissionStartPoolState::Blocked => Err(Error::other(
|
||||
DecommissionStartPoolState::Decommissioned => Err(invalid_decommission_request(
|
||||
"failed to start decommission: target pool is already decommissioned",
|
||||
)),
|
||||
DecommissionStartPoolState::Blocked => Err(invalid_decommission_request(
|
||||
"failed to start decommission: target pool decommission is blocked; clear failed or canceled metadata before starting again",
|
||||
)),
|
||||
}
|
||||
@@ -865,7 +871,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))
|
||||
.count();
|
||||
if active_count.saturating_sub(active_target_count) == 0 {
|
||||
return Err(Error::other(
|
||||
return Err(invalid_decommission_request(
|
||||
"failed to start decommission: at least one active pool must remain after decommission start",
|
||||
));
|
||||
}
|
||||
@@ -1751,8 +1757,53 @@ fn ensure_decommission_capacity_reservations_available(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_external_decommission_target_admission(meta: &PoolMeta, target_pool_index: usize, phase: &'static str) -> Result<()> {
|
||||
if active_decommission_source_indices(meta).into_iter().any(|source_pool_index| {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum DecommissionCapacityAdmission {
|
||||
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
|
||||
.get(source_pool_index)
|
||||
.and_then(|pool| pool.decommission.as_ref())
|
||||
@@ -1762,6 +1813,13 @@ fn ensure_external_decommission_target_admission(meta: &PoolMeta, target_pool_in
|
||||
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
||||
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)
|
||||
.get(&target_pool_index)
|
||||
.copied()
|
||||
@@ -4268,7 +4326,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<()> {
|
||||
if !pool_present {
|
||||
return Err(Error::other("failed to cancel decommission: target pool was not found"));
|
||||
return Err(invalid_decommission_request("failed to cancel decommission: target pool was not found"));
|
||||
}
|
||||
|
||||
if !decommission_present || terminal {
|
||||
@@ -4287,7 +4345,7 @@ fn ensure_decommission_clear_allowed(
|
||||
unresolved_entries: usize,
|
||||
) -> Result<()> {
|
||||
if !pool_present {
|
||||
return Err(Error::other("failed to clear decommission: target pool was not found"));
|
||||
return Err(invalid_decommission_request("failed to clear decommission: target pool was not found"));
|
||||
}
|
||||
|
||||
if !decommission_present {
|
||||
@@ -4303,7 +4361,7 @@ fn ensure_decommission_clear_allowed(
|
||||
}
|
||||
|
||||
if unresolved_entries > 0 {
|
||||
return Err(Error::other(format!(
|
||||
return Err(invalid_decommission_request(format!(
|
||||
"failed to clear decommission: {unresolved_entries} unresolved listing entries must be reconciled by retrying decommission"
|
||||
)));
|
||||
}
|
||||
@@ -4313,7 +4371,7 @@ fn ensure_decommission_clear_allowed(
|
||||
|
||||
fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation: &str) -> Result<()> {
|
||||
if single_pool {
|
||||
return Err(Error::other(format!(
|
||||
return Err(invalid_decommission_request(format!(
|
||||
"failed to {operation}: single pool deployments do not support decommission"
|
||||
)));
|
||||
}
|
||||
@@ -4323,7 +4381,9 @@ fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation
|
||||
|
||||
fn validate_start_decommission_request(indices: &[usize], single_pool: bool) -> Result<()> {
|
||||
if indices.is_empty() {
|
||||
return Err(Error::other("failed to start decommission: no target pools were provided"));
|
||||
return Err(invalid_decommission_request(
|
||||
"failed to start decommission: no target pools were provided",
|
||||
));
|
||||
}
|
||||
|
||||
ensure_decommission_terminal_operation_supported(single_pool, "start decommission")
|
||||
@@ -7016,6 +7076,15 @@ impl PoolMeta {
|
||||
.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) {
|
||||
for pool in &mut self.pools {
|
||||
if let Some(info) = pool.decommission.as_mut() {
|
||||
@@ -10033,10 +10102,10 @@ impl ECStore {
|
||||
pub(crate) async fn acquire_external_decommission_capacity_fence(
|
||||
&self,
|
||||
target_pool_indices: &[usize],
|
||||
phase: &'static str,
|
||||
admission: DecommissionCapacityAdmission,
|
||||
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
Ok(self
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, phase)
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, admission)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
@@ -10044,14 +10113,14 @@ impl ECStore {
|
||||
pub(crate) async fn acquire_external_decommission_capacity_fence_with_active_source(
|
||||
&self,
|
||||
target_pool_indices: &[usize],
|
||||
phase: &'static str,
|
||||
admission: DecommissionCapacityAdmission,
|
||||
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
||||
let save_guard = self.pool_meta_save_gate.lock().await;
|
||||
let (pool_meta_guard, snapshot) = self
|
||||
.acquire_pool_meta_read_guard(&save_guard, "target capacity admission failed")
|
||||
.await?;
|
||||
for target_pool_index in target_pool_indices.iter().copied() {
|
||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, phase)?;
|
||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, admission)?;
|
||||
}
|
||||
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
||||
drop(save_guard);
|
||||
@@ -10073,7 +10142,9 @@ impl ECStore {
|
||||
let admissions = target_pool_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|target_pool_index| ensure_external_decommission_target_admission(&snapshot, target_pool_index, "heal"))
|
||||
.map(|target_pool_index| {
|
||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, DecommissionCapacityAdmission::Heal)
|
||||
})
|
||||
.collect();
|
||||
drop(save_guard);
|
||||
Ok((pool_meta_guard, admissions))
|
||||
@@ -10747,7 +10818,7 @@ impl ECStore {
|
||||
));
|
||||
}
|
||||
let Some((owner, model_version)) = admitted_owner else {
|
||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, "mutation")?;
|
||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, DecommissionCapacityAdmission::Mutation)?;
|
||||
drop(save_guard);
|
||||
let capacity_lease = read_guard.lock_lost_signal();
|
||||
return operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
||||
@@ -20911,17 +20982,17 @@ mod pools_tests {
|
||||
DecommissionStartPoolState, DecommissionTargetConsumption, DecommissionTerminalState, DecommissionUnresolvedEntry,
|
||||
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,
|
||||
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, apply_decommission_status_space_info,
|
||||
await_decommission_worker, bind_decommission_cancelers, bind_missing_decommission_cancelers,
|
||||
build_decommission_capacity_reservation, build_decommission_capacity_reservation_with_model,
|
||||
cancel_decommission_canceler, clamp_decommission_entry_concurrency, classify_decommission_terminal_state,
|
||||
count_decommission_item, decommission_cancel_signal_result, decommission_durable_ilm_receipt_path,
|
||||
decommission_durable_ilm_receipt_run_prefix, decommission_durable_ilm_receipt_run_token,
|
||||
decommission_entry_queue_capacity, decommission_item_size, decommission_meta_bucket_options,
|
||||
decommission_physical_pool_capacity, decommission_retry_backoff_delay, decommission_start_pool_state,
|
||||
decommission_unresolved_listing_error, dedup_indices, default_decommission_bucket_concurrency,
|
||||
default_decommission_entry_concurrency, drain_decommission_entry_queue, enqueue_decommission_entry,
|
||||
ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_available,
|
||||
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, active_decommission_source_indices,
|
||||
apply_decommission_status_space_info, await_decommission_worker, bind_decommission_cancelers,
|
||||
bind_missing_decommission_cancelers, build_decommission_capacity_reservation,
|
||||
build_decommission_capacity_reservation_with_model, cancel_decommission_canceler, clamp_decommission_entry_concurrency,
|
||||
classify_decommission_terminal_state, count_decommission_item, decommission_cancel_signal_result,
|
||||
decommission_durable_ilm_receipt_path, decommission_durable_ilm_receipt_run_prefix,
|
||||
decommission_durable_ilm_receipt_run_token, decommission_entry_queue_capacity, decommission_item_size,
|
||||
decommission_meta_bucket_options, decommission_physical_pool_capacity, decommission_retry_backoff_delay,
|
||||
decommission_start_pool_state, decommission_unresolved_listing_error, dedup_indices,
|
||||
default_decommission_bucket_concurrency, default_decommission_entry_concurrency, drain_decommission_entry_queue,
|
||||
enqueue_decommission_entry, ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_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_start_local_leader, ensure_decommission_start_pool_states,
|
||||
@@ -20957,12 +21028,13 @@ mod pools_tests {
|
||||
with_decommission_entry_context,
|
||||
};
|
||||
use super::{
|
||||
DecommissionCapacityOwner, DecommissionCapacityReleaseProof, DecommissionCapacityReservation,
|
||||
DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id, ensure_decommission_target_owner_admission,
|
||||
ensure_exact_delete_capacity_namespace_fences, ensure_external_decommission_target_admission,
|
||||
is_decommission_capacity_blocked_error, plan_exact_delete_capacity_reconciliations,
|
||||
record_decommission_target_consumption, release_decommission_target_inflight, reserve_decommission_target_pending,
|
||||
resolve_decommission_target_pending, set_decommission_capacity_info_overrides_for_test,
|
||||
DecommissionCapacityAdmission, DecommissionCapacityOwner, DecommissionCapacityReleaseProof,
|
||||
DecommissionCapacityReservation, DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id,
|
||||
ensure_decommission_target_owner_admission, ensure_exact_delete_capacity_namespace_fences,
|
||||
ensure_external_decommission_target_admission, is_decommission_capacity_blocked_error,
|
||||
plan_exact_delete_capacity_reconciliations, record_decommission_target_consumption, release_decommission_target_inflight,
|
||||
reserve_decommission_target_pending, resolve_decommission_target_pending,
|
||||
set_decommission_capacity_info_overrides_for_test,
|
||||
};
|
||||
use crate::bucket::lifecycle::{
|
||||
DurableIlmRecordCheckpoint,
|
||||
@@ -25116,6 +25188,25 @@ mod pools_tests {
|
||||
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]
|
||||
fn test_ensure_decommission_start_allowed_rejects_missing_pool() {
|
||||
let err =
|
||||
@@ -25818,7 +25909,7 @@ mod pools_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_write_admission_cannot_race_into_a_reserved_target() {
|
||||
fn ordinary_write_admission_shares_a_reserved_target_without_becoming_its_owner() {
|
||||
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||
let capacity_infos = vec![
|
||||
@@ -25842,13 +25933,18 @@ mod pools_tests {
|
||||
)
|
||||
.expect("the decommission reservation should fit");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
ensure_external_decommission_target_admission(&meta, 1, "ordinary_put"),
|
||||
Err(Error::SlowDown)
|
||||
),
|
||||
"an ordinary write must not consume a target reservation"
|
||||
);
|
||||
for admission in [
|
||||
DecommissionCapacityAdmission::Mutation,
|
||||
DecommissionCapacityAdmission::BatchDelete,
|
||||
DecommissionCapacityAdmission::ScannerBacklog,
|
||||
] {
|
||||
ensure_external_decommission_target_admission(&meta, 1, admission)
|
||||
.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 {
|
||||
data_movement: true,
|
||||
src_pool_idx: 0,
|
||||
@@ -25875,6 +25971,119 @@ mod pools_tests {
|
||||
let mut decommission_opts = rebalance_opts;
|
||||
expected_owner.apply_to(&mut decommission_opts);
|
||||
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]
|
||||
|
||||
@@ -570,6 +570,465 @@ mod decommission_lock_order_tests {
|
||||
.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]
|
||||
#[serial_test::serial]
|
||||
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
||||
@@ -4499,6 +4958,470 @@ 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]
|
||||
#[serial_test::serial]
|
||||
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
||||
|
||||
@@ -984,6 +984,24 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
|
||||
.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 {
|
||||
let identity = data_movement_upload_identity(source);
|
||||
source.mod_time.is_some()
|
||||
@@ -1453,7 +1471,9 @@ fn resolve_data_movement_overwrite_resume_result_for(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
||||
Ok(matches!(err, Error::PreconditionFailed)
|
||||
&& (is_equivalent_scanner_backlog_replica(source, &target, compare_part_checksums)
|
||||
|| is_superseding_unversioned_data_movement_object(source, &target)))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -3288,6 +3308,132 @@ mod tests {
|
||||
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]
|
||||
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
||||
let source = overwrite_equivalence_source();
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
||||
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
||||
|
||||
use crate::core::pools::DecommissionCapacityAdmission;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::super::GetObjectMetadataCacheKey;
|
||||
#[cfg(test)]
|
||||
@@ -1809,7 +1811,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||
Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||
.acquire_external_decommission_capacity_fence(
|
||||
&[self.pool_index],
|
||||
DecommissionCapacityAdmission::ExistingMultipart,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
@@ -2345,6 +2350,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
bucket,
|
||||
object,
|
||||
opts.no_lock || object_lock_guard.is_some(),
|
||||
DecommissionCapacityAdmission::ExistingMultipart,
|
||||
)
|
||||
.await?;
|
||||
decommission_object_lock_guard = object_guard;
|
||||
@@ -3110,7 +3116,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
{
|
||||
decommission_capacity_guard = Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||
.acquire_external_decommission_capacity_fence(
|
||||
&[self.pool_index],
|
||||
DecommissionCapacityAdmission::ExistingMultipart,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
//! bounds are unchanged, and the impls reach shared primitives through the
|
||||
//! SetDisks core (io_primitives) via inherent calls.
|
||||
|
||||
use crate::core::pools::DecommissionCapacityAdmission;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::super::MetadataCacheInvalidationProbe;
|
||||
use super::super::{
|
||||
@@ -3905,6 +3907,7 @@ impl SetDisks {
|
||||
bucket,
|
||||
object,
|
||||
opts.no_lock || object_lock_guard.is_some(),
|
||||
DecommissionCapacityAdmission::Mutation,
|
||||
)
|
||||
.await?;
|
||||
decommission_object_lock_guard = object_guard;
|
||||
@@ -4102,7 +4105,7 @@ impl SetDisks {
|
||||
{
|
||||
decommission_capacity_guard = Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::{
|
||||
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
||||
};
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::core::pools::DecommissionCapacityAdmission;
|
||||
use rustfs_filemeta::RestoreStatusOps;
|
||||
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
||||
use s3s::dto::{RestoreStatus, Timestamp};
|
||||
@@ -160,7 +161,13 @@ impl SetDisks {
|
||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||
store
|
||||
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
||||
.acquire_external_decommission_commit_guards(
|
||||
self.pool_index,
|
||||
bucket,
|
||||
object,
|
||||
opts.no_lock,
|
||||
DecommissionCapacityAdmission::Mutation,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
(None, false, None)
|
||||
@@ -178,7 +185,7 @@ impl SetDisks {
|
||||
{
|
||||
decommission_capacity_guard = Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
@@ -264,7 +271,13 @@ impl SetDisks {
|
||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||
store
|
||||
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
||||
.acquire_external_decommission_commit_guards(
|
||||
self.pool_index,
|
||||
bucket,
|
||||
object,
|
||||
opts.no_lock,
|
||||
DecommissionCapacityAdmission::Mutation,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
(None, false, None)
|
||||
@@ -282,7 +295,7 @@ impl SetDisks {
|
||||
{
|
||||
decommission_capacity_guard = Some(
|
||||
store
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -671,9 +671,9 @@ mod tests {
|
||||
use crate::cluster::rpc::PeerS3Client;
|
||||
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
||||
use crate::core::pools::{
|
||||
DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME,
|
||||
PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus, initialized_pool_meta_identity_for_test,
|
||||
set_decommission_capacity_info_overrides_for_test,
|
||||
DecommissionCapacityAdmission, DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout,
|
||||
DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus,
|
||||
initialized_pool_meta_identity_for_test, set_decommission_capacity_info_overrides_for_test,
|
||||
};
|
||||
use crate::core::sets::HealFormatAfterSaveBarrier;
|
||||
use crate::disk::error::Result as DiskResult;
|
||||
@@ -1149,7 +1149,7 @@ mod tests {
|
||||
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||
let capacity_guard = store
|
||||
.acquire_external_decommission_capacity_fence(&[0], "heal")
|
||||
.acquire_external_decommission_capacity_fence(&[0], DecommissionCapacityAdmission::Heal)
|
||||
.await
|
||||
.expect("ordinary heal capacity fence should be acquired");
|
||||
|
||||
|
||||
@@ -4198,13 +4198,60 @@ mod tests {
|
||||
.await
|
||||
.expect("suspended source versions should be readable")
|
||||
.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!(
|
||||
versions
|
||||
.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"
|
||||
!source.deleted && source.version_id.is_none_or(|version_id| version_id.is_nil()),
|
||||
"the source pool must retain its null data version until worker convergence"
|
||||
);
|
||||
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]
|
||||
@@ -8042,7 +8089,7 @@ mod tests {
|
||||
write_suspended_decommission_source(&store, &bucket, object).await;
|
||||
mark_test_pool_decommissioning(&store, 0).await;
|
||||
|
||||
let delete_err = store
|
||||
let deleted = store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
@@ -8052,12 +8099,12 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("capacity-reserved target must reject a concurrent suspended DELETE");
|
||||
assert!(
|
||||
matches!(delete_err, Error::SlowDown),
|
||||
"unexpected suspended DELETE result: {delete_err:?}"
|
||||
);
|
||||
.expect("a healthy reserved target must accept suspended DELETE");
|
||||
assert!(deleted.delete_marker);
|
||||
assert_eq!(deleted.version_id, Some(uuid::Uuid::nil()));
|
||||
let marker_mod_time = deleted.mod_time.expect("DELETE must return the marker generation");
|
||||
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 worker_store = Arc::clone(&store);
|
||||
@@ -8077,7 +8124,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("suspended decommission worker should join")
|
||||
.expect("worker must migrate the fenced suspended source");
|
||||
.expect("worker must converge the old null source behind the newer DELETE marker");
|
||||
|
||||
assert_decommission_source_absent(
|
||||
&store,
|
||||
@@ -8089,10 +8136,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
||||
b"suspended source generation"
|
||||
);
|
||||
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
@@ -8125,7 +8169,7 @@ mod tests {
|
||||
},
|
||||
None,
|
||||
));
|
||||
let (_deleted, errors) = store
|
||||
let (deleted, errors) = store
|
||||
.delete_objects(
|
||||
&bucket,
|
||||
vec![ObjectToDelete {
|
||||
@@ -8139,10 +8183,23 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(errors.as_slice(), [Some(Error::SlowDown)]),
|
||||
matches!(errors.as_slice(), [None]),
|
||||
"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_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
let worker_store = Arc::clone(&store);
|
||||
@@ -8162,7 +8219,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect("suspended batch decommission worker should join")
|
||||
.expect("worker must migrate the batch-fenced suspended source");
|
||||
.expect("worker must converge the old null source behind the newer batch DELETE marker");
|
||||
|
||||
assert_decommission_source_absent(
|
||||
&store,
|
||||
@@ -8174,10 +8231,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
||||
b"suspended source generation"
|
||||
);
|
||||
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
|
||||
@@ -762,13 +762,7 @@ impl ECStore {
|
||||
self.pools
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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)
|
||||
})
|
||||
})
|
||||
.filter(|(pool_index, _)| pool_meta.scanner_pause_backlog_pool_writable(*pool_index))
|
||||
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ use crate::bucket::object_lock::objectlock_sys::{
|
||||
};
|
||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||
use crate::bucket::versioning::VersioningApi;
|
||||
use crate::core::pools::{DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
||||
use crate::core::pools::{DecommissionCapacityAdmission, DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
||||
use crate::disk::OldCurrentSize;
|
||||
use crate::object_api::{
|
||||
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
||||
@@ -3060,6 +3060,68 @@ 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>(
|
||||
&self,
|
||||
target_pool_idx: usize,
|
||||
@@ -3129,8 +3191,11 @@ impl ECStore {
|
||||
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
||||
} else {
|
||||
self.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
||||
.await?
|
||||
self.acquire_external_decommission_capacity_fence_with_active_source(
|
||||
&[target_pool_idx],
|
||||
DecommissionCapacityAdmission::Mutation,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||
// Active migration acquires the object namespace before its capacity
|
||||
@@ -3146,7 +3211,7 @@ impl ECStore {
|
||||
let capacity_guard = if capacity_releasing {
|
||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
||||
} else {
|
||||
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], "mutation")
|
||||
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Mutation)
|
||||
.await?
|
||||
};
|
||||
(capacity_guard, Some(guard))
|
||||
@@ -3176,9 +3241,10 @@ impl ECStore {
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
no_lock: bool,
|
||||
admission: DecommissionCapacityAdmission,
|
||||
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
||||
let (capacity_guard, has_active_decommission) = self
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], admission)
|
||||
.await?;
|
||||
if !has_active_decommission {
|
||||
// Keep the read probe through the staged commit. This closes the
|
||||
@@ -3227,7 +3293,10 @@ impl ECStore {
|
||||
return operation(opts).await;
|
||||
}
|
||||
let (capacity_guard, has_active_decommission) = self
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "heal")
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(
|
||||
&[target_pool_idx],
|
||||
DecommissionCapacityAdmission::Heal,
|
||||
)
|
||||
.await?;
|
||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||
// Active migration acquires the object namespace before its capacity
|
||||
@@ -3248,7 +3317,7 @@ impl ECStore {
|
||||
None => false,
|
||||
};
|
||||
let capacity_guard = self
|
||||
.acquire_external_decommission_capacity_fence(&[target_pool_idx], "heal")
|
||||
.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Heal)
|
||||
.await?;
|
||||
opts.no_lock = target_lock_covered;
|
||||
(capacity_guard, Some(guard))
|
||||
@@ -5093,9 +5162,14 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
||||
let target_pool_indices = (0..self.pools.len()).collect::<Vec<_>>();
|
||||
// Only marker destinations can grow. Other pools participate in
|
||||
// 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
|
||||
.acquire_external_decommission_capacity_fence(&target_pool_indices, "batch_delete")
|
||||
.acquire_external_decommission_capacity_fence(&target_pool_indices, DecommissionCapacityAdmission::BatchDelete)
|
||||
.await
|
||||
{
|
||||
Ok(fence) => Some(fence),
|
||||
|
||||
@@ -166,6 +166,24 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap<String, String>) -> bool {
|
||||
let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
|
||||
(None, None) => true,
|
||||
(Some(key), _) if key.is_empty() => true,
|
||||
(Some(key), Some(value)) => object_tags.get(key) == Some(value),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
filter
|
||||
.and
|
||||
.as_ref()
|
||||
.and_then(|and| and.tags.as_deref())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(filter.tag.iter())
|
||||
.all(tag_matches)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReplicationTargetValidationError {
|
||||
RoleWithMultipleDestinations,
|
||||
@@ -704,7 +722,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
||||
|
||||
if let Some(filter) = &rule.filter {
|
||||
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
|
||||
if filter.test_tags(&object_tags) {
|
||||
if replication_filter_tags_match(filter, &object_tags) {
|
||||
rules.push(rule.clone());
|
||||
}
|
||||
} else {
|
||||
@@ -1139,6 +1157,47 @@ mod tests {
|
||||
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actionable_rules_require_every_and_tag_to_match() {
|
||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||
rule.filter = Some(s3s::dto::ReplicationRuleFilter {
|
||||
and: Some(s3s::dto::ReplicationRuleAndOperator {
|
||||
prefix: None,
|
||||
tags: Some(vec![
|
||||
s3s::dto::Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: Some("prod".to_string()),
|
||||
},
|
||||
s3s::dto::Tag {
|
||||
key: Some("tier".to_string()),
|
||||
value: Some("gold".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let config = structure_config(vec![rule]);
|
||||
let object = |user_tags: &str| ObjectOpts {
|
||||
name: "object".to_string(),
|
||||
user_tags: user_tags.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(config.filter_target_arns(&object("env=prod")).is_empty());
|
||||
assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
|
||||
assert!(config.filter_target_arns(&object("")).is_empty());
|
||||
|
||||
let mut malformed = config;
|
||||
malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: None,
|
||||
}]);
|
||||
assert!(
|
||||
malformed.filter_target_arns(&object("env=prod")).is_empty(),
|
||||
"a malformed tag filter must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
|
||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||
|
||||
@@ -580,6 +580,30 @@ impl FailStats {
|
||||
FailedMetric { count, size }
|
||||
}
|
||||
|
||||
/// Both rolling windows from one walk of the samples. `short` must be the
|
||||
/// narrower window; the walk stops at `long`. Callers that need both (the
|
||||
/// per-node site snapshot) would otherwise scan the deque twice while
|
||||
/// holding the bucket-stats read lock, and the deque is only bounded by
|
||||
/// the one-hour window - an unreachable target under load fills it.
|
||||
pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
|
||||
let now = Instant::now();
|
||||
let mut short_metric = FailedMetric::default();
|
||||
let mut long_metric = FailedMetric::default();
|
||||
for sample in self.recent.iter().rev() {
|
||||
let age = now.duration_since(sample.observed_at);
|
||||
if age > long {
|
||||
break;
|
||||
}
|
||||
if age <= short {
|
||||
short_metric.count += 1;
|
||||
short_metric.size += sample.size;
|
||||
}
|
||||
long_metric.count += 1;
|
||||
long_metric.size += sample.size;
|
||||
}
|
||||
(short_metric, long_metric)
|
||||
}
|
||||
|
||||
pub fn merge(&self, other: &FailStats) -> Self {
|
||||
Self {
|
||||
count: self.count.saturating_add(other.count),
|
||||
@@ -912,6 +936,26 @@ mod tests {
|
||||
assert_eq!(last_hour.size, 96);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_stats_recent_windows_matches_two_separate_scans() {
|
||||
let mut stats = FailStats::default();
|
||||
stats.add_size(64, None::<&()>);
|
||||
stats.add_size(32, None::<&()>);
|
||||
|
||||
let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
||||
let expected_minute = stats.recent_since(Duration::from_secs(60));
|
||||
let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
|
||||
|
||||
assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
|
||||
assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
|
||||
assert_eq!(minute.count, 2);
|
||||
assert_eq!(hour.size, 96);
|
||||
|
||||
let empty = FailStats::default();
|
||||
let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
||||
assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_stats_saturate_instead_of_wrapping() {
|
||||
let mut stats = FailStats {
|
||||
|
||||
@@ -23,9 +23,7 @@ use super::ScannerCycleOutcome;
|
||||
use crate::data_usage_define::DataUsageCacheRevision;
|
||||
use crate::storage_api::ScannerStorage;
|
||||
use crate::storage_api::owner::ObjectIO as _;
|
||||
use crate::{
|
||||
BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks, save_config_with_preconditions,
|
||||
};
|
||||
use crate::{BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks};
|
||||
use futures::future::join_all;
|
||||
use http::HeaderMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -1195,13 +1193,14 @@ where
|
||||
};
|
||||
let revision = revisions.get(&id).cloned();
|
||||
let data = data.clone();
|
||||
let storeapi = storeapi.clone();
|
||||
async move {
|
||||
let Some(revision) = revision else {
|
||||
return (id, Err("replica revision is unavailable".to_string()));
|
||||
};
|
||||
let result = save_config_with_preconditions(set, SCANNER_PAUSE_BACKLOG_PATH.as_str(), data, revision.preconditions())
|
||||
let result = storeapi
|
||||
.save_scanner_pause_backlog_replica(id.pool_index, id.set_index, data, revision.preconditions())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| err.to_string());
|
||||
(id, result)
|
||||
}
|
||||
|
||||
@@ -152,6 +152,49 @@ fn run_data_scanner_keeps_its_two_argument_api() {
|
||||
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]
|
||||
async fn restarted_main_loop_completes_durable_pause_backlog_catch_up() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
|
||||
@@ -355,6 +355,13 @@ pub(crate) trait ScannerStorage:
|
||||
async fn list_bucket_for_scanner(&self, opts: &storage_contracts::BucketOptions) -> EcstoreResultType<ScannerBucketListing>;
|
||||
fn all_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)]
|
||||
fn scanner_observed_probe_store_key(&self) -> usize;
|
||||
}
|
||||
@@ -417,6 +424,18 @@ impl ScannerStorage for EcstoreStore {
|
||||
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)]
|
||||
fn scanner_observed_probe_store_key(&self) -> usize {
|
||||
std::ptr::from_ref(self).cast::<()>() as usize
|
||||
@@ -577,6 +596,20 @@ mod tests {
|
||||
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 {
|
||||
0
|
||||
}
|
||||
|
||||
@@ -919,6 +919,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// The filename's item count is untrusted. Reject a payload that contains
|
||||
// more items than advertised instead of returning success and allowing the
|
||||
// caller to delete the entry with trailing events still in the file.
|
||||
match deserializer.next() {
|
||||
None => {}
|
||||
Some(Ok(_)) => {
|
||||
return Err(StoreError::Deserialization(format!(
|
||||
"Batch for key {key} contains more than {} items",
|
||||
key.item_count
|
||||
)));
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Err(StoreError::Deserialization(format!("Failed to deserialize trailing batch item: {e}")));
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() && key.item_count > 0 {
|
||||
return Err(StoreError::Deserialization("No items found".to_string()));
|
||||
}
|
||||
@@ -1381,6 +1397,39 @@ mod tests {
|
||||
let _ = store.delete();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_multiple_errors_on_batch_with_trailing_items_instead_of_partial_success() {
|
||||
let dir = temp_store_dir("trailing-batch-items");
|
||||
let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
|
||||
store.open().unwrap();
|
||||
|
||||
let items = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
|
||||
let original_key = store.put_multiple(items).unwrap();
|
||||
assert_eq!(original_key.item_count, 3);
|
||||
|
||||
// Keep the three-item payload but make its filename claim that it contains
|
||||
// only two items, simulating a corrupt or otherwise untrusted queue key.
|
||||
let original_path = store.file_path(&original_key);
|
||||
let advertised_key = Key {
|
||||
item_count: 2,
|
||||
..original_key
|
||||
};
|
||||
let advertised_path = store.file_path(&advertised_key);
|
||||
std::fs::rename(&original_path, &advertised_path).unwrap();
|
||||
|
||||
let err = store.get_multiple(&advertised_key).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, StoreError::Deserialization(_)),
|
||||
"expected Deserialization error, got {err:?}"
|
||||
);
|
||||
|
||||
// Because get_multiple failed, the batch entry remains available for
|
||||
// inspection or recovery instead of being silently discarded.
|
||||
assert!(advertised_path.exists());
|
||||
|
||||
let _ = store.delete();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_put_raw_respects_entry_limit() {
|
||||
let dir = temp_store_dir("concurrent-limit");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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,6 +60,8 @@ Required headings and strings in these files are asserted by `scripts/check_arch
|
||||
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
|
||||
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
|
||||
|
||||
Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
|
||||
Operations runbooks are registered in the [documentation operations index](../README.md#operations), and testing references live in [../testing/README.md](../testing/README.md).
|
||||
|
||||
For replication operations, start with [site replication operations](../operations/site-replication-operations.md), [replication target check](../operations/replication-check.md), [replication object size limits](../operations/replication-object-size-limits.md), and [replication outbound transport](../operations/replication-outbound-transport.md).
|
||||
|
||||
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
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
|
||||
|
||||
`POST /v3/pools/decommission` with comma-separated pool targets is a queue submission:
|
||||
@@ -15,7 +17,8 @@ RustFS supports queued multi-pool decommission start requests on multi-pool depl
|
||||
- reject duplicate target pools in the same request;
|
||||
- reject active or queued target pools;
|
||||
- reject completed decommission targets, because completion means the pool can be removed from the deployment configuration;
|
||||
- allow failed or canceled targets to be retried;
|
||||
- require failed or canceled targets to be cleared before restarting, except
|
||||
when unresolved listing entries require an explicit recovery retry;
|
||||
- persist queued metadata before starting workers;
|
||||
- start only the local-leader prefix of the queue on the receiving node.
|
||||
|
||||
@@ -56,6 +59,29 @@ 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.
|
||||
|
||||
`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
|
||||
|
||||
`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`.
|
||||
@@ -70,6 +96,40 @@ When decommission metadata is present, `decommissionInfo` includes:
|
||||
|
||||
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
|
||||
|
||||
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,6 +38,42 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
Standard S3 areas that must not be described as complete:
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# 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.
|
||||
+22
-20
@@ -3,7 +3,7 @@
|
||||
**Use this when:** a check is red and you need to know whether it blocks the merge, which workflow and job produced it, and how to reproduce it locally.
|
||||
**Source of truth:** the live `main` ruleset (command below) for required status; `.github/workflows/<file>.yml` for triggers, `paths`, `timeout-minutes`, and cron; `.config/nextest.toml` for e2e profile filters; `.github/scheduled-validations.json` for the freshness-watchdog list.
|
||||
|
||||
A job blocks a merge only when its exact check name is in the live `main` ruleset. A workflow name, a `merge_group` trigger, or a red PR check does not make a job required by itself.
|
||||
A job blocks a merge when its exact check name is required by the live `main` ruleset, or when its result is required by the `Test and Lint` aggregate. A workflow name, a `merge_group` trigger, or an unrelated red PR check does not make a job required by itself.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
@@ -13,9 +13,11 @@ The `main` ruleset (`6436880`) requires exactly these contexts, with `strict_req
|
||||
|---|---|---|
|
||||
| `CLA Check` | `cla.yml` | Contributor agreement |
|
||||
| `Quick Checks` | `ci.yml` job `quick-checks` | Formatting and repository guard scripts |
|
||||
| `Test and Lint` | `ci.yml` job `test-and-lint` | Clippy, workspace nextest (`ci` profile, excluding `e2e_test`), doctests, migration-gate count (`scripts/check_migration_gate_count.sh`) |
|
||||
| `Test and Lint` | `ci.yml` job `required-checks` | Exact expected results for every CI validation job, including workspace checks, critical E2E, feature lanes, and event-specific full suites |
|
||||
|
||||
For PRs limited to the `paths-ignore` list in `ci.yml`, `ci-docs-only.yml` reports `Quick Checks` and `Test and Lint` under the same names; it runs the quick checks and `scripts/check_no_planning_docs.sh`, not a Rust build or tests. `scripts/check_ci_paths_sync.sh` keeps the two path lists aligned.
|
||||
Every PR enters `ci.yml`. The `classify-changes` job uses the base revision of `scripts/ci_gate.py` to select a conservative documentation-only path: root Markdown/licenses, `AGENTS.md`, Markdown under `docs/` or `.agents/skills/`, and documentation images. Unknown paths, unavailable Git history, an empty diff, or a missing base policy select the full matrix. Renames include their deleted source path. Documentation-only PRs still run Quick Checks and Typos; the aggregate requires the expensive jobs to be skipped exactly as selected.
|
||||
|
||||
`required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`.
|
||||
|
||||
Verify the live rule before changing merge policy:
|
||||
|
||||
@@ -24,25 +26,25 @@ gh api repos/rustfs/rustfs/rulesets/6436880 \
|
||||
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
||||
```
|
||||
|
||||
Promotion rule: never promote a report-only lane to required from one green run. Require at least 14 days and 30 representative PRs with at least 99% complete execution, then update the ruleset and this file together.
|
||||
The aggregate requires the validation lanes already selected by `ci.yml`; this closes the gap where a failing critical lane left the required workspace check green. Independent workflows remain report-only unless separately required. Before adding a new expensive lane or moving existing PR coverage to a schedule, collect representative execution and regression evidence, establish ownership and a working scheduled replacement, and update this reference with the resulting policy.
|
||||
|
||||
## Pull request and merge matrix
|
||||
|
||||
"Report-only" means visible and actionable but not in the required list. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
|
||||
"Via aggregate" means a wrong result fails the required `Test and Lint` check. "Report-only" means visible and actionable but outside both the required list and aggregate. Budgets are each job's `timeout-minutes` in the named workflow and are not copied here.
|
||||
|
||||
| Event | Check name | Workflow / job | Merge status | Reproduce |
|
||||
|---|---|---|---|---|
|
||||
| PR, non-doc change | `Quick Checks` | `ci.yml` `quick-checks` | Required | `make pre-commit` |
|
||||
| PR, non-doc change | `Test and Lint` | `ci.yml` `test-and-lint` | Required | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
|
||||
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Report-only | `typos` |
|
||||
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Report-only | exact command in the job |
|
||||
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Report-only | `cargo nextest run` with the job's `--features` |
|
||||
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Report-only | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
||||
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Report-only; prerequisite for the black-box jobs | `cargo build -p rustfs --bins` |
|
||||
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Report-only | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Report-only | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Report-only | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||
| PR, non-doc change | `Workspace Test and Lint` | `ci.yml` `test-and-lint` | Via aggregate | `cargo clippy --all-targets -- -D warnings`; `cargo nextest run --profile ci --all --exclude e2e_test`; `cargo test --all --doc`; `scripts/check_migration_gate_count.sh` |
|
||||
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
|
||||
| PR, non-doc change | `ILM Integration (serial)` | `ci.yml` `test-ilm-integration-serial` | Via aggregate | exact command in the job |
|
||||
| PR, non-doc change | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate | `cargo nextest run` with the job's `--features` |
|
||||
| PR, non-doc change | `Connect Short Credential Boundary` | `ci.yml` `connect-short-credential-boundary` | Via aggregate | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail |
|
||||
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `cargo build -p rustfs --bins --features e2e-test-hooks` |
|
||||
| PR, non-doc change | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
|
||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Via aggregate | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Via aggregate | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Via aggregate | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
||||
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
||||
@@ -52,8 +54,8 @@ Promotion rule: never promote a report-only lane to required from one green run.
|
||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
||||
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
||||
| PR limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||
| PR, documentation-only selection | `Quick Checks`, `Typos`, `Test and Lint` | `ci.yml` `quick-checks`, `typos`, `required-checks` | Required directly or via aggregate | Quick Checks commands; `python3 scripts/ci_gate.py --self-test` |
|
||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Via aggregate on these events | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||
|
||||
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
||||
|
||||
@@ -67,11 +69,11 @@ the serialized cluster fault-domain suites for scheduled soak signal.
|
||||
|
||||
## Scheduled validation
|
||||
|
||||
Scheduled lanes never block a PR. Their workflow-local gate fails the run, scheduled failures route to the shared failure-issue action, and `scheduled-validation-freshness.yml` fails when a workflow listed in `.github/scheduled-validations.json` has not run within its `max_age_hours` (a `never_ran_grace_until` entry covers the window before a newly enabled cron's first slot). Cadence is qualitative here; the cron lives in each workflow's `on.schedule`.
|
||||
Scheduled lanes never block a PR. Their workflow-local gate fails the run, scheduled failures route to the shared failure-issue action, and `scheduled-validation-freshness.yml` fails when a workflow listed in `.github/scheduled-validations.json` has no recent attempt or completed successful scheduled run within its `max_age_hours` (a `never_ran_grace_until` entry covers the window before a newly enabled cron's first slot). Cadence is qualitative here; the cron lives in each workflow's `on.schedule`.
|
||||
|
||||
| Workflow (cadence) | Jobs | Verdict and artifacts | In freshness list | Reproduce |
|
||||
|---|---|---|---|---|
|
||||
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | per-job | yes | dispatch `ci.yml` |
|
||||
| `ci.yml` (weekly) | full matrix, including the schedule/dispatch-only rio-v2 jobs `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` | strict aggregate; the full E2E lane runs on dispatch, merge groups, and main pushes | yes | dispatch `ci.yml` |
|
||||
| `build.yml` (weekly) | `build-rustfs` over the six-target platform matrix in `prepare-platform-matrix` (four Linux, macOS aarch64, Windows x86_64) | build/package integrity | yes | dispatch `build.yml` with an exact platform set |
|
||||
| `e2e-replication-nightly.yml` (nightly) | `repl-nightly`, `cluster-nightly`, `protocols-nightly` | three independent gates; JUnit, membership listing, server logs | yes | `cargo nextest run --profile e2e-repl-nightly -p e2e_test`; `--profile e2e-nightly`; `-j 1 --profile e2e-protocols` |
|
||||
| `e2e-distributed.yml` (storage-sensitive PRs + nightly) | `distributed` | fail-closed 4-node 4-disk S3, durability, replication, movement, fault, and direct/rolling upgrade gate; JUnit, membership listing, per-node server logs | yes, with `never_ran_grace_until` | download the pinned previous release as in the workflow, export `RUSTFS_UPGRADE_SOURCE_BINARY`, then `cargo nextest run --profile e2e-distributed -p e2e_test` |
|
||||
@@ -88,7 +90,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on missing or stale attempts or completed successes | n/a | dispatch |
|
||||
|
||||
Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.
|
||||
|
||||
|
||||
@@ -627,6 +627,32 @@ pub(crate) async fn cluster_replication_stats(bucket: &str, context: Option<Arc<
|
||||
.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) {
|
||||
let mut seen_grid_hosts = HashSet::new();
|
||||
let peers: Vec<_> = peer_clients
|
||||
@@ -699,6 +725,7 @@ pub struct SetRemoteTargetHandler {}
|
||||
impl Operation for SetRemoteTargetHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
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);
|
||||
|
||||
@@ -926,6 +953,8 @@ impl Operation for SetRemoteTargetHandler {
|
||||
.map_err(map_bucket_target_error)?;
|
||||
let _targets_guard = lock_bucket_targets_metadata(bucket).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)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize target ARN"))?;
|
||||
|
||||
@@ -1006,6 +1035,7 @@ pub struct RemoveRemoteTargetHandler {}
|
||||
impl Operation for RemoveRemoteTargetHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
||||
let app_context = app_context_from_req(&req);
|
||||
|
||||
debug!("remove remote target called");
|
||||
let queries = extract_query_params(&req.uri);
|
||||
@@ -1081,6 +1111,7 @@ impl Operation for RemoveRemoteTargetHandler {
|
||||
}
|
||||
let json_targets = serde_json::to_vec(&targets)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets"))?;
|
||||
let notification_bucket = bucket.clone();
|
||||
let bucket = bucket.clone();
|
||||
let arn = arn_str.clone();
|
||||
// The pool cancellation owns a detached task. Both outer guards must
|
||||
@@ -1101,6 +1132,8 @@ impl Operation for RemoveRemoteTargetHandler {
|
||||
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()))))
|
||||
}
|
||||
}
|
||||
@@ -1787,6 +1820,25 @@ mod tests {
|
||||
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]
|
||||
fn update_ops_parse_minio_query_contract() {
|
||||
let ops = parse_remote_target_update_ops(&query_map(&[
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1318,6 +1318,24 @@ impl Operation for ImportIam {
|
||||
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 mut header = HeaderMap::new();
|
||||
@@ -1424,6 +1442,16 @@ mod tests {
|
||||
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]
|
||||
fn test_should_check_deny_only_for_regular_self_request() {
|
||||
let cred = Credentials {
|
||||
|
||||
@@ -409,6 +409,26 @@ fn transfer_summaries(stats: &InternalReplicationStats) -> (XferSummaryWire, Tar
|
||||
(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 {
|
||||
/// Project the aggregated internal stats onto the `MetricsV2` shape.
|
||||
///
|
||||
@@ -418,6 +438,7 @@ impl MetricsV2Wire {
|
||||
/// `queueStats.nodes` and treats an empty list as "no data".
|
||||
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 failed = failure_counters(&bucket_stats.replication_stats);
|
||||
let mut nodes: Vec<ReplQNodeStatsWire> = bucket_stats
|
||||
.queue_stats
|
||||
.nodes
|
||||
@@ -436,6 +457,7 @@ impl MetricsV2Wire {
|
||||
q_stats: InQueueMetricWire::from(&bucket_stats.replication_stats.q_stat),
|
||||
xfer_stats: xfer_stats.clone(),
|
||||
tgt_xfer_stats: tgt_xfer_stats.clone(),
|
||||
errors: failed,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
@@ -444,6 +466,7 @@ impl MetricsV2Wire {
|
||||
if let Some(first) = nodes.first_mut() {
|
||||
first.xfer_stats = xfer_stats.clone();
|
||||
first.tgt_xfer_stats = tgt_xfer_stats.clone();
|
||||
first.errors = failed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +501,12 @@ mod tests {
|
||||
target.replicated_size = 4096;
|
||||
target.failed.count = 3;
|
||||
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.current_bandwidth_bytes_per_sec = 512.5;
|
||||
stats
|
||||
@@ -537,6 +566,10 @@ mod tests {
|
||||
assert_eq!(node["queueStats"]["peak"], node["queueStats"]["max"]);
|
||||
assert!(node["activeWorkers"].get("curr").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!({}));
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,26 @@ pub(crate) fn settle_observed_site_replication_retry_event(
|
||||
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(
|
||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||
peer: &PeerInfo,
|
||||
@@ -244,6 +264,17 @@ pub(crate) fn upsert_site_replication_retry_event(
|
||||
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
|
||||
.len()
|
||||
.saturating_add(1)
|
||||
@@ -274,7 +305,7 @@ pub(crate) fn upsert_site_replication_retry_event(
|
||||
retry_count: 1,
|
||||
failed: false,
|
||||
last_error: detail,
|
||||
updated_at: Some(now),
|
||||
updated_at: Some(OffsetDateTime::now_utc()),
|
||||
edit_generation: generation,
|
||||
peer_unreachable,
|
||||
deletions_recorded: false,
|
||||
@@ -365,6 +396,60 @@ 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";
|
||||
|
||||
/// Per-peer cap on recorded deletion bodies. Beyond it the peer's collapsed
|
||||
|
||||
@@ -168,6 +168,8 @@ mod rfc3339_map {
|
||||
pub(crate) struct PendingEndpointRefresh {
|
||||
pub(crate) id: String,
|
||||
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")]
|
||||
pub(crate) remote_peers: BTreeMap<String, PeerInfo>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
|
||||
@@ -693,6 +693,122 @@ 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
|
||||
/// 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
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::RngExt as _;
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
@@ -836,6 +837,39 @@ impl StorageReplicationStatsHandle {
|
||||
|
||||
pub(crate) async fn site_metrics_snapshot(&self) -> ReplicationSiteMetricsSnapshot {
|
||||
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 {
|
||||
uptime: metrics.uptime,
|
||||
queued_curr_count: metrics.queued.curr.count,
|
||||
@@ -859,6 +893,12 @@ impl StorageReplicationStatsHandle {
|
||||
proxy_delete_tag_failed: metrics.proxied.delete_tag_failed,
|
||||
replica_size: metrics.replica_size,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,6 +939,12 @@ pub(crate) struct ReplicationSiteMetricsSnapshot {
|
||||
pub(crate) proxy_delete_tag_failed: i64,
|
||||
pub(crate) replica_size: 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 {
|
||||
@@ -2043,13 +2089,32 @@ pub(crate) async fn init_compression_total_memory_from_backend(store: Arc<ECStor
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BUCKET_RESYNC_LOCK_RETRY_MAX_MS, apply_active_resync_intents, bucket_resync_transaction_lock_retry_ceiling_ms,
|
||||
bucket_resync_transaction_lock_retry_delay, bucket_resync_transaction_lock_retry_reason,
|
||||
bucket_targets_metadata_lock_shard, ecstore_bucket, lock_bucket_targets_metadata, new_instance_ctx,
|
||||
retry_bucket_resync_transaction_lock, scanner_maintenance_config_file,
|
||||
BUCKET_RESYNC_LOCK_RETRY_MAX_MS, StorageReplicationStatsHandle, apply_active_resync_intents,
|
||||
bucket_resync_transaction_lock_retry_ceiling_ms, bucket_resync_transaction_lock_retry_delay,
|
||||
bucket_resync_transaction_lock_retry_reason, bucket_targets_metadata_lock_shard, ecstore_bucket,
|
||||
lock_bucket_targets_metadata, new_instance_ctx, retry_bucket_resync_transaction_lock, scanner_maintenance_config_file,
|
||||
};
|
||||
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]
|
||||
async fn bucket_target_metadata_locks_serialize_only_matching_shards() {
|
||||
let bucket = "bucket-target-lock";
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci.yml's pull_request paths-ignore and ci-docs-only.yml's paths must be equal.
|
||||
#
|
||||
# ci-docs-only.yml exists to report the required checks for pull requests that
|
||||
# ci.yml skips. The two lists are the complement of each other, so any drift
|
||||
# breaks one of two ways, both silent:
|
||||
#
|
||||
# - an entry only in ci.yml's paths-ignore: a PR touching only those files
|
||||
# triggers neither workflow, nobody reports "Test and Lint" or "Quick
|
||||
# Checks", and the PR waits on a required check forever;
|
||||
# - an entry only in ci-docs-only.yml's paths: both workflows run, which is
|
||||
# merely wasteful — but it also means the lists no longer describe the same
|
||||
# intent, and the next edit is made against a wrong assumption.
|
||||
#
|
||||
# The push paths-ignore in ci.yml is deliberately NOT compared: no required
|
||||
# check is reported for push events, so it does not have to pair with anything.
|
||||
#
|
||||
# Also asserts ci-docs-only.yml still declares both companion job names, since a
|
||||
# rename there produces exactly the permanent-pending failure above.
|
||||
#
|
||||
# Usage: scripts/check_ci_paths_sync.sh
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CI=".github/workflows/ci.yml"
|
||||
DOCS=".github/workflows/ci-docs-only.yml"
|
||||
|
||||
# Print the quoted list items that follow $2 within the block introduced by $1.
|
||||
# Both files keep these as a flat list of quoted scalars, so no YAML parser is
|
||||
# needed and the script stays dependency-free like its check_* siblings.
|
||||
extract() {
|
||||
local file="$1" event="$2" key="$3"
|
||||
awk -v event="$event" -v key="$key" '
|
||||
$0 ~ "^ " event ":[[:space:]]*$" { in_event = 1; next }
|
||||
in_event && /^ [a-z_]+:[[:space:]]*$/ { in_event = 0 }
|
||||
in_event && $0 ~ "^ " key ":[[:space:]]*$" { in_list = 1; next }
|
||||
in_list {
|
||||
if ($0 ~ /^ - /) {
|
||||
item = $0
|
||||
sub(/^ - /, "", item)
|
||||
gsub(/^"|"$/, "", item)
|
||||
print item
|
||||
next
|
||||
}
|
||||
if ($0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*$/) in_list = 0
|
||||
}
|
||||
' "$file" | sort
|
||||
}
|
||||
|
||||
ci_list="$(extract "$CI" "pull_request" "paths-ignore")"
|
||||
docs_list="$(extract "$DOCS" "pull_request" "paths")"
|
||||
|
||||
if [ -z "$ci_list" ] || [ -z "$docs_list" ]; then
|
||||
echo "ERROR: could not read one of the path lists — did the file structure change?" >&2
|
||||
echo " $CI pull_request.paths-ignore: $(printf '%s' "$ci_list" | grep -c . || true) entries" >&2
|
||||
echo " $DOCS pull_request.paths: $(printf '%s' "$docs_list" | grep -c . || true) entries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status=0
|
||||
|
||||
if ! diff_out="$(diff <(printf '%s\n' "$ci_list") <(printf '%s\n' "$docs_list"))"; then
|
||||
echo "ERROR: $CI pull_request paths-ignore and $DOCS paths have drifted." >&2
|
||||
echo " '<' is only in $CI, '>' is only in $DOCS:" >&2
|
||||
printf '%s\n' "$diff_out" | sed 's/^/ /' >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
for job_name in "Test and Lint" "Quick Checks"; do
|
||||
if ! grep -q "name: ${job_name}\$" "$DOCS"; then
|
||||
echo "ERROR: $DOCS no longer declares a job named '${job_name}'." >&2
|
||||
echo " It is a required status check; without a companion job here, a" >&2
|
||||
echo " docs-only PR waits on it forever." >&2
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: ci.yml and ci-docs-only.yml path lists agree ($(printf '%s\n' "$ci_list" | wc -l | tr -d ' ') entries)"
|
||||
@@ -55,8 +55,11 @@ cd "$(dirname "$0")/.."
|
||||
# now reports an unreadable configuration as a plain string instead of raising
|
||||
# an S3 error per arm (24 invocation lines removed from
|
||||
# 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
|
||||
S3_ERROR_LINES_BASELINE=1589
|
||||
S3_ERROR_LINES_BASELINE=1588
|
||||
# ecstore-scoped ratchet (rustfs/backlog#1842): the storage engine must not
|
||||
# know S3 wire/DTO types (ARCHITECTURE.md invariant 4). The S3-*consuming*
|
||||
# client was extracted to crates/s3-client, where s3s usage is legitimate;
|
||||
|
||||
@@ -542,7 +542,7 @@ def yaml_scalar_continues(lines: list[str], index: int, indent: int) -> bool:
|
||||
def check_quick_checks(root: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
bypass_key = r'''(?:if|continue-on-error|needs|"if"|"continue-on-error"|"needs"|'if'|'continue-on-error'|'needs')\s*:'''
|
||||
for name in ("ci.yml", "ci-docs-only.yml"):
|
||||
for name in ("ci.yml",):
|
||||
relative = f".github/workflows/{name}"
|
||||
path = root / relative
|
||||
job = yaml_block(path.read_text().splitlines(), "quick-checks", 2) if path.is_file() else None
|
||||
@@ -1165,7 +1165,6 @@ class SelfTests(unittest.TestCase):
|
||||
".github/workflows/ci.yml": caller.replace(
|
||||
" steps:", " if: github.event_name != 'pull_request' || github.event.action != 'closed'\n steps:"
|
||||
),
|
||||
".github/workflows/ci-docs-only.yml": caller,
|
||||
".github/actions/quick-checks/action.yml": action,
|
||||
}
|
||||
for relative, source in sources.items():
|
||||
@@ -1173,7 +1172,7 @@ class SelfTests(unittest.TestCase):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source)
|
||||
self.assertEqual(check_quick_checks(root), [])
|
||||
for relative in (".github/workflows/ci.yml", ".github/workflows/ci-docs-only.yml"):
|
||||
for relative in (".github/workflows/ci.yml",):
|
||||
source = sources[relative]
|
||||
mutations = {
|
||||
"different action": source.replace("./.github/actions/quick-checks", "./.github/actions/other"),
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select safe documentation-only CI and verify the complete required job set."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ALWAYS_JOBS = ("classify-changes", "typos", "quick-checks")
|
||||
CODE_JOBS = (
|
||||
"test-and-lint", "test-ilm-integration-serial", "test-and-lint-rio-v2",
|
||||
"connect-short-credential-boundary", "test-and-lint-protocols",
|
||||
"build-rustfs-debug-binary", "uring-integration", "e2e-tests",
|
||||
"s3-implemented-tests", "s3-lifecycle-behavior-tests",
|
||||
)
|
||||
OPTIONAL_JOBS = ("build-rustfs-debug-binary-rio-v2", "e2e-tests-rio-v2", "e2e-full")
|
||||
NON_VALIDATION_JOBS = {"required-checks", "cancel-closed-pr-runs", "alert-on-failure"}
|
||||
|
||||
|
||||
def documentation_path(path: str) -> bool:
|
||||
parts = PurePosixPath(path).parts
|
||||
if not parts or path.startswith("/") or any(part in (".", "..") for part in parts) or any(ord(c) < 32 for c in path):
|
||||
return False
|
||||
if parts[-1] == "AGENTS.md":
|
||||
return True
|
||||
if len(parts) == 1 and (path.endswith(".md") or path == "LICENSE" or path.startswith("LICENSE-")):
|
||||
return True
|
||||
if path.startswith(("docs/", ".agents/skills/")) and path.endswith(".md"):
|
||||
return True
|
||||
return path.startswith("docs/") and path.endswith((".png", ".jpg", ".svg"))
|
||||
|
||||
|
||||
def select_mode(event: str, base: str, head: str, root: Path) -> str:
|
||||
if event != "pull_request" or not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)):
|
||||
return "full"
|
||||
try:
|
||||
changed = subprocess.check_output(
|
||||
["git", "diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", base, head, "--"],
|
||||
cwd=root, stderr=subprocess.PIPE,
|
||||
).decode("utf-8")
|
||||
except (subprocess.CalledProcessError, UnicodeError):
|
||||
return "full"
|
||||
paths = changed.rstrip("\0").split("\0") if changed else []
|
||||
return "docs" if paths and all(documentation_path(path) for path in paths) else "full"
|
||||
|
||||
|
||||
def expected_results(mode: str, event: str, ref: str) -> dict[str, str]:
|
||||
if event not in ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch"):
|
||||
raise ValueError(f"unsupported CI event: {event!r}")
|
||||
if mode not in ("docs", "full") or (mode == "docs" and event != "pull_request"):
|
||||
raise ValueError(f"invalid CI selection: {mode!r} for {event!r}")
|
||||
expected = {job: "success" for job in ALWAYS_JOBS}
|
||||
expected.update({job: "success" if mode == "full" else "skipped" for job in CODE_JOBS})
|
||||
rio = mode == "full" and event in ("schedule", "workflow_dispatch")
|
||||
expected.update({job: "success" if rio else "skipped" for job in OPTIONAL_JOBS[:2]})
|
||||
full = mode == "full" and (event in ("merge_group", "workflow_dispatch") or (event == "push" and ref == "refs/heads/main"))
|
||||
expected["e2e-full"] = "success" if full else "skipped"
|
||||
return expected
|
||||
|
||||
|
||||
def verify_results(needs: object, event: str, ref: str) -> list[str]:
|
||||
if not isinstance(needs, dict):
|
||||
return ["needs must be a job-result object"]
|
||||
selection = needs.get("classify-changes", {})
|
||||
outputs = selection.get("outputs", {}) if isinstance(selection, dict) else {}
|
||||
mode = outputs.get("mode") if isinstance(outputs, dict) else None
|
||||
try:
|
||||
expected = expected_results(mode, event, ref)
|
||||
except ValueError as error:
|
||||
return [str(error)]
|
||||
errors = []
|
||||
if set(needs) != set(expected):
|
||||
errors.append(f"job set differs: missing={sorted(set(expected) - set(needs))}, unexpected={sorted(set(needs) - set(expected))}")
|
||||
for job, required in expected.items():
|
||||
result = needs.get(job, {})
|
||||
actual = result.get("result") if isinstance(result, dict) else None
|
||||
if actual != required:
|
||||
errors.append(f"{job}: expected {required}, got {actual!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def check_workflow(root: Path) -> list[str]:
|
||||
# Reuse the repository's canonical-indentation checker; actionlint validates YAML syntax.
|
||||
from check_test_wiring import yaml_block, yaml_scalar_continues
|
||||
|
||||
errors = []
|
||||
lines = (root / ".github/workflows/ci.yml").read_text().splitlines()
|
||||
jobs = yaml_block(lines, "jobs", 0) or []
|
||||
names = set()
|
||||
for index, line in enumerate(jobs):
|
||||
if not re.match(r"^ \S", line) or line.lstrip().startswith("#"):
|
||||
continue
|
||||
header = re.fullmatch(r''' (["']?)([A-Za-z_][A-Za-z0-9_-]*)\1\s*:\s*(?:#.*)?''', line)
|
||||
if header is None:
|
||||
errors.append("CI job declarations must use single-line job IDs")
|
||||
continue
|
||||
name = header[2]
|
||||
if name in names:
|
||||
errors.append(f"duplicate CI job ID: {name}")
|
||||
names.add(name)
|
||||
jobs[index] = f" {name}:"
|
||||
required = set(ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS)
|
||||
if names - NON_VALIDATION_JOBS != required:
|
||||
errors.append("CI verification jobs and the required gate contract differ")
|
||||
for job in required:
|
||||
block = yaml_block(jobs, job, 2) or []
|
||||
if any(re.match(r"\s+(?:- )?[\"']?continue-on-error[\"']?\s*:", line) for line in block):
|
||||
errors.append(f"{job} cannot convert a validation failure into success")
|
||||
gate = yaml_block(jobs, "required-checks", 2) or []
|
||||
def scalar(block, key, indent):
|
||||
prefix = " " * indent + key + ": "
|
||||
matches = [index for index, line in enumerate(block) if line.startswith(prefix)]
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
index = matches[0]
|
||||
if yaml_scalar_continues(block, index, indent):
|
||||
return None
|
||||
return block[index][len(prefix):]
|
||||
|
||||
display_names = {}
|
||||
for job in names:
|
||||
block = [re.sub(r'''^ (?:'name'|"name")\s*:\s*''', " name: ", line)
|
||||
for line in yaml_block(jobs, job, 2) or []]
|
||||
value = scalar(block, "name", 4)
|
||||
display = re.fullmatch(r'''(?:"([^"\\]*)"|'([^']*)'|([^'"#][^#]*?))(?:\s+#.*)?\s*''', (value or "").strip())
|
||||
if display is None or (display[3] is not None and display[3].startswith(tuple("|>*&!{[?"))):
|
||||
errors.append(f"{job} must use a verifiable single-line display name")
|
||||
continue
|
||||
name = next(value for value in display.groups() if value is not None)
|
||||
if "${{" in name and (job != "test-and-lint-protocols" or name != "Test and Lint (${{ matrix.features.name }})"):
|
||||
errors.append(f"{job} has an unverifiable dynamic display name")
|
||||
display_names[job] = name
|
||||
|
||||
dependencies = yaml_block(gate, "needs", 4) or []
|
||||
declared = [line.strip().removeprefix("- ") for line in dependencies if line.strip()]
|
||||
if set(declared) != required or len(declared) != len(required):
|
||||
errors.append("required-checks must directly depend on every verification job exactly once")
|
||||
if display_names.get("required-checks") != "Test and Lint" or list(display_names.values()).count("Test and Lint") != 1:
|
||||
errors.append("Test and Lint must uniquely name the aggregate gate")
|
||||
if scalar(gate, "if", 4) != "always() && (github.event_name != 'pull_request' || github.event.action != 'closed')":
|
||||
errors.append("required-checks must run after failed or skipped dependencies")
|
||||
if scalar(gate, "shell", 8) != "bash" or scalar(gate, "run", 8) != "python3 scripts/ci_gate.py verify" or scalar(gate, "CI_NEEDS", 10) != "${{ toJSON(needs) }}":
|
||||
errors.append("required-checks must verify the actual needs results")
|
||||
if any(re.match(r'''\s+(?:- )?(?:["']?continue-on-error["']?\s*:|["']?if["']?\s*:)''', line) and not line.startswith(" if:") for line in gate):
|
||||
errors.append("required-checks cannot ignore failures")
|
||||
pr = yaml_block(lines, "pull_request", 2) or []
|
||||
if any(line.strip().startswith(("paths:", "paths-ignore:")) for line in pr):
|
||||
errors.append("all pull requests must enter the single CI workflow")
|
||||
if (root / ".github/workflows/ci-docs-only.yml").exists():
|
||||
errors.append("the duplicate required-status companion must be removed")
|
||||
return errors
|
||||
|
||||
|
||||
class SelfTests(unittest.TestCase):
|
||||
def test_documentation_paths_do_not_hide_build_or_fixture_changes(self):
|
||||
for path in ("README.md", "AGENTS.md", "crates/utils/AGENTS.md", "docs/testing/README.md", "docs/diagram.svg", ".agents/skills/example/SKILL.md"):
|
||||
self.assertTrue(documentation_path(path), path)
|
||||
for path in ("", "src/lib.rs", "crates/foo/tests/fixtures/data.md", "Cargo.lock", "build.rs", "deploy/chart.yaml", ".github/workflows/ci.yml", "scripts/dev_build.sh", "assets/logo.png", "docs/test.rs", "README.md\n", "../README.md"):
|
||||
self.assertFalse(documentation_path(path), path)
|
||||
|
||||
def test_git_range_includes_deleted_source_and_rename_origins(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", "-c", "core.hooksPath=/dev/null", "-c", "user.name=CI Test", "-c", "user.email=ci@example.invalid", *args], cwd=root, stderr=subprocess.PIPE).decode().strip()
|
||||
git("init", "-q")
|
||||
(root / "server.rs").write_text("fn main() {}\n")
|
||||
(root / "README.md").write_text("old\n")
|
||||
git("add", "."); git("commit", "-qm", "base")
|
||||
base = git("rev-parse", "HEAD")
|
||||
(root / "README.md").write_text("new\n")
|
||||
git("add", "."); git("commit", "-qm", "docs")
|
||||
docs = git("rev-parse", "HEAD")
|
||||
self.assertEqual(select_mode("pull_request", base, docs, root), "docs")
|
||||
(root / "server.rs").rename(root / "server.md")
|
||||
git("add", "-A"); git("commit", "-qm", "rename source")
|
||||
head = git("rev-parse", "HEAD")
|
||||
self.assertEqual(select_mode("pull_request", base, head, root), "full")
|
||||
self.assertEqual(select_mode("pull_request", docs, docs, root), "full")
|
||||
self.assertEqual(select_mode("pull_request", "0" * 40, head, root), "full")
|
||||
self.assertEqual(select_mode("pull_request", "--output=bad", head, root), "full")
|
||||
self.assertEqual(select_mode("merge_group", base, docs, root), "full")
|
||||
|
||||
def test_event_contract_requires_complete_candidate_and_optional_lanes(self):
|
||||
ordinary = expected_results("full", "pull_request", "refs/pull/1/merge")
|
||||
self.assertEqual({job for job, state in ordinary.items() if state == "skipped"}, set(OPTIONAL_JOBS))
|
||||
docs = expected_results("docs", "pull_request", "refs/pull/1/merge")
|
||||
self.assertEqual({job for job, state in docs.items() if state == "success"}, set(ALWAYS_JOBS))
|
||||
for event in ("schedule", "workflow_dispatch", "merge_group", "push"):
|
||||
result = expected_results("full", event, "refs/heads/main")
|
||||
self.assertEqual(result["e2e-full"], "skipped" if event == "schedule" else "success")
|
||||
self.assertEqual(result["e2e-tests-rio-v2"], "success" if event in ("schedule", "workflow_dispatch") else "skipped")
|
||||
with self.assertRaises(ValueError):
|
||||
expected_results("docs", event, "refs/heads/main")
|
||||
|
||||
def test_every_wrong_result_missing_job_or_selection_fails_closed(self):
|
||||
for mode, event in (("full", "pull_request"), ("docs", "pull_request"), ("full", "schedule"), ("full", "workflow_dispatch"), ("full", "merge_group")):
|
||||
good = {job: {"result": value} for job, value in expected_results(mode, event, "refs/heads/main").items()}
|
||||
good["classify-changes"]["outputs"] = {"mode": mode}
|
||||
self.assertEqual(verify_results(good, event, "refs/heads/main"), [])
|
||||
for job in good:
|
||||
for value in ("success", "skipped", "failure", "cancelled", "neutral", "", None):
|
||||
if value == good[job]["result"]:
|
||||
continue
|
||||
with self.subTest(mode=mode, event=event, job=job, result=value):
|
||||
bad = {**good, job: {**good[job], "result": value}}
|
||||
self.assertTrue(verify_results(bad, event, "refs/heads/main"))
|
||||
self.assertTrue(verify_results({key: value for key, value in good.items() if key != job}, event, "refs/heads/main"))
|
||||
missing_result = {key: value for key, value in good[job].items() if key != "result"}
|
||||
self.assertTrue(verify_results({**good, job: missing_result}, event, "refs/heads/main"))
|
||||
self.assertTrue(verify_results({**good, "unknown-job": {"result": "success"}}, event, "refs/heads/main"))
|
||||
for selection in ({}, {"mode": ""}, {"mode": True}, []):
|
||||
bad = {**good, "classify-changes": {"result": "success", "outputs": selection}}
|
||||
self.assertTrue(verify_results(bad, event, "refs/heads/main"))
|
||||
|
||||
def test_repository_wiring_and_missing_dependency_regression(self):
|
||||
self.assertEqual(check_workflow(ROOT), [])
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / ".github/workflows").mkdir(parents=True)
|
||||
source = (ROOT / ".github/workflows/ci.yml").read_text()
|
||||
path = root / ".github/workflows/ci.yml"
|
||||
for job in ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS:
|
||||
before, gate = source.split(" required-checks:\n", 1)
|
||||
path.write_text(before + " required-checks:\n" + gate.replace(f" - {job}\n", "", 1))
|
||||
self.assertTrue(check_workflow(root), job)
|
||||
for old, new in (
|
||||
("run: python3 scripts/ci_gate.py verify", "run: python3 scripts/ci_gate.py verify || true"),
|
||||
("run: python3 scripts/ci_gate.py verify", "run: python3 scripts/ci_gate.py verify\n || true"),
|
||||
("CI_NEEDS: ${{ toJSON(needs) }}", "CI_NEEDS: '{}'"),
|
||||
("name: Test and Lint\n", "name: Unrequired result\n"),
|
||||
(" shell: bash\n run: python3 scripts/ci_gate.py verify", " shell: echo {0}\n run: python3 scripts/ci_gate.py verify"),
|
||||
(" shell: bash\n run: python3 scripts/ci_gate.py verify", " run: python3 scripts/ci_gate.py verify"),
|
||||
(" run: python3 scripts/ci_gate.py verify", ' "if": false\n run: python3 scripts/ci_gate.py verify'),
|
||||
):
|
||||
path.write_text(source.replace(old, new))
|
||||
self.assertTrue(check_workflow(root), new)
|
||||
for job in ALWAYS_JOBS + CODE_JOBS + OPTIONAL_JOBS:
|
||||
for field in ("continue-on-error", '"continue-on-error"', "'continue-on-error'"):
|
||||
path.write_text(source.replace(f" {job}:\n", f" {job}:\n {field}: true\n", 1))
|
||||
self.assertTrue(check_workflow(root), (job, field))
|
||||
before, block = source.split(f" {job}:\n", 1)
|
||||
block = block.replace(" - name:", f" - {field}: true\n name:", 1)
|
||||
path.write_text(before + f" {job}:\n" + block)
|
||||
self.assertTrue(check_workflow(root), (job, field, "step"))
|
||||
path.write_text(source + "\n cancel-after-test-and-lint-failure:\n runs-on: ubuntu-latest\n")
|
||||
self.assertTrue(check_workflow(root))
|
||||
|
||||
def test_job_ids_and_display_names_cannot_hide_validation(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / ".github/workflows").mkdir(parents=True)
|
||||
source = (ROOT / ".github/workflows/ci.yml").read_text()
|
||||
path = root / ".github/workflows/ci.yml"
|
||||
for header in ("typos", "'typos'", '"typos"'):
|
||||
path.write_text(source.replace(" typos:\n", f" {header}: # spelling\n"))
|
||||
self.assertEqual(check_workflow(root), [], header)
|
||||
for name in ("Test and Lint # required", "'Test and Lint'", '"Test and Lint" # required'):
|
||||
path.write_text(source.replace(" name: Test and Lint\n", f" name: {name}\n"))
|
||||
self.assertEqual(check_workflow(root), [], name)
|
||||
for key in ("'name'", '"name"'):
|
||||
path.write_text(source.replace(" name: Typos\n", f" {key}: Typos\n"))
|
||||
self.assertEqual(check_workflow(root), [], key)
|
||||
for header in ("new_test", "NewTest", "_new_test", "'new_test'", '"new_test"', '"new\\u005ftest"'):
|
||||
path.write_text(source + f"\n {header}:\n name: New test\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n")
|
||||
self.assertTrue(check_workflow(root), header)
|
||||
path.write_text(source + "\n 'typos':\n name: Duplicate\n runs-on: ubuntu-latest\n steps:\n - run: exit 1\n")
|
||||
self.assertIn("duplicate CI job ID: typos", check_workflow(root))
|
||||
for name in (
|
||||
"Test and Lint", "Test and Lint # duplicate", "'Test and Lint'",
|
||||
'"Test and Lint" # duplicate', '"Test\\u0020and Lint"',
|
||||
">-\n Test and Lint", "|-\n Test and Lint", "Test and\n Lint",
|
||||
"*required_name", "&required_name Test and Lint", "!!str Test and Lint",
|
||||
"${{ 'Test and Lint' }}", '"${{ github.event.inputs.check_name }}"',
|
||||
):
|
||||
path.write_text(source.replace(" name: Typos\n", f" name: {name}\n"))
|
||||
self.assertTrue(check_workflow(root), name)
|
||||
path.write_text(source.replace(" name: Typos\n", ""))
|
||||
self.assertIn("typos must use a verifiable single-line display name", check_workflow(root))
|
||||
|
||||
def test_verify_command_preserves_failures(self):
|
||||
good = {job: {"result": value} for job, value in expected_results("full", "pull_request", "refs/pull/1/merge").items()}
|
||||
good["classify-changes"]["outputs"] = {"mode": "full"}
|
||||
failed = {**good, "e2e-tests": {"result": "failure"}}
|
||||
for needs, code in ((json.dumps(good), 0), (json.dumps(failed), 1), ("{}", 1), ("{", 1)):
|
||||
with self.subTest(needs=needs):
|
||||
env = dict(os.environ, CI_NEEDS=needs, GITHUB_EVENT_NAME="pull_request", GITHUB_REF="refs/pull/1/merge")
|
||||
result = subprocess.run([sys.executable, str(Path(__file__).resolve()), "verify"], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, code, result.stderr)
|
||||
self.assertIn("ERROR:" if code else "CI contract passed", result.stderr if code else result.stdout)
|
||||
|
||||
def test_actual_selector_bootstrap_uses_base_policy_and_fails_closed(self):
|
||||
from check_test_wiring import yaml_block
|
||||
jobs = yaml_block((ROOT / ".github/workflows/ci.yml").read_text().splitlines(), "jobs", 0)
|
||||
selector = yaml_block(jobs, "classify-changes", 2)
|
||||
body = "\n".join(line[10:] for line in selector[selector.index(" run: |") + 1:])
|
||||
for event, changed, base_sha, available, broken, expected in (
|
||||
("pull_request", "README.md", "b" * 40, True, False, "docs"),
|
||||
("pull_request", "src/server.rs", "b" * 40, True, False, "full"),
|
||||
("pull_request", "README.md", "b" * 40, False, False, "full"),
|
||||
("merge_group", "README.md", "b" * 40, False, False, "full"),
|
||||
("pull_request", "README.md", "b" * 40, True, True, None),
|
||||
("pull_request", "README.md", "", True, True, "full"),
|
||||
):
|
||||
with self.subTest(event=event, changed=changed, available=available, broken=broken), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "scripts").mkdir()
|
||||
(root / "scripts/ci_gate.py").write_text("raise SystemExit(71)\n")
|
||||
(root / "python3").symlink_to(sys.executable)
|
||||
base = root / "base-policy.py"
|
||||
base.write_text("raise SystemExit(29)\n" if broken else Path(__file__).read_text())
|
||||
git = root / "git"
|
||||
git.write_text('''#!/bin/sh
|
||||
if [ "$1" = show ]; then
|
||||
[ "$2" = "$CI_BASE_SHA:scripts/ci_gate.py" ] || exit 19
|
||||
[ "$BASE_AVAILABLE" = yes ] || exit 128
|
||||
cat "$BASE_POLICY"
|
||||
elif [ "$1" = diff ]; then
|
||||
printf '%s\\0' "$CHANGED_PATH"
|
||||
else
|
||||
exit 20
|
||||
fi
|
||||
''')
|
||||
git.chmod(0o755)
|
||||
output = root / "output"
|
||||
output.touch()
|
||||
env = dict(os.environ, GITHUB_EVENT_NAME=event, CI_BASE_SHA=base_sha, GITHUB_SHA="c" * 40,
|
||||
RUNNER_TEMP=str(root), GITHUB_OUTPUT=str(output), BASE_POLICY=str(base),
|
||||
BASE_AVAILABLE="yes" if available else "no", CHANGED_PATH=changed,
|
||||
PATH=f"{root}{os.pathsep}{os.environ['PATH']}")
|
||||
result = subprocess.run(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", body], cwd=root, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 29 if expected is None else 0, result.stderr)
|
||||
self.assertEqual(output.read_text(), "" if expected is None else f"mode={expected}\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.argv[1:] == ["--self-test"]:
|
||||
return not unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(SelfTests)).wasSuccessful()
|
||||
if sys.argv[1:] == ["select"]:
|
||||
mode = select_mode(os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("CI_BASE_SHA", ""), os.environ.get("GITHUB_SHA", ""), Path.cwd())
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||||
output.write(f"mode={mode}\n")
|
||||
print(f"CI selection: {mode}")
|
||||
return 0
|
||||
if sys.argv[1:] == ["verify"]:
|
||||
try:
|
||||
errors = verify_results(json.loads(os.environ["CI_NEEDS"]), os.environ.get("GITHUB_EVENT_NAME", ""), os.environ.get("GITHUB_REF", ""))
|
||||
except (KeyError, ValueError) as error:
|
||||
errors = [str(error)]
|
||||
elif sys.argv[1:] == ["--check-workflow"]:
|
||||
errors = check_workflow(ROOT)
|
||||
else:
|
||||
print("usage: ci_gate.py {select|verify|--check-workflow|--self-test}", file=sys.stderr)
|
||||
return 2
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
if not errors:
|
||||
print("CI contract passed")
|
||||
return bool(errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -25,7 +25,7 @@
|
||||
10|crates/ecstore/src/cluster/rpc/remote_disk.rs
|
||||
6|crates/ecstore/src/config/com.rs
|
||||
14|crates/ecstore/src/config/storageclass.rs
|
||||
180|crates/ecstore/src/core/pools.rs
|
||||
178|crates/ecstore/src/core/pools.rs
|
||||
7|crates/ecstore/src/data_movement/mod.rs
|
||||
2|crates/ecstore/src/data_usage/local_snapshot.rs
|
||||
12|crates/ecstore/src/data_usage/mod.rs
|
||||
|
||||
@@ -837,6 +837,55 @@ emit_step_result() {
|
||||
self.assertIn(value, 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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user