mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 05:36:24 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab7d3f9a7d | |||
| c123ae2123 |
@@ -1,2 +1,2 @@
|
||||
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
||||
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
|
||||
sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
|
||||
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
|
||||
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
|
||||
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
|
||||
sha256-darwin=364f2329a7b72eb9f1608dbe1a3af37af4095354014f3cbe23ca448492d89961
|
||||
sha256-linux=60983f1ebe7068cf660d473c5f76c76a650410ccc99d71934ddca7fd67607987
|
||||
|
||||
@@ -89,7 +89,6 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||
@echo "🧪 Checking test wiring..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
|
||||
|
||||
.PHONY: log-analyzer-rules-check
|
||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||
|
||||
@@ -32,8 +32,6 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_hotpath_warp_ab_gate.sh
|
||||
./scripts/test_hotpath_warp_abba.sh
|
||||
./scripts/test_scanner_validation_harness.sh
|
||||
./scripts/test_scanner_heal_w13_mrf_evidence.sh
|
||||
./scripts/test_scanner_heal_w16_recovery_evidence.sh
|
||||
./scripts/test_exact_1mib_handoff_abba.sh
|
||||
./scripts/test_pinned_paired_abba_bench.sh
|
||||
./scripts/test_manual_transition_runbooks.sh
|
||||
@@ -41,7 +39,6 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||
|
||||
@@ -318,9 +318,6 @@
|
||||
"description": "Measured MRF scale and replay cost with retained responsibility",
|
||||
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
|
||||
"evidence_fields": [
|
||||
"mrf_scale_measurement",
|
||||
"mrf_replay_cost_measurement",
|
||||
"retained_responsibility_evidence",
|
||||
"mrf_cleanup_gc_soak_evidence"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -111,6 +111,10 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
shell: bash
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
shell: bash
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Reports the existing required checks for paths excluded by ci.yml.
|
||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||
|
||||
name: Continuous Integration (docs only)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened ]
|
||||
branches: [ main, release ]
|
||||
paths:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "deploy/**"
|
||||
- "scripts/dev_*.sh"
|
||||
- "scripts/probe.sh"
|
||||
- "LICENSE*"
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
- "README*"
|
||||
- "**/*.png"
|
||||
- "**/*.jpg"
|
||||
- "**/*.svg"
|
||||
- ".github/workflows/build.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
- ".github/workflows/audit.yml"
|
||||
- "flake.lock"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
||||
# planning-type document could be slipped in (git add -f bypasses
|
||||
# .gitignore). Run the guard here so the required "Test and Lint" check
|
||||
# stays meaningful for docs-only changes.
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Satisfy required check for docs-only changes
|
||||
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
|
||||
+74
-77
@@ -37,6 +37,25 @@ on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
branches: [ main, release ]
|
||||
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
|
||||
# reports the required "Test and Lint" check for PRs skipped here.
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "deploy/**"
|
||||
- "scripts/dev_*.sh"
|
||||
- "scripts/probe.sh"
|
||||
- "LICENSE*"
|
||||
- ".gitignore"
|
||||
- ".dockerignore"
|
||||
- "README*"
|
||||
- "**/*.png"
|
||||
- "**/*.jpg"
|
||||
- "**/*.svg"
|
||||
- ".github/workflows/build.yml"
|
||||
- ".github/workflows/docker.yml"
|
||||
- ".github/workflows/audit.yml"
|
||||
- "flake.lock"
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
schedule:
|
||||
@@ -69,32 +88,6 @@ jobs:
|
||||
- name: Explain cancellation run
|
||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||
|
||||
classify-changes:
|
||||
name: Select CI scope
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
mode: ${{ steps.scope.outputs.mode }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Select scope using the base revision's policy
|
||||
id: scope
|
||||
env:
|
||||
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
|
||||
else
|
||||
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||
echo "Base CI policy unavailable; running the full matrix."
|
||||
fi
|
||||
|
||||
typos:
|
||||
name: Typos
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -107,7 +100,7 @@ jobs:
|
||||
- name: Typos check with custom config file
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||
|
||||
# Fail early with compile-free checks for every pull request.
|
||||
# Fail early with compile-free checks shared with docs-only CI.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -123,9 +116,9 @@ jobs:
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Workspace Test and Lint
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
name: Test and Lint
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -296,6 +289,45 @@ jobs:
|
||||
- name: Run rebalance/decommission migration proofs
|
||||
run: ./scripts/check_migration_gate_count.sh
|
||||
|
||||
# Record the reason before this job completes as FAILURE. A separate
|
||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
||||
# required check's failure verdict.
|
||||
- name: Annotate early-stop reason
|
||||
if: >-
|
||||
failure() && github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
{
|
||||
echo "## CI early-stop"
|
||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
||||
cancel-after-test-and-lint-failure:
|
||||
name: Cancel siblings after Test and Lint failure
|
||||
if: >-
|
||||
failure() && needs.test-and-lint.result == 'failure'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: [ test-and-lint ]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Cancel remaining jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
||||
|
||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||
@@ -308,8 +340,8 @@ jobs:
|
||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||
test-ilm-integration-serial:
|
||||
name: ILM Integration (serial)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -376,8 +408,8 @@ jobs:
|
||||
|
||||
test-and-lint-rio-v2:
|
||||
name: Test and Lint (rio-v2)
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -417,8 +449,8 @@ jobs:
|
||||
|
||||
connect-short-credential-boundary:
|
||||
name: Connect Short Credential Boundary
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -475,8 +507,8 @@ jobs:
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
@@ -529,8 +561,8 @@ jobs:
|
||||
|
||||
build-rustfs-debug-binary:
|
||||
name: Build RustFS Debug Binary
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
@@ -652,8 +684,8 @@ jobs:
|
||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||
@@ -1181,44 +1213,9 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
required-checks:
|
||||
name: Test and Lint
|
||||
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs:
|
||||
- classify-changes
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
- test-ilm-integration-serial
|
||||
- test-and-lint-rio-v2
|
||||
- connect-short-credential-boundary
|
||||
- test-and-lint-protocols
|
||||
- build-rustfs-debug-binary
|
||||
- uring-integration
|
||||
- e2e-tests
|
||||
- s3-implemented-tests
|
||||
- s3-lifecycle-behavior-tests
|
||||
- build-rustfs-debug-binary-rio-v2
|
||||
- e2e-tests-rio-v2
|
||||
- e2e-full
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Require the expected result of every CI lane
|
||||
env:
|
||||
CI_NEEDS: ${{ toJSON(needs) }}
|
||||
shell: bash
|
||||
run: python3 scripts/ci_gate.py verify
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs:
|
||||
- classify-changes
|
||||
- connect-short-credential-boundary
|
||||
- required-checks
|
||||
- typos
|
||||
- quick-checks
|
||||
- test-and-lint
|
||||
|
||||
Generated
-1
@@ -9928,7 +9928,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc-fast",
|
||||
"futures",
|
||||
"hotpath",
|
||||
|
||||
@@ -593,20 +593,6 @@ pub struct DataUsageSnapshotIdentity {
|
||||
pub scanner_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DataUsageSegmentInvalidationProof {
|
||||
#[serde(default)]
|
||||
pub process_epoch: String,
|
||||
#[serde(default)]
|
||||
pub generation_start: u64,
|
||||
#[serde(default)]
|
||||
pub generation_end: u64,
|
||||
#[serde(default)]
|
||||
pub producer_identity_coverage_complete: bool,
|
||||
#[serde(default)]
|
||||
pub cold_zero_walk_oracle: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DataUsageSnapshotSetState {
|
||||
pub pool_index: u64,
|
||||
@@ -621,8 +607,6 @@ pub struct DataUsageSnapshotSetState {
|
||||
pub complete: bool,
|
||||
#[serde(default)]
|
||||
pub tombstone: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
|
||||
}
|
||||
|
||||
impl DataUsageInfo {
|
||||
@@ -3089,7 +3073,6 @@ mod tests {
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: false,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}];
|
||||
assert!(observed_data_usage_is_newer(&partial, &authoritative));
|
||||
}
|
||||
@@ -3112,7 +3095,6 @@ mod tests {
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
},
|
||||
DataUsageSnapshotSetState {
|
||||
pool_index: 1,
|
||||
@@ -3122,7 +3104,6 @@ mod tests {
|
||||
scan_plan_digest: Some([2; 32]),
|
||||
complete: false,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
@@ -3132,42 +3113,6 @@ mod tests {
|
||||
assert!(partial.is_valid_partial_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_state_segment_invalidation_proof_is_additive() {
|
||||
#[derive(Deserialize)]
|
||||
struct LegacySetState {
|
||||
pool_index: u64,
|
||||
set_index: u64,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
let proof = DataUsageSegmentInvalidationProof {
|
||||
process_epoch: "scanner-process".to_string(),
|
||||
generation_start: 3,
|
||||
generation_end: 5,
|
||||
producer_identity_coverage_complete: true,
|
||||
cold_zero_walk_oracle: true,
|
||||
};
|
||||
let state = DataUsageSnapshotSetState {
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
scanner_cycle: Some(9),
|
||||
scanner_epoch: Some(4),
|
||||
scan_plan_digest: Some([7; 32]),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: Some(proof.clone()),
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
|
||||
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
|
||||
assert_eq!(legacy.pool_index, 1);
|
||||
assert_eq!(legacy.set_index, 2);
|
||||
assert!(legacy.complete);
|
||||
|
||||
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
|
||||
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completeness_marker_requires_a_snapshot_timestamp() {
|
||||
let untimestamped = DataUsageInfo {
|
||||
|
||||
@@ -268,7 +268,6 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
|
||||
let rule = CorsRule::builder()
|
||||
.allowed_methods("GET")
|
||||
.allowed_origins("https://example.com")
|
||||
.allowed_headers("*")
|
||||
.build()?;
|
||||
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
||||
|
||||
@@ -289,60 +288,6 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
|
||||
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
||||
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
||||
|
||||
let http = reqwest::Client::builder().no_proxy().build()?;
|
||||
let url = format!("http://{}/{}", cluster.nodes[1].address, BUCKET_METADATA_RELOAD_BUCKET);
|
||||
let without_headers = http
|
||||
.request(reqwest::Method::OPTIONS, &url)
|
||||
.header("Origin", "https://example.com")
|
||||
.header("Access-Control-Request-Method", "GET")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(without_headers.status().is_success());
|
||||
assert!(!without_headers.headers().contains_key("access-control-allow-headers"));
|
||||
assert!(
|
||||
without_headers
|
||||
.headers()
|
||||
.get("vary")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
|
||||
"a cached header-free preflight must not suppress a later requested header grant"
|
||||
);
|
||||
let preflight = http
|
||||
.request(reqwest::Method::OPTIONS, &url)
|
||||
.header("Origin", "https://example.com")
|
||||
.header("Access-Control-Request-Method", "GET")
|
||||
.header("Access-Control-Request-Headers", "X-Another-Header, x-could-be-anything")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(preflight.status().is_success(), "peer preflight should succeed: {preflight:?}");
|
||||
assert_eq!(
|
||||
preflight
|
||||
.headers()
|
||||
.get("access-control-allow-headers")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("x-another-header,x-could-be-anything"),
|
||||
"a wildcard rule must return only the headers requested by this preflight"
|
||||
);
|
||||
assert!(
|
||||
preflight
|
||||
.headers()
|
||||
.get("vary")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
|
||||
"preflight caches must distinguish the requested header list"
|
||||
);
|
||||
let denied = http
|
||||
.request(reqwest::Method::OPTIONS, &url)
|
||||
.header("Origin", "https://disallowed.example.com")
|
||||
.header("Access-Control-Request-Method", "GET")
|
||||
.header("Access-Control-Request-Headers", "x-another-header")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
!denied.headers().contains_key("access-control-allow-headers"),
|
||||
"a rejected origin must not receive the requested header grant"
|
||||
);
|
||||
|
||||
writer
|
||||
.delete_bucket_cors()
|
||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||
|
||||
@@ -962,6 +962,27 @@ pub(crate) async fn wait_for_rebalance_active(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_rebalance_running_with_progress(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
expected_id: &str,
|
||||
timeout: Duration,
|
||||
) -> TestResult {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let status = rebalance_status_json(cluster).await?;
|
||||
if rebalance_running_with_progress(&status, expected_id)? {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_rebalance_complete(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
expected_id: &str,
|
||||
|
||||
@@ -26,7 +26,6 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
const EC84_NODE_COUNT: usize = 3;
|
||||
const EC84_DRIVES_PER_NODE: usize = 4;
|
||||
@@ -34,8 +33,6 @@ const EC84_DATA_BLOCKS: usize = 8;
|
||||
const EC84_PARITY_BLOCKS: usize = 4;
|
||||
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
|
||||
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
|
||||
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const EC84_HEAL_CONTROL_RETRY_DELAY: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExpectedShard {
|
||||
@@ -214,29 +211,6 @@ fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> T
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
|
||||
let message = error.to_string();
|
||||
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
|
||||
}
|
||||
|
||||
async fn start_ec84_root_heal_when_control_ready(
|
||||
heal_url: &str,
|
||||
heal_body: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> TestResult {
|
||||
let deadline = Instant::now() + EC84_HEAL_CONTROL_READY_TIMEOUT;
|
||||
loop {
|
||||
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && Instant::now() < deadline => {
|
||||
sleep(EC84_HEAL_CONTROL_RETRY_DELAY).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
|
||||
let mut expected = Vec::new();
|
||||
for index in 0..4 {
|
||||
@@ -330,7 +304,7 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
||||
let heal_body =
|
||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
|
||||
start_ec84_root_heal_when_control_ready(&heal_url, heal_body, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||
|
||||
wait_until(
|
||||
Duration::from_secs(120),
|
||||
@@ -391,23 +365,3 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cluster_heal_coordination_retry_is_exact() {
|
||||
let retryable: Box<dyn std::error::Error + Send + Sync> =
|
||||
"admin POST failed: 500 Internal Server Error cluster heal coordination unavailable".into();
|
||||
assert!(is_cluster_heal_coordination_unavailable(retryable.as_ref()));
|
||||
|
||||
let other_internal: Box<dyn std::error::Error + Send + Sync> =
|
||||
"admin POST failed: 500 Internal Server Error unrelated".into();
|
||||
assert!(!is_cluster_heal_coordination_unavailable(other_internal.as_ref()));
|
||||
|
||||
let wrong_status: Box<dyn std::error::Error + Send + Sync> =
|
||||
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
|
||||
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||
decommission_status_json, put_inventory_retrying, rebalance_active, rebalance_status_json, retrying_get_equals, retrying_put,
|
||||
start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||
wait_for_decommission_running_with_progress, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
|
||||
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use std::time::Duration;
|
||||
@@ -67,10 +67,7 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
|
||||
assert_inventory(&live, &bucket, &inventory).await?;
|
||||
|
||||
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||
// The status API reads persisted progress, whose first periodic save is
|
||||
// after 30 seconds. A shorter run can remain at zero until completion.
|
||||
// Require Started around the S3 operations and nonzero progress at completion.
|
||||
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||
retrying_put(
|
||||
&live,
|
||||
&bucket,
|
||||
@@ -87,26 +84,11 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await?;
|
||||
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
|
||||
assert!(
|
||||
listed
|
||||
.contents()
|
||||
.iter()
|
||||
.any(|object| object.key() == Some("during-rebalance.bin")),
|
||||
"list during rebalance missed the newly written key"
|
||||
);
|
||||
let status = rebalance_status_json(&dist.cluster).await?;
|
||||
if !rebalance_active(&status, &rebalance_id)? {
|
||||
if !rebalance_running_with_progress(&status, &rebalance_id)? {
|
||||
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
||||
}
|
||||
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||
let after = dist.client(1)?;
|
||||
assert_inventory(&after, &bucket, &inventory).await?;
|
||||
for (key, body) in [
|
||||
("during-decommission.bin", b"written-while-decommissioning".as_slice()),
|
||||
("during-rebalance.bin", b"written-while-rebalancing".as_slice()),
|
||||
] {
|
||||
retrying_get_equals(&after, &bucket, key, body, Duration::from_secs(30)).await?;
|
||||
}
|
||||
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1569,9 +1569,7 @@ mod tests {
|
||||
} else {
|
||||
if matches!(
|
||||
scenario,
|
||||
InterruptionScenario::BackgroundTargetRestart
|
||||
| InterruptionScenario::BackgroundTargetRestartEc84
|
||||
| InterruptionScenario::BackgroundCoordinatorRestart
|
||||
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
|
||||
) {
|
||||
cluster.stop_node_gracefully(interruption_node).await?;
|
||||
} else {
|
||||
@@ -1769,22 +1767,33 @@ mod tests {
|
||||
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
|
||||
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
|
||||
if task_status["summary"].as_str() != Some("finished") {
|
||||
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
||||
}
|
||||
if interruption_node == 0 {
|
||||
// Restart recovery must finish the original durable root request.
|
||||
// Admin tasks are process-local. Physical and queue convergence
|
||||
// above establish recovery; a lost task must not report success.
|
||||
assert_eq!(
|
||||
task_status["summary"].as_str(),
|
||||
Some("notFound"),
|
||||
"interrupted task status: {task_status}"
|
||||
);
|
||||
assert_eq!(
|
||||
task_status["detail"].as_str(),
|
||||
Some("heal task not found or expired"),
|
||||
"interrupted admin task must be explicitly unavailable: {task_status}"
|
||||
);
|
||||
info!(
|
||||
event = "heal_interruption_recovered",
|
||||
component = "e2e_test",
|
||||
subsystem = "heal",
|
||||
interruption_node,
|
||||
interruption_kind,
|
||||
task_state = "finished",
|
||||
"Original root heal completed after coordinator restart"
|
||||
task_state = "not_found",
|
||||
"Physical recovery completed after coordinator restart"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if task_status["summary"].as_str() != Some("finished") {
|
||||
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
||||
}
|
||||
|
||||
if let Some(evidence_context) = evidence_run {
|
||||
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
||||
|
||||
@@ -109,10 +109,10 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
||||
.await;
|
||||
|
||||
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
||||
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||
assert_eq!(
|
||||
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("ServiceUnavailable")
|
||||
Some("InternalError")
|
||||
);
|
||||
let unavailable_absence = s3_client
|
||||
.get_object()
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! Regression coverage for anonymous access on multipart control APIs.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use crate::kms::common::LocalKMSTestEnvironment;
|
||||
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
||||
@@ -1466,10 +1465,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
|
||||
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
kms_env.wait_for_kms_ready().await?;
|
||||
let env = &kms_env.base_env;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let master_key = local_sse_master_key_value();
|
||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
let bucket = "anon-post-default-sse-kms";
|
||||
let object_key = "post-default-sse-kms-object.txt";
|
||||
@@ -1485,7 +1484,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
|
||||
.apply_server_side_encryption_by_default(
|
||||
ServerSideEncryptionByDefault::builder()
|
||||
.sse_algorithm(ServerSideEncryption::AwsKms)
|
||||
.kms_master_key_id(default_key_id)
|
||||
.kms_master_key_id("test-key")
|
||||
.build()
|
||||
.expect("default encryption rule should build"),
|
||||
)
|
||||
|
||||
@@ -1137,12 +1137,7 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
|
||||
let miss = env.raw_get(bucket, miss_key).await?;
|
||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||
}
|
||||
let (listed, _, _) = tokio::try_join!(
|
||||
env.wait_local_listed(bucket, hit_key, SETTLE),
|
||||
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
|
||||
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
|
||||
)?;
|
||||
assert!(listed);
|
||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
||||
|
||||
let status = env.status_json(bucket).await?;
|
||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||
|
||||
@@ -2606,24 +2606,6 @@ where
|
||||
usize::try_from(size).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_decommission_set_local_usage_cache(bucket: &str, object: &str) -> bool {
|
||||
if bucket != RUSTFS_META_BUCKET {
|
||||
return false;
|
||||
}
|
||||
let Some(path) = object
|
||||
.strip_prefix(BUCKET_META_PREFIX)
|
||||
.and_then(|path| path.strip_prefix('/'))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let name = match path.rsplit_once('/') {
|
||||
Some((bucket, name)) if !bucket.is_empty() && !bucket.contains('/') && bucket != "." && bucket != ".." => name,
|
||||
Some(_) => return false,
|
||||
None => path,
|
||||
};
|
||||
name.strip_suffix(".bkp").unwrap_or(name) == DATA_USAGE_CACHE_NAME
|
||||
}
|
||||
|
||||
fn with_decommission_entry_context<E: Display>(stage: &str, bucket: &str, object: &str, err: E) -> Error {
|
||||
Error::other(format!("decommission entry {stage} failed for bucket {bucket} object {object}: {err}"))
|
||||
}
|
||||
@@ -3481,11 +3463,6 @@ impl PoolRebalanceActivationFence {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
|
||||
pool: Arc<S>,
|
||||
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
||||
@@ -3496,21 +3473,17 @@ where
|
||||
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
|
||||
>,
|
||||
{
|
||||
// Match entry admission: rebalance.bin -> pool.bin. An entry retains its
|
||||
// run read fence while target mutations acquire the pool metadata fence;
|
||||
// activation must not hold pool.bin while waiting for that entry to drain.
|
||||
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
||||
#[cfg(test)]
|
||||
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
|
||||
let rebalance_meta_guard = rebalance_meta_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(activation_rebalance_meta_lock_error)?;
|
||||
// Activation lock order is always pool.bin -> rebalance.bin.
|
||||
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
|
||||
let pool_meta_guard = pool_meta_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(activation_pool_meta_lock_error)?;
|
||||
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
|
||||
let rebalance_meta_guard = rebalance_meta_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(activation_rebalance_meta_lock_error)?;
|
||||
|
||||
Ok(PoolRebalanceActivationFence {
|
||||
pool_meta_guard,
|
||||
@@ -13312,12 +13285,6 @@ impl ECStore {
|
||||
);
|
||||
return Ok(DecommissionEntryAttemptOutcome::Complete);
|
||||
}
|
||||
// Scanner caches describe their own erasure set and are rebuilt there.
|
||||
// Copying one onto another set can overwrite unrelated cache contents or
|
||||
// leave an unresolvable target-capacity intent after a conditional PUT.
|
||||
if is_decommission_set_local_usage_cache(&bucket, &entry.name) {
|
||||
return Ok(DecommissionEntryAttemptOutcome::Complete);
|
||||
}
|
||||
let durable_ilm_record = if bucket == RUSTFS_META_BUCKET {
|
||||
classify_durable_ilm_record(&entry.name)
|
||||
.map_err(|err| with_decommission_entry_context("durable_ilm_namespace", &bucket, &entry.name, err))?
|
||||
@@ -13875,7 +13842,7 @@ impl ECStore {
|
||||
|
||||
let bucket = bucket.clone();
|
||||
|
||||
let read_result = set
|
||||
let rd = match set
|
||||
.get_object_reader(
|
||||
bucket.as_str(),
|
||||
&encode_dir_object(&version.name),
|
||||
@@ -13883,11 +13850,8 @@ impl ECStore {
|
||||
HeaderMap::new(),
|
||||
&decommission_object_migration_read_opts(version_id.clone()),
|
||||
)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
let read_result =
|
||||
decommission_test_wrap_result("object_read", &bucket, &version.name, version_attempt, read_result);
|
||||
let rd = match read_result {
|
||||
.await
|
||||
{
|
||||
Ok(rd) => rd,
|
||||
Err(err) => {
|
||||
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
||||
@@ -13896,6 +13860,15 @@ impl ECStore {
|
||||
break;
|
||||
}
|
||||
|
||||
if !ignore {
|
||||
//
|
||||
if bucket == RUSTFS_META_BUCKET && version.name.contains(DATA_USAGE_CACHE_NAME) {
|
||||
ignore = true;
|
||||
error!("decommission_pool: ignore data usage cache {}", &version.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
failure = true;
|
||||
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
|
||||
error!(
|
||||
@@ -16863,7 +16836,7 @@ impl ECStore {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_decommission_set_local_usage_cache(&bucket_name, &entry.name) {
|
||||
if bucket_name == RUSTFS_META_BUCKET && entry.name.contains(DATA_USAGE_CACHE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17181,361 +17154,6 @@ mod tests {
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use serde::Serialize;
|
||||
|
||||
#[test]
|
||||
fn decommission_set_local_usage_cache_classification_is_exact() {
|
||||
for object in [
|
||||
"buckets/.usage-cache.bin",
|
||||
"buckets/.usage-cache.bin.bkp",
|
||||
"buckets/photos/.usage-cache.bin",
|
||||
"buckets/photos/.usage-cache.bin.bkp",
|
||||
] {
|
||||
assert!(is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
|
||||
assert!(!is_decommission_set_local_usage_cache("user-bucket", object), "{object}");
|
||||
}
|
||||
for object in [
|
||||
"buckets/.usage.v2.json",
|
||||
"buckets/.usage.v2.json.bkp",
|
||||
"buckets/.usage-cache.bin.extra",
|
||||
"buckets/.usage-cache.bin.bkp.extra",
|
||||
"buckets/prefix.usage-cache.bin",
|
||||
"buckets/photos/.usage-cache.bin.bkp.bkp",
|
||||
"buckets/photos/nested/.usage-cache.bin",
|
||||
"buckets//.usage-cache.bin",
|
||||
"buckets/../.usage-cache.bin",
|
||||
"buckets/./.usage-cache.bin",
|
||||
"buckets/.usage-cache.bin/child",
|
||||
"config/.usage-cache.bin",
|
||||
"buckets-other/.usage-cache.bin",
|
||||
".usage-cache.bin",
|
||||
] {
|
||||
assert!(!is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn decommission_keeps_set_local_usage_caches_out_of_target_capacity() {
|
||||
use crate::object_api::PutObjReader;
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
// Keep the scenario's large setup and migration futures off the test
|
||||
// future so ordinary metadata I/O retains the default thread stack.
|
||||
let (_temp_dirs, store, _other_store) =
|
||||
Box::pin(crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None)).await;
|
||||
let user_bucket = "decommission-usage-cache-control";
|
||||
Box::pin(store.make_bucket(user_bucket, &MakeBucketOptions::default()))
|
||||
.await
|
||||
.expect("create the ordinary-object control bucket");
|
||||
let incarnation = Box::pin(store.bucket_incarnation_id(user_bucket))
|
||||
.await
|
||||
.expect("control bucket incarnation");
|
||||
let source_time = OffsetDateTime::now_utc();
|
||||
let cache_objects = [
|
||||
"buckets/.usage-cache.bin",
|
||||
"buckets/.usage-cache.bin.bkp",
|
||||
"buckets/photos/.usage-cache.bin",
|
||||
"buckets/photos/.usage-cache.bin.bkp",
|
||||
];
|
||||
let conflict_object = "buckets/.usage-cache.bin.conflict";
|
||||
let source_read_failure_object = "buckets/.usage-cache.bin.read-error";
|
||||
let source_body = b"source set cache";
|
||||
let target_body = b"independent older target set cache";
|
||||
for object in cache_objects.into_iter().chain([conflict_object]) {
|
||||
for (pool_index, body, mod_time) in [
|
||||
(0, source_body.as_slice(), source_time),
|
||||
(1, target_body.as_slice(), source_time - Duration::seconds(1)),
|
||||
] {
|
||||
// Scanner cache persistence writes directly to its own set.
|
||||
store.pools[pool_index]
|
||||
.get_disks_by_key(object)
|
||||
.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(body.to_vec()),
|
||||
&ObjectOptions {
|
||||
mod_time: Some(mod_time),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed distinct native set-local objects");
|
||||
}
|
||||
}
|
||||
let controls = [
|
||||
(RUSTFS_META_BUCKET, "buckets/.usage.v2.json"),
|
||||
(RUSTFS_META_BUCKET, "buckets/photos/.usage-cache.bin.extra"),
|
||||
(user_bucket, "ordinary-object"),
|
||||
(user_bucket, "buckets/.usage-cache.bin"),
|
||||
];
|
||||
for (bucket, object) in controls {
|
||||
store.pools[0]
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"ordinary object contents".to_vec()),
|
||||
&ObjectOptions {
|
||||
expected_bucket_incarnation_id: (bucket == user_bucket).then_some(incarnation),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed a control that must migrate");
|
||||
}
|
||||
store.pools[0]
|
||||
.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
source_read_failure_object,
|
||||
&mut PutObjReader::from_vec(source_body.to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("seed a similarly named object whose source read will fail");
|
||||
|
||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||
set_decommission_capacity_info_overrides_for_test(
|
||||
store.id,
|
||||
vec![vec![
|
||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
|
||||
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
|
||||
]],
|
||||
);
|
||||
Box::pin(store.save_current_pool_meta_for_decommission_start(&[0], Vec::new()))
|
||||
.await
|
||||
.expect("activate the decommission capacity reservation");
|
||||
|
||||
for object in cache_objects {
|
||||
Box::pin(store.decommission_entry_for_test(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
RUSTFS_META_BUCKET.to_string(),
|
||||
store.pools[0].get_disks_by_key(object),
|
||||
))
|
||||
.await
|
||||
.expect("set-local cache must not enter cross-pool migration");
|
||||
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
|
||||
let mut reader = store.pools[pool_index]
|
||||
.get_disks_by_key(object)
|
||||
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("each set must retain its own cache");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read retained cache");
|
||||
assert_eq!(actual, expected, "pool {pool_index}, {object}");
|
||||
}
|
||||
let meta = store.pool_meta.read().await;
|
||||
let info = meta.pools[0].decommission.as_ref().expect("decommission progress");
|
||||
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 0));
|
||||
assert_eq!((info.bytes_done, info.bytes_failed), (0, 0));
|
||||
let reservation = info.capacity_reservation.as_ref().expect("capacity reservation");
|
||||
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
||||
assert_eq!(reservation.consumed_target_physical_bytes, 0);
|
||||
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
|
||||
}
|
||||
let mut persisted = PoolMeta::default();
|
||||
Box::pin(persisted.load_no_lock_from_replicas(store.pools.clone()))
|
||||
.await
|
||||
.expect("reload durable capacity intents after cache entries");
|
||||
let reservation = persisted.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.and_then(|info| info.capacity_reservation.as_ref())
|
||||
.expect("durable reservation");
|
||||
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
||||
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
|
||||
|
||||
let injected_reads = Arc::new(AtomicUsize::new(0));
|
||||
let observed_reads = Arc::clone(&injected_reads);
|
||||
let read_fault = DecommissionTestFaultGuard::install(Arc::new(move |stage, bucket, object, _, success| {
|
||||
if stage == "object_read" && bucket == RUSTFS_META_BUCKET && object == source_read_failure_object && success {
|
||||
observed_reads.fetch_add(1, Ordering::SeqCst);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}));
|
||||
Box::pin(store.decommission_entry_for_test(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: source_read_failure_object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
RUSTFS_META_BUCKET.to_string(),
|
||||
store.pools[0].get_disks_by_key(source_read_failure_object),
|
||||
))
|
||||
.await
|
||||
.expect("entry must record the non-NotFound source read failure");
|
||||
drop(read_fault);
|
||||
assert_eq!(injected_reads.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
|
||||
{
|
||||
let meta = store.pool_meta.read().await;
|
||||
let info = meta.pools[0].decommission.as_ref().expect("source read failure progress");
|
||||
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 1));
|
||||
assert_eq!(info.bytes_failed, source_body.len());
|
||||
assert_eq!(
|
||||
info.capacity_reservation
|
||||
.as_ref()
|
||||
.expect("reservation")
|
||||
.pending_target_physical_bytes,
|
||||
0
|
||||
);
|
||||
}
|
||||
let mut retained = store.pools[0]
|
||||
.get_object_reader(
|
||||
RUSTFS_META_BUCKET,
|
||||
source_read_failure_object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("source read failure must retain the source");
|
||||
let mut retained_body = Vec::new();
|
||||
retained
|
||||
.stream
|
||||
.read_to_end(&mut retained_body)
|
||||
.await
|
||||
.expect("read retained source");
|
||||
assert_eq!(retained_body, source_body);
|
||||
drop(retained);
|
||||
let target_err = store.pools[1]
|
||||
.get_object_info(RUSTFS_META_BUCKET, source_read_failure_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("failed source read must not create a target object");
|
||||
assert!(is_err_object_not_found(&target_err), "unexpected target state: {target_err:?}");
|
||||
|
||||
for (bucket, object) in controls {
|
||||
Box::pin(store.decommission_entry_for_test(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
bucket.to_string(),
|
||||
store.pools[0].get_disks_by_key(object),
|
||||
))
|
||||
.await
|
||||
.expect("ordinary and similarly named objects must migrate");
|
||||
let mut reader = store.pools[1]
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("control must exist on the target");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read migrated control");
|
||||
assert_eq!(actual, b"ordinary object contents", "{bucket}/{object}");
|
||||
let err = store.pools[0]
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("migrated control must be removed from the source");
|
||||
assert!(is_err_object_not_found(&err), "{bucket}/{object}: {err:?}");
|
||||
}
|
||||
|
||||
Box::pin(store.decommission_entry_for_test(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: conflict_object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
RUSTFS_META_BUCKET.to_string(),
|
||||
store.pools[0].get_disks_by_key(conflict_object),
|
||||
))
|
||||
.await
|
||||
.expect("entry must record a real conditional-copy failure");
|
||||
let meta = store.pool_meta.read().await;
|
||||
let info = meta.pools[0].decommission.as_ref().expect("final progress");
|
||||
assert_eq!(info.items_decommissioned, controls.len());
|
||||
assert_eq!(
|
||||
info.items_decommission_failed, 2,
|
||||
"similar names must not hide read or migration failures"
|
||||
);
|
||||
assert_eq!(info.bytes_failed, source_body.len() * 2);
|
||||
drop(meta);
|
||||
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
|
||||
let mut reader = store.pools[pool_index]
|
||||
.get_object_reader(RUSTFS_META_BUCKET, conflict_object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("failed migration must preserve both objects");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read conflict object");
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn decommission_final_sweep_excludes_only_set_local_usage_caches() {
|
||||
use crate::object_api::PutObjReader;
|
||||
|
||||
let (_temp_dirs, store, _other_store) =
|
||||
crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None).await;
|
||||
for object in [
|
||||
"buckets/.usage-cache.bin",
|
||||
"buckets/.usage-cache.bin.bkp",
|
||||
"buckets/photos/.usage-cache.bin",
|
||||
"buckets/photos/.usage-cache.bin.bkp",
|
||||
] {
|
||||
store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"set-local cache".to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("seed each supported set-local cache path");
|
||||
}
|
||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||
set_decommission_capacity_info_overrides_for_test(
|
||||
store.id,
|
||||
vec![vec![
|
||||
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
|
||||
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
|
||||
]],
|
||||
);
|
||||
store
|
||||
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||
.await
|
||||
.expect("activate the final-sweep generation");
|
||||
let generation = store.active_decommission_generation(0).await.expect("active generation");
|
||||
store
|
||||
.check_after_decommission(0, &CancellationToken::new(), generation)
|
||||
.await
|
||||
.expect("the four set-local cache forms must not block the final sweep");
|
||||
|
||||
for object in [
|
||||
"buckets/.usage-cache.bin.extra",
|
||||
"buckets/photos/.usage-cache.bin.bkp.extra",
|
||||
"buckets/.usage.v2.json",
|
||||
] {
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
source_set
|
||||
.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"unmigrated ordinary metadata".to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("seed ordinary metadata that must prevent completion");
|
||||
let err = store
|
||||
.check_after_decommission(0, &CancellationToken::new(), generation)
|
||||
.await
|
||||
.expect_err("a remaining similar name or global usage snapshot must block completion");
|
||||
assert!(err.to_string().contains("after decommissioning"), "unexpected final-sweep error: {err:?}");
|
||||
assert!(err.to_string().contains(object), "the final sweep must identify {object}: {err:?}");
|
||||
source_set
|
||||
.delete_object(RUSTFS_META_BUCKET, object, ObjectOptions::default())
|
||||
.await
|
||||
.expect("remove only the ordinary-metadata control before the next sweep");
|
||||
}
|
||||
store
|
||||
.check_after_decommission(0, &CancellationToken::new(), generation)
|
||||
.await
|
||||
.expect("only the four set-local caches remain after removing the controls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_activation_fleet_proof_error_classifier_matches_only_retryable_proof_failures() {
|
||||
assert!(is_pool_activation_fleet_proof_error(&Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED)));
|
||||
@@ -20247,55 +19865,6 @@ mod tests {
|
||||
assert!(!is_decommission_copy_cleanup_safe_error(&wrap(Error::SlowDown)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_target_gate_retry_recognizes_multipart_part_errors() {
|
||||
let wrap = |inner: Error| {
|
||||
data_movement::data_movement_part_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"put_object_part",
|
||||
"bucket-a",
|
||||
"object-a",
|
||||
1,
|
||||
inner,
|
||||
)
|
||||
};
|
||||
let gate_busy_message =
|
||||
format!("{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX}7{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX}");
|
||||
let wrapped = wrap(decommission_capacity_blocked_error(&gate_busy_message));
|
||||
assert!(is_decommission_capacity_target_gate_busy(&wrapped));
|
||||
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), Some(7));
|
||||
assert_eq!(
|
||||
wrapped.to_string(),
|
||||
format!(
|
||||
"Io error: decommission_object: put_object_part failed for bucket-a/object-a part 1: {}",
|
||||
decommission_capacity_blocked_error(&gate_busy_message)
|
||||
)
|
||||
);
|
||||
|
||||
for unrelated in [
|
||||
Error::SlowDown,
|
||||
Error::DiskFull,
|
||||
decommission_capacity_blocked_error("target capacity is exhausted"),
|
||||
Error::other(gate_busy_message),
|
||||
] {
|
||||
let wrapped = wrap(unrelated);
|
||||
assert!(!is_decommission_capacity_target_gate_busy(&wrapped));
|
||||
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), None);
|
||||
}
|
||||
for missing_target in [
|
||||
Error::FileNotFound,
|
||||
Error::ObjectNotFound("bucket-a".to_string(), "object-a".to_string()),
|
||||
Error::VersionNotFound("bucket-a".to_string(), "object-a".to_string(), "version-a".to_string()),
|
||||
] {
|
||||
assert!(is_decommission_copy_cleanup_safe_error(&missing_target));
|
||||
assert!(
|
||||
!is_decommission_copy_cleanup_safe_error(&wrap(missing_target)),
|
||||
"a missing target part must never authorize source cleanup"
|
||||
);
|
||||
}
|
||||
assert!(is_decommission_target_capacity_error(&wrap(Error::DiskFull)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
|
||||
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
|
||||
@@ -22525,7 +22094,7 @@ mod pools_tests {
|
||||
.resources
|
||||
.lock()
|
||||
.expect("activation lock recorder should not be poisoned"),
|
||||
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
|
||||
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
|
||||
);
|
||||
|
||||
let mut second_acquire = Box::pin(acquire_pool_rebalance_activation_locks(second.clone(), None));
|
||||
@@ -22541,50 +22110,10 @@ mod pools_tests {
|
||||
.resources
|
||||
.lock()
|
||||
.expect("activation lock recorder should not be poisoned"),
|
||||
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
|
||||
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_activation_cancellation_releases_rebalance_fence_while_pool_fence_is_contended() {
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
let pool = Arc::new(ActivationLockRecorder {
|
||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||
owner: "activation-cancellation",
|
||||
resources: StdMutex::new(Vec::new()),
|
||||
});
|
||||
let pool_lock = pool
|
||||
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||
.await
|
||||
.expect("pool lock should be created");
|
||||
let pool_reader = pool_lock
|
||||
.get_read_lock(std::time::Duration::from_secs(5))
|
||||
.await
|
||||
.expect("ordinary mutation should hold the pool read fence");
|
||||
pool.resources.lock().expect("recorder should not be poisoned").clear();
|
||||
let mut activation = Box::pin(acquire_pool_rebalance_activation_locks(Arc::clone(&pool), None));
|
||||
assert!(matches!(futures::poll!(&mut activation), Poll::Pending));
|
||||
assert_eq!(
|
||||
*pool.resources.lock().expect("recorder should not be poisoned"),
|
||||
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()],
|
||||
"activation must hold the run fence before waiting for the pool fence",
|
||||
);
|
||||
drop(activation);
|
||||
let rebalance_lock = pool
|
||||
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, REBAL_META_NAME)
|
||||
.await
|
||||
.expect("run lock should be created");
|
||||
let run_writer = rebalance_lock
|
||||
.get_write_lock(std::time::Duration::from_secs(5))
|
||||
.await
|
||||
.expect("cancelling activation must release its already-acquired run fence");
|
||||
assert!(
|
||||
!pool_reader.is_released(),
|
||||
"cancelling activation must not release another caller's pool fence"
|
||||
);
|
||||
assert!(!run_writer.is_lock_lost());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_receipt_run_token_changes_with_persisted_start_time() {
|
||||
let first = OffsetDateTime::from_unix_timestamp(1_000).expect("first run timestamp should be valid");
|
||||
|
||||
@@ -1521,27 +1521,9 @@ fn data_movement_part_stage_error(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
part_number: usize,
|
||||
err: Error,
|
||||
err: impl std::fmt::Display,
|
||||
) -> Error {
|
||||
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
|
||||
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
|
||||
return data_movement_context_error(rendered, err);
|
||||
}
|
||||
// A missing target part is not evidence that the source can be deleted.
|
||||
// Keep other part errors opaque to the source-cleanup classifiers.
|
||||
Error::other(rendered)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn data_movement_part_stage_error_for_test(
|
||||
op_label: &str,
|
||||
stage: &str,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
part_number: usize,
|
||||
err: Error,
|
||||
) -> Error {
|
||||
data_movement_part_stage_error(op_label, stage, bucket, object, part_number, err)
|
||||
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}"))
|
||||
}
|
||||
|
||||
fn is_data_movement_part_read_error(err: &Error) -> bool {
|
||||
@@ -2446,15 +2428,8 @@ mod tests {
|
||||
let err =
|
||||
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
|
||||
let message = err.to_string();
|
||||
assert_eq!(
|
||||
message,
|
||||
Error::other(format!(
|
||||
"rebalance_object: put_object_part failed for bucket-a/object-a part 7: {}",
|
||||
Error::SlowDown
|
||||
))
|
||||
.to_string()
|
||||
);
|
||||
assert!(data_movement_stage_source(&err).is_none());
|
||||
assert!(message.contains("rebalance_object: put_object_part failed for bucket-a/object-a part 7"));
|
||||
assert!(message.contains(Error::SlowDown.to_string().as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3397,7 +3397,6 @@ mod tests {
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: false,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}];
|
||||
partial.buckets_usage.insert(
|
||||
"bucket".to_string(),
|
||||
@@ -3470,7 +3469,6 @@ mod tests {
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -572,7 +572,7 @@ impl ECStore {
|
||||
where
|
||||
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||
{
|
||||
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin.
|
||||
// Lock order: pool_meta_save_gate -> pool.bin -> rebalance.bin.
|
||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
|
||||
// Classify the durable rebalance record while holding both namespace
|
||||
|
||||
@@ -50,11 +50,6 @@ fn ensure_rebalance_entry_active(cancel: &CancellationToken) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
static REBALANCE_ENTRY_RUN_FENCE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RebalanceEntryTarget {
|
||||
bucket: String,
|
||||
@@ -261,15 +256,9 @@ impl ECStore {
|
||||
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
|
||||
|
||||
// Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate.
|
||||
// Target capacity admission can then acquire pool.bin under the run fence.
|
||||
// Stop waits for in-flight entries through cleanup, but not for entries admitted later.
|
||||
ensure_rebalance_entry_active(&cancel)?;
|
||||
let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?;
|
||||
#[cfg(test)]
|
||||
if let Ok((arrived, release)) = REBALANCE_ENTRY_RUN_FENCE_BARRIER.try_with(Clone::clone) {
|
||||
arrived.notify_one();
|
||||
release.notified().await;
|
||||
}
|
||||
let lock_lost_signal = run_guard.lock_lost_signal();
|
||||
#[cfg(test)]
|
||||
let _run_signal_test_fence = lock_lost_signal
|
||||
@@ -1248,130 +1237,6 @@ mod tests {
|
||||
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn real_rebalance_entry_progresses_while_peer_activation_waits_for_run_fence() {
|
||||
const REBALANCE_ID: &str = "rebalance-peer-activation-lock-order";
|
||||
let (_temp_dirs, store, peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(
|
||||
active_rebalance_meta(REBALANCE_ID),
|
||||
))
|
||||
.await;
|
||||
assert!(!Arc::ptr_eq(&store.ctx, &peer.ctx), "node-local movement gates must be independent");
|
||||
{
|
||||
let mut meta = peer.rebalance_meta.write().await;
|
||||
let meta = meta.as_mut().expect("peer should know the durable run");
|
||||
meta.activation_gate = Arc::default();
|
||||
meta.cancel = None;
|
||||
}
|
||||
let bucket = crate::disk::RUSTFS_META_BUCKET;
|
||||
let object = "rebalance-peer-activation-object";
|
||||
let version_id = uuid::Uuid::new_v4();
|
||||
let payload = b"entry must drain before peer activation takes the pool fence".repeat(1024);
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
let target_set = store.pools[1].get_disks_by_key(object);
|
||||
let opts = ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut writer = PutObjReader::from_vec(payload.clone());
|
||||
let source_before = source_set
|
||||
.put_object(bucket, object, &mut writer, &opts)
|
||||
.await
|
||||
.expect("source version should be written");
|
||||
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
|
||||
let arrived = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
// JoinSet aborts both scoped tasks if an assertion or timeout fails.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
let entry_store = Arc::clone(&store);
|
||||
tasks.spawn(
|
||||
REBALANCE_ENTRY_RUN_FENCE_BARRIER.scope((Arc::clone(&arrived), Arc::clone(&release)), async move {
|
||||
entry_store
|
||||
.rebalance_entry(
|
||||
RebalanceEntryTarget {
|
||||
bucket: bucket.to_string(),
|
||||
pool_index: 0,
|
||||
},
|
||||
entry,
|
||||
source_set,
|
||||
Arc::new(RebalanceBucketConfigs::default()),
|
||||
Arc::from(REBALANCE_ID),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
}),
|
||||
);
|
||||
tokio::time::timeout(StdDuration::from_secs(30), arrived.notified())
|
||||
.await
|
||||
.expect("real entry must acquire its persisted run read fence");
|
||||
|
||||
let attempted = Arc::new(tokio::sync::Notify::new());
|
||||
let peer_pool = Arc::clone(&peer.pools[0]);
|
||||
let (activation_done, activation_result) = tokio::sync::oneshot::channel();
|
||||
tasks.spawn(
|
||||
crate::core::pools::REBALANCE_ACTIVATION_LOCK_ATTEMPT.scope(Arc::clone(&attempted), async move {
|
||||
let result = peer.fence_rebalance_worker_activation(peer_pool, REBALANCE_ID).await;
|
||||
let result = result.map(|fence| match fence {
|
||||
super::super::control::RebalanceWorkerActivationFence::Ready(fence) => {
|
||||
fence.ensure_held().expect("peer activation must retain both fences");
|
||||
}
|
||||
super::super::control::RebalanceWorkerActivationFence::NotStartedTerminal => {
|
||||
panic!("the paused entry's run must still require activation");
|
||||
}
|
||||
});
|
||||
activation_done.send(result).expect("activation receiver should remain alive");
|
||||
Ok(RebalanceEntryOutcome::Completed)
|
||||
}),
|
||||
);
|
||||
tokio::time::timeout(StdDuration::from_secs(30), attempted.notified())
|
||||
.await
|
||||
.expect("peer activation must attempt the persisted rebalance write fence");
|
||||
release.notify_one();
|
||||
|
||||
tokio::time::timeout(StdDuration::from_secs(30), async {
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
assert!(matches!(
|
||||
result
|
||||
.expect("scoped task must not panic")
|
||||
.expect("entry must not fail or defer"),
|
||||
RebalanceEntryOutcome::Completed
|
||||
));
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("entry and peer activation must both make progress");
|
||||
activation_result
|
||||
.await
|
||||
.expect("peer activation result should be sent")
|
||||
.expect("peer activation must not time out behind the entry it blocks");
|
||||
|
||||
let mut reader = target_set
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
|
||||
.await
|
||||
.expect("the exact target version must be readable");
|
||||
let mut actual = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut actual)
|
||||
.await
|
||||
.expect("target body should drain completely");
|
||||
assert_eq!(actual, payload);
|
||||
assert_eq!(reader.object_info.version_id, source_before.version_id);
|
||||
assert_eq!(reader.object_info.etag, source_before.etag);
|
||||
assert_eq!(reader.object_info.mod_time, source_before.mod_time);
|
||||
let source_error = store.pools[0]
|
||||
.get_object_info(bucket, object, &opts)
|
||||
.await
|
||||
.expect_err("completed entry must clean up the source version");
|
||||
assert!(crate::error::is_err_object_not_found(&source_error) || crate::error::is_err_version_not_found(&source_error));
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let stats = &meta.as_ref().expect("local run must remain installed").pool_stats[0];
|
||||
assert_eq!(stats.num_objects, 1);
|
||||
assert_eq!(stats.num_versions, 1);
|
||||
assert_eq!(stats.cleanup_warnings.count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() {
|
||||
|
||||
@@ -1907,124 +1907,6 @@ fn test_is_transient_rebalance_error_accepts_wrapped_disk_timeout() {
|
||||
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_stage_wrapped_transient_errors_remain_retryable() {
|
||||
let cases = [
|
||||
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
|
||||
Error::Lock(rustfs_lock::LockError::network(
|
||||
"peer unavailable",
|
||||
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
|
||||
)),
|
||||
Error::SlowDown,
|
||||
Error::ErasureReadQuorum,
|
||||
Error::ErasureWriteQuorum,
|
||||
Error::Io(std::io::Error::other(DiskError::Timeout)),
|
||||
Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)),
|
||||
];
|
||||
for mut error in cases {
|
||||
for depth in 0..=3 {
|
||||
assert!(is_transient_rebalance_error(&error), "transient source lost at depth {depth}: {error:?}");
|
||||
assert!(
|
||||
should_defer_rebalance_entry_failure(&error),
|
||||
"exhausted transient entries must be deferred"
|
||||
);
|
||||
assert!(should_retry_rebalance_listing(&error, 0, 3));
|
||||
assert!(
|
||||
!should_retry_rebalance_listing(&error, 2, 3),
|
||||
"wrapping must not bypass the attempt limit"
|
||||
);
|
||||
error = data_movement::data_movement_stage_error_for_test(
|
||||
"rebalance_object",
|
||||
"put_object",
|
||||
"bucket",
|
||||
"baseline/00042.bin",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_stage_wrapped_terminal_errors_remain_terminal() {
|
||||
let cases = [
|
||||
Error::FileAccessDenied,
|
||||
Error::FileCorrupt,
|
||||
Error::OperationCanceled,
|
||||
Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string()),
|
||||
Error::Lock(rustfs_lock::LockError::already_locked("bucket/object", "owner")),
|
||||
Error::other("permission denied"),
|
||||
];
|
||||
for mut error in cases {
|
||||
for depth in 0..=3 {
|
||||
assert!(
|
||||
!is_transient_rebalance_error(&error),
|
||||
"terminal source must survive depth {depth}: {error:?}"
|
||||
);
|
||||
assert!(!should_defer_rebalance_entry_failure(&error));
|
||||
// Object names are untrusted context, not evidence of a transient failure.
|
||||
error = data_movement::data_movement_stage_error_for_test(
|
||||
"rebalance_object",
|
||||
"put_object",
|
||||
"bucket",
|
||||
"remote lock rpc timed out",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rebalance_stage_wrapped_lock_timeout_retries_real_migration_loop() {
|
||||
for succeeds_on_retry in [true, false] {
|
||||
let backend = MigrationBackendSpy::new(None, None);
|
||||
let attempts = AtomicUsize::new(0);
|
||||
let waits = AtomicUsize::new(0);
|
||||
let mut transfer = |_, _, _| {
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if succeeds_on_retry && attempt > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
Err(data_movement::data_movement_stage_error_for_test(
|
||||
"rebalance_object",
|
||||
"put_object",
|
||||
"bucket",
|
||||
"baseline/00042.bin",
|
||||
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
|
||||
))
|
||||
}
|
||||
};
|
||||
let version = version_normal();
|
||||
let result = migrate_entry_version_with_retry_wait(
|
||||
&backend,
|
||||
"bucket".to_string(),
|
||||
0,
|
||||
&version,
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
|_: String, _: String, _: ObjectOptions| async { Ok::<_, Error>(ObjectInfo::default()) },
|
||||
|_| {
|
||||
waits.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.moved, succeeds_on_retry);
|
||||
assert_eq!(result.failed, !succeeds_on_retry);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), if succeeds_on_retry { 2 } else { 3 });
|
||||
assert_eq!(backend.get_calls(), attempts.load(Ordering::SeqCst));
|
||||
assert_eq!(waits.load(Ordering::SeqCst), attempts.load(Ordering::SeqCst) - 1);
|
||||
if !succeeds_on_retry {
|
||||
assert_eq!(result.stage, Some("write_target"));
|
||||
assert!(should_defer_rebalance_entry_failure(
|
||||
result.error.as_ref().expect("exhaustion must retain its source error")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_rebalance_error_accepts_io_timeout_message() {
|
||||
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout"))));
|
||||
|
||||
@@ -244,7 +244,6 @@ pub(super) fn resolve_rebalance_bucket_result(
|
||||
}
|
||||
|
||||
pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
|
||||
let err = rebalance_error_source(err);
|
||||
match err {
|
||||
Error::SlowDown
|
||||
| Error::ErasureReadQuorum
|
||||
@@ -257,15 +256,6 @@ pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn rebalance_error_source(mut err: &Error) -> &Error {
|
||||
// Stage context contains object names, so classify the preserved source,
|
||||
// not timeout-like text supplied by an object name. Iterate nested stages.
|
||||
while let Some(source) = crate::data_movement::data_movement_stage_source(err) {
|
||||
err = source;
|
||||
}
|
||||
err
|
||||
}
|
||||
|
||||
fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
|
||||
match err {
|
||||
rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true,
|
||||
@@ -319,7 +309,6 @@ pub(super) fn rebalance_listing_retry_delay(attempt: usize) -> Duration {
|
||||
}
|
||||
|
||||
fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
|
||||
let err = rebalance_error_source(err);
|
||||
match err {
|
||||
Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true,
|
||||
Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()),
|
||||
@@ -596,48 +585,3 @@ impl SetDisks {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod error_source_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stage_wrapped_errors_select_the_source_backoff_policy() {
|
||||
let cases = [
|
||||
(
|
||||
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
|
||||
true,
|
||||
),
|
||||
(
|
||||
Error::Lock(rustfs_lock::LockError::network(
|
||||
"peer unavailable",
|
||||
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
|
||||
)),
|
||||
true,
|
||||
),
|
||||
(Error::other("remote lock rpc timed out"), true),
|
||||
(Error::SlowDown, false),
|
||||
(Error::Io(std::io::Error::other(DiskError::Timeout)), false),
|
||||
(Error::FileAccessDenied, false),
|
||||
];
|
||||
for (mut error, lock_backoff) in cases {
|
||||
for depth in 0..=3 {
|
||||
assert_eq!(
|
||||
is_rebalance_lock_or_rpc_timeout(&error),
|
||||
lock_backoff,
|
||||
"wrong backoff at depth {depth}: {error:?}"
|
||||
);
|
||||
if !lock_backoff {
|
||||
assert_eq!(rebalance_migration_retry_delay(1, &error), REBALANCE_MIGRATION_RETRY_BASE_DELAY * 2);
|
||||
}
|
||||
error = crate::data_movement::data_movement_stage_error_for_test(
|
||||
"rebalance_object",
|
||||
"put_object",
|
||||
"bucket",
|
||||
"remote lock rpc timed out",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,16 +164,6 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
|
||||
max_keys
|
||||
}
|
||||
|
||||
fn list_versions_scan_limit(max_keys: i32, has_version_marker: bool) -> i32 {
|
||||
if max_keys <= 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The marker object's versions may all be filtered out after gathering.
|
||||
// Reserve its raw entry in addition to the next-page lookahead entry.
|
||||
max_keys_plus_one(max_keys, true) + i32::from(has_version_marker)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
enum GatherResultsState {
|
||||
LimitReached,
|
||||
@@ -2149,19 +2139,15 @@ fn build_list_versions_next_marker(
|
||||
// here; advertise it as the literal `null` marker so a resumed listing
|
||||
// parses it back to `VersionMarker::Null` instead of a nil UUID that
|
||||
// `find_version_index` can never match (issue #6745).
|
||||
let version_marker = if last.is_dir && last.mod_time.is_none() {
|
||||
// A CommonPrefix has no version to resume; a version marker would
|
||||
// make the next page include this same prefix again.
|
||||
None
|
||||
} else {
|
||||
(
|
||||
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)),
|
||||
Some(
|
||||
last.version_id
|
||||
.filter(|v| !v.is_nil())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "null".to_string()),
|
||||
)
|
||||
};
|
||||
(Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), version_marker)
|
||||
),
|
||||
)
|
||||
} else if let Some(last_prefix) = prefixes.last() {
|
||||
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
|
||||
} else {
|
||||
@@ -2880,20 +2866,6 @@ fn listing_entries_supplement_target(
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(directory) = entries.0.iter().flatten().find(|entry| entry.is_dir()) {
|
||||
let directory_copies = entries
|
||||
.0
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|entry| entry.is_dir() && entry.name == directory.name)
|
||||
.count();
|
||||
// A committed child may have some of its directory copies only on
|
||||
// fallback disks, just like object metadata in a partial primary sample.
|
||||
if directory_copies < resolver.dir_quorum {
|
||||
return Some(directory.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, entry) in entries.0.iter().enumerate() {
|
||||
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
|
||||
continue;
|
||||
@@ -4046,7 +4018,8 @@ impl ECStore {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
||||
// Always request max_keys + 1 to detect if there are more results
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
@@ -5352,7 +5325,7 @@ impl Sets {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
@@ -6061,7 +6034,7 @@ impl SetDisks {
|
||||
|
||||
let has_version_marker = version_marker.is_some();
|
||||
let version_marker = version_marker.map(parse_version_marker).transpose()?;
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
@@ -6275,7 +6248,7 @@ impl SetDisks {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
@@ -7468,153 +7441,6 @@ mod test {
|
||||
assert!(cancel.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_versions_pagination_scan_limit_boundaries() {
|
||||
for has_version_marker in [false, true] {
|
||||
assert_eq!(super::list_versions_scan_limit(-1, has_version_marker), 0);
|
||||
assert_eq!(super::list_versions_scan_limit(0, has_version_marker), 0);
|
||||
let marker_slot = i32::from(has_version_marker);
|
||||
assert_eq!(super::list_versions_scan_limit(1, has_version_marker), 2 + marker_slot);
|
||||
assert_eq!(super::list_versions_scan_limit(MAX_OBJECT_LIST, has_version_marker), 1001 + marker_slot);
|
||||
assert_eq!(super::list_versions_scan_limit(i32::MAX, has_version_marker), 1001 + marker_slot);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_versions_pagination_does_not_require_an_empty_final_page() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "version-pagination-bucket";
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("pagination bucket should be created");
|
||||
let mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
|
||||
for kind in ["objects", "deletes", "null", "mixed", "delimiter"] {
|
||||
let count = if kind == "mixed" { 5 } else { 10 };
|
||||
let mut expected = Vec::new();
|
||||
for index in 0..count {
|
||||
let name = if kind == "delimiter" && index % 2 == 1 {
|
||||
format!("{kind}/testobject-{index:02}/child")
|
||||
} else {
|
||||
format!("{kind}/testobject-{index:02}")
|
||||
};
|
||||
let entry = match kind {
|
||||
"deletes" => test_delete_marker_meta_entry(&name, mod_time),
|
||||
"null" => test_object_meta_entry(&name),
|
||||
"mixed" => test_object_with_delete_marker_meta_entry(&name, mod_time, mod_time + time::Duration::SECOND),
|
||||
_ => test_object_meta_entry_with_erasure_versions(&name, &[(mod_time, "etag", 2, 2)]),
|
||||
};
|
||||
for dir in &dirs {
|
||||
let object_dir = dir.path().join(bucket).join(&name);
|
||||
tokio::fs::create_dir_all(&object_dir)
|
||||
.await
|
||||
.expect("pagination object directory should be created");
|
||||
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), &entry.metadata)
|
||||
.await
|
||||
.expect("pagination metadata should be written");
|
||||
}
|
||||
if kind == "delimiter" && index % 2 == 1 {
|
||||
expected.push((name.trim_end_matches("child").to_owned(), None, false));
|
||||
} else {
|
||||
let versions = entry.file_info_versions(bucket).expect("fixture versions should decode");
|
||||
expected.extend(
|
||||
versions
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| (name.clone(), version.version_id, version.deleted)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let prefix = format!("{kind}/");
|
||||
let delimiter = (kind == "delimiter").then(|| "/".to_owned());
|
||||
// Exercise each public/internal entry point with the reported page size.
|
||||
// The store entry point also covers exact and one-over limit boundaries.
|
||||
for (layer, max_keys) in [(0, 0), (0, 1), (0, 5), (0, 9), (0, 10), (0, 11), (1, 5), (2, 5), (3, 5)] {
|
||||
if layer == 3 && delimiter.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
let expected_pages = if max_keys == 0 {
|
||||
1
|
||||
} else {
|
||||
10usize.div_ceil(usize::try_from(max_keys).expect("positive page size"))
|
||||
};
|
||||
let mut actual = Vec::new();
|
||||
for page in 0..expected_pages {
|
||||
let result = match layer {
|
||||
0 => {
|
||||
store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
|
||||
.await
|
||||
}
|
||||
1 => {
|
||||
store.pools[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
|
||||
.await
|
||||
}
|
||||
2 => {
|
||||
store.pools[0].disk_set[0]
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
store.pools[0].disk_set[0]
|
||||
.clone()
|
||||
.inner_list_object_versions_for_recursive_delete(
|
||||
bucket,
|
||||
&prefix,
|
||||
marker,
|
||||
version_marker,
|
||||
max_keys,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.expect("version page should list successfully");
|
||||
let page_size = usize::try_from(max_keys).expect("nonnegative page size");
|
||||
assert_eq!(result.objects.len() + result.prefixes.len(), (10 - page * page_size).min(page_size));
|
||||
let has_more = page + 1 < expected_pages;
|
||||
assert_eq!(result.is_truncated, has_more, "{kind}, layer {layer}, max_keys {max_keys}, page {page}");
|
||||
assert_eq!(
|
||||
result.next_marker.is_some(),
|
||||
has_more,
|
||||
"key marker must exist only when another page exists"
|
||||
);
|
||||
if !has_more {
|
||||
assert!(
|
||||
result.next_version_idmarker.is_none(),
|
||||
"the final page must not advertise a version marker"
|
||||
);
|
||||
}
|
||||
actual.extend(
|
||||
result
|
||||
.objects
|
||||
.into_iter()
|
||||
.map(|object| (object.name, object.version_id, object.delete_marker)),
|
||||
);
|
||||
actual.extend(result.prefixes.into_iter().map(|prefix| (prefix, None, false)));
|
||||
marker = result.next_marker;
|
||||
version_marker = result.next_version_idmarker;
|
||||
}
|
||||
// Objects and CommonPrefixes are serialized separately; compare their
|
||||
// identities without relying on their relative position in the response.
|
||||
actual.sort();
|
||||
let mut expected = if max_keys == 0 { Vec::new() } else { expected.clone() };
|
||||
expected.sort();
|
||||
assert_eq!(actual, expected, "{kind}, layer {layer}, max_keys {max_keys}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
|
||||
let version_marker = Some(VersionMarker::Null);
|
||||
@@ -9622,71 +9448,6 @@ mod test {
|
||||
assert!(supplemented.is_latest_delete_marker());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_listing_supplement_checks_fallback_disks_for_common_prefix_quorum() {
|
||||
let mut fallback_disks = Vec::new();
|
||||
let mut fallback_tempdirs = Vec::new();
|
||||
for index in 0..4 {
|
||||
let tempdir = tempfile::tempdir().expect("fallback tempdir should be created");
|
||||
let endpoint = Endpoint::try_from(tempdir.path().to_str().expect("fallback path should be utf8"))
|
||||
.expect("fallback endpoint should parse");
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("fallback disk should be created");
|
||||
disk.make_volume("bucket").await.expect("fallback bucket should be created");
|
||||
for copies in [3, 4] {
|
||||
if index < copies {
|
||||
let object = format!("quux-{copies}/thud");
|
||||
let entry = test_object_meta_entry(&object);
|
||||
disk.write_all("bucket", &format!("{object}/{STORAGE_FORMAT_FILE}"), bytes::Bytes::from(entry.metadata))
|
||||
.await
|
||||
.expect("fallback child metadata should be written");
|
||||
}
|
||||
}
|
||||
fallback_disks.push(disk);
|
||||
fallback_tempdirs.push(tempdir);
|
||||
}
|
||||
let supplement = ListingSupplement::new(
|
||||
ListingSupplementOptions {
|
||||
bucket: "bucket".to_owned(),
|
||||
path: String::new(),
|
||||
recursive: false,
|
||||
incl_deleted: false,
|
||||
skip_hidden_prefix_check: false,
|
||||
filter_prefix: None,
|
||||
forward_to: None,
|
||||
per_disk_limit: 100,
|
||||
skip_total_timeout: true,
|
||||
walkdir_timeout: None,
|
||||
walkdir_stall_timeout: None,
|
||||
},
|
||||
Arc::new(fallback_disks),
|
||||
FallbackClaimTracker::default(),
|
||||
);
|
||||
// A 16-drive EC:4 set asks 12 primary disks. A committed write may
|
||||
// exist on eight primary disks and all four remaining fallback disks.
|
||||
let resolver = list_metadata_resolution_params("bucket".to_owned(), 4, 12, false, 0);
|
||||
for fallback_copies in [3, 4] {
|
||||
let prefix = format!("quux-{fallback_copies}/");
|
||||
let mut primary = vec![Some(test_dir_meta_entry(&prefix)); 8];
|
||||
primary.extend([None, None, None, None]);
|
||||
let entry =
|
||||
resolve_listing_entries_with_supplement(MetaCacheEntries(primary), resolver.clone(), true, supplement.clone())
|
||||
.await;
|
||||
assert_eq!(
|
||||
entry.map(|entry| entry.name),
|
||||
(fallback_copies == 4).then_some(prefix),
|
||||
"the common prefix needs all twelve copies, including fallback disks"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
|
||||
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||
|
||||
@@ -104,7 +104,6 @@ walkdir = { workspace = true }
|
||||
http = { workspace = true }
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
+16
-129
@@ -841,10 +841,6 @@ pub struct HealManager {
|
||||
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||
/// Set IDs whose durable replacement metadata is corrupt or conflicting.
|
||||
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
|
||||
/// Durable handoff of interrupted administrator root traversals.
|
||||
root_recovery: Arc<root_recovery::RootHealRecovery>,
|
||||
/// Keep forceStart's cancellation side effects inside the shutdown fence.
|
||||
force_start_shutdown: Mutex<()>,
|
||||
/// Storage layer interface
|
||||
storage: Arc<dyn HealStorageAPI>,
|
||||
/// Cancel token
|
||||
@@ -880,7 +876,6 @@ struct HealQueueContext<'a> {
|
||||
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
|
||||
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
|
||||
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||
root_recovery: &'a Arc<root_recovery::RootHealRecovery>,
|
||||
config: &'a Arc<RwLock<HealConfig>>,
|
||||
statistics: &'a Arc<RwLock<HealStatistics>>,
|
||||
storage: &'a Arc<dyn HealStorageAPI>,
|
||||
@@ -1382,8 +1377,6 @@ impl HealManager {
|
||||
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
|
||||
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||
root_recovery: Arc::new(root_recovery::RootHealRecovery::default()),
|
||||
force_start_shutdown: Mutex::new(()),
|
||||
storage,
|
||||
cancel_token: CancellationToken::new(),
|
||||
statistics: Arc::new(RwLock::new(HealStatistics::new())),
|
||||
@@ -1419,23 +1412,6 @@ impl HealManager {
|
||||
"Heal manager starting"
|
||||
);
|
||||
|
||||
// Restore graceful-shutdown root responsibilities before automatic
|
||||
// repair can admit overlapping work.
|
||||
if let Err(error) = self.replay_root_heals().await {
|
||||
// A missing owner or invalid root record must not block existing
|
||||
// replacement recovery. Keep its file for a later restart after
|
||||
// the owner is readable or the record has been repaired.
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_MANAGER_STATE,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
state = "root_recovery_deferred",
|
||||
error = %error,
|
||||
"Root heal restart recovery deferred"
|
||||
);
|
||||
}
|
||||
|
||||
// start scheduler
|
||||
self.start_scheduler().await?;
|
||||
|
||||
@@ -1473,7 +1449,6 @@ impl HealManager {
|
||||
|
||||
/// Stop HealManager
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
let _force_start_guard = self.force_start_shutdown.lock().await;
|
||||
info!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_MANAGER_STATE,
|
||||
@@ -1483,39 +1458,11 @@ impl HealManager {
|
||||
"Heal manager stopping"
|
||||
);
|
||||
|
||||
// Keep scheduler, cancellation, and retry ownership stable until every
|
||||
// unfinished root traversal has a durable successor. A failed write
|
||||
// must leave the manager running and the shutdown marker unclean.
|
||||
let mut active_heals = self.active_heals.lock().await;
|
||||
let queue = self.heal_queue.lock().await;
|
||||
let retrying = self.retrying_heals.lock().await;
|
||||
for task in active_heals.values() {
|
||||
if root_recovery::is_root_heal(&task.heal_type, task.source) {
|
||||
if task.get_status().await == HealTaskStatus::Completed {
|
||||
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
|
||||
} else {
|
||||
let mut request = match task.retry_request_with_remaining_timeout().await {
|
||||
Ok(request) => request,
|
||||
Err(Error::TaskTimeout) => {
|
||||
let mut request = task.retry_request();
|
||||
request.options.timeout = Some(Duration::ZERO);
|
||||
request
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
request.retry_attempts = task.retry_attempts;
|
||||
self.root_recovery.persist(&request).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for request in queue.requests().chain(retrying.values().map(|retrying| &retrying.request)) {
|
||||
self.root_recovery.persist(request).await?;
|
||||
}
|
||||
// cancel all tasks
|
||||
self.cancel_token.cancel();
|
||||
drop(retrying);
|
||||
drop(queue);
|
||||
|
||||
// cancel active workers after the durable handoff
|
||||
// wait for all tasks to complete
|
||||
let mut active_heals = self.active_heals.lock().await;
|
||||
for task in active_heals.values() {
|
||||
if let Err(e) = task.cancel().await {
|
||||
warn!(
|
||||
@@ -1642,17 +1589,6 @@ impl HealManager {
|
||||
let admission_start = Instant::now();
|
||||
let source = request.source;
|
||||
let force_start = request.force_start;
|
||||
// A forceStart must not retire an old durable owner if shutdown will
|
||||
// reject its replacement. Hold the same gate through final admission.
|
||||
let _force_start_guard = if source == HealRequestSource::Admin && force_start {
|
||||
let guard = self.force_start_shutdown.lock().await;
|
||||
if self.cancel_token.is_cancelled() {
|
||||
return Err(Error::Other("Heal manager is stopping".to_string()));
|
||||
}
|
||||
Some(guard)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// HS-06 forceStart semantics (admin only): MinIO stops the old task
|
||||
// first and then starts the new one. Cancel any active admin task
|
||||
// overlapping this request's path before entering admission, so the
|
||||
@@ -1660,9 +1596,7 @@ impl HealManager {
|
||||
if request.source == HealRequestSource::Admin && request.force_start {
|
||||
let overlapping: Vec<String> = {
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
let queue = self.heal_queue.lock().await;
|
||||
let retrying = self.retrying_heals.lock().await;
|
||||
let mut ids = active_heals
|
||||
active_heals
|
||||
.iter()
|
||||
.filter(|(task_id, task)| {
|
||||
task.source == HealRequestSource::Admin
|
||||
@@ -1670,17 +1604,7 @@ impl HealManager {
|
||||
&& *task_id != &request.id
|
||||
})
|
||||
.map(|(task_id, _)| task_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
ids.extend(
|
||||
queue
|
||||
.requests()
|
||||
.chain(retrying.values().map(|retrying| &retrying.request))
|
||||
.filter(|pending| {
|
||||
root_recovery::is_root_heal(&pending.heal_type, pending.source) && pending.id != request.id
|
||||
})
|
||||
.map(|pending| pending.id.clone()),
|
||||
);
|
||||
ids
|
||||
.collect()
|
||||
};
|
||||
for task_id in overlapping {
|
||||
match self.cancel_task(&task_id).await {
|
||||
@@ -1694,14 +1618,17 @@ impl HealManager {
|
||||
result = "force_start_cancelled_overlap",
|
||||
"Admin forceStart cancelled an overlapping heal task"
|
||||
),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
// A failed or timed-out replay may have only its durable owner
|
||||
// left. Root responsibility overlaps every administrator path.
|
||||
for pending in self.root_recovery.pending().await? {
|
||||
if pending.id != request.id {
|
||||
self.cancel_task(&pending.id).await?;
|
||||
Err(err) => warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
request_id = %request.id,
|
||||
cancelled_task_id = %task_id,
|
||||
error = %err,
|
||||
result = "force_start_cancel_failed",
|
||||
"Admin forceStart failed to cancel an overlapping heal task"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1714,9 +1641,6 @@ impl HealManager {
|
||||
// active -> retrying transitions can slip between duplicate checks.
|
||||
let lock_phase_start = Instant::now();
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if self.cancel_token.is_cancelled() {
|
||||
return Err(Error::Other("Heal manager is stopping".to_string()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
pause_duplicate_admission_after_active_lock(&request.id).await;
|
||||
let mut queue = self.heal_queue.lock().await;
|
||||
@@ -2221,7 +2145,6 @@ impl HealManager {
|
||||
{
|
||||
let mut active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id) {
|
||||
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
|
||||
task.cancel().await?;
|
||||
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
|
||||
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
|
||||
@@ -2245,11 +2168,6 @@ impl HealManager {
|
||||
|
||||
{
|
||||
let mut retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
|
||||
self.root_recovery
|
||||
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
|
||||
.await?;
|
||||
}
|
||||
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
|
||||
retrying.cancel_token.cancel();
|
||||
drop(retrying_heals);
|
||||
@@ -2270,11 +2188,6 @@ impl HealManager {
|
||||
}
|
||||
|
||||
let mut queue = self.heal_queue.lock().await;
|
||||
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
|
||||
self.root_recovery
|
||||
.remove(&request.id, &request.heal_type, request.source)
|
||||
.await?;
|
||||
}
|
||||
if queue.remove_request_id(&canonical_task_id).is_some() {
|
||||
publish_heal_queue_length(&queue);
|
||||
info!(
|
||||
@@ -2292,10 +2205,6 @@ impl HealManager {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
drop(queue);
|
||||
if self.root_recovery.cancel_pending(&canonical_task_id).await? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
@@ -2314,7 +2223,6 @@ impl HealManager {
|
||||
|
||||
for task_id in &task_ids {
|
||||
if let Some(task) = active_heals.get(task_id) {
|
||||
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
|
||||
task.cancel().await?;
|
||||
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
|
||||
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
|
||||
@@ -2343,11 +2251,6 @@ impl HealManager {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for task_id in &task_ids {
|
||||
if let Some(retrying) = retrying_heals.get(task_id) {
|
||||
self.root_recovery
|
||||
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
|
||||
.await?;
|
||||
}
|
||||
if let Some(retrying) = retrying_heals.remove(task_id) {
|
||||
retrying.cancel_token.cancel();
|
||||
cancelled += 1;
|
||||
@@ -2370,14 +2273,6 @@ impl HealManager {
|
||||
}
|
||||
|
||||
let mut queue = self.heal_queue.lock().await;
|
||||
for request in queue
|
||||
.requests()
|
||||
.filter(|request| heal_type_matches_path(&request.heal_type, heal_path))
|
||||
{
|
||||
self.root_recovery
|
||||
.remove(&request.id, &request.heal_type, request.source)
|
||||
.await?;
|
||||
}
|
||||
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
|
||||
if !queued_cancelled.is_empty() {
|
||||
publish_heal_queue_length(&queue);
|
||||
@@ -2389,13 +2284,6 @@ impl HealManager {
|
||||
self.remove_mrf_repair_notice_targets_for_task(&request.id);
|
||||
}
|
||||
|
||||
if heal_type_matches_path(&HealType::Cluster, heal_path) {
|
||||
for pending in self.root_recovery.pending().await? {
|
||||
if self.root_recovery.cancel_pending(&pending.id).await? {
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancelled == 0 {
|
||||
return Err(Error::TaskNotFound {
|
||||
task_id: heal_path.to_string(),
|
||||
@@ -2507,7 +2395,6 @@ impl std::fmt::Debug for HealManager {
|
||||
|
||||
mod auto_scan;
|
||||
mod queue;
|
||||
mod root_recovery;
|
||||
mod scheduler;
|
||||
mod unclean_shutdown;
|
||||
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Graceful-shutdown handoff for administrator root heals. This namespace is
|
||||
//! separate from erasure-set checkpoints and replacement generations, which
|
||||
//! cannot represent a cluster traversal. One coordinator disk owns each
|
||||
//! record; never create a fallback copy after an uncertain write or deletion.
|
||||
|
||||
use super::*;
|
||||
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskAPI, EcstoreDiskBytes};
|
||||
use crate::heal::{DiskStore, RUSTFS_META_BUCKET};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// The metadata bucket already exists and its parent is durable. Creating a
|
||||
// nested journal directory here would also require syncing every ancestor.
|
||||
const ROOT_RECOVERY_PREFIX: &str = "root-heal-";
|
||||
const ROOT_RECOVERY_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RootHealIntent {
|
||||
schema: u32,
|
||||
task_id: String,
|
||||
#[serde(deserialize_with = "decode_options")]
|
||||
options: HealOptions,
|
||||
priority: HealPriority,
|
||||
retry_attempts: u32,
|
||||
created_at: SystemTime,
|
||||
}
|
||||
|
||||
impl RootHealIntent {
|
||||
fn from_request(request: &HealRequest) -> Self {
|
||||
Self {
|
||||
schema: ROOT_RECOVERY_SCHEMA,
|
||||
task_id: request.id.clone(),
|
||||
options: request.options.clone(),
|
||||
priority: request.priority,
|
||||
retry_attempts: request.retry_attempts,
|
||||
created_at: request.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_request(self) -> HealRequest {
|
||||
let mut request = HealRequest::new(HealType::Cluster, self.options, self.priority);
|
||||
request.id = self.task_id;
|
||||
request.source = HealRequestSource::Admin;
|
||||
request.retry_attempts = self.retry_attempts;
|
||||
request.created_at = self.created_at;
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RootHealRecovery {
|
||||
mutation: Mutex<()>,
|
||||
#[cfg(test)]
|
||||
disks: Option<Vec<DiskStore>>,
|
||||
}
|
||||
|
||||
pub(super) fn is_root_heal(heal_type: &HealType, source: HealRequestSource) -> bool {
|
||||
source == HealRequestSource::Admin && matches!(heal_type, HealType::Cluster)
|
||||
}
|
||||
|
||||
fn decode_options<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<HealOptions, D::Error> {
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| serde::de::Error::custom("root heal options must be an object"))?;
|
||||
const FIELDS: &[&str] = &[
|
||||
"scan_mode",
|
||||
"remove_corrupted",
|
||||
"recreate_missing",
|
||||
"update_parity",
|
||||
"recursive",
|
||||
"dry_run",
|
||||
"no_lock",
|
||||
"timeout",
|
||||
"pool_index",
|
||||
"set_index",
|
||||
];
|
||||
if object.keys().any(|key| !FIELDS.contains(&key.as_str())) {
|
||||
return Err(serde::de::Error::custom("unknown root heal recovery option"));
|
||||
}
|
||||
let options: HealOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||
if options.no_lock {
|
||||
return Err(serde::de::Error::custom("administrator root heal cannot skip namespace locking"));
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn intent_path(task_id: &str) -> Result<String> {
|
||||
let parsed = uuid::Uuid::parse_str(task_id).map_err(|_| Error::Other("Invalid root heal recovery task id".to_string()))?;
|
||||
if parsed.to_string() != task_id {
|
||||
return Err(Error::Other("Noncanonical root heal recovery task id".to_string()));
|
||||
}
|
||||
Ok(format!("{ROOT_RECOVERY_PREFIX}{task_id}.json"))
|
||||
}
|
||||
|
||||
fn decode_intent(task_id: &str, bytes: &[u8]) -> Result<RootHealIntent> {
|
||||
let _ = intent_path(task_id)?;
|
||||
let intent: RootHealIntent = serde_json::from_slice(bytes)
|
||||
.map_err(|error| Error::Other(format!("Invalid root heal recovery record {task_id}: {error}")))?;
|
||||
if intent.schema != ROOT_RECOVERY_SCHEMA || intent.task_id != task_id {
|
||||
return Err(Error::Other(format!("Unsupported or mismatched root heal recovery record {task_id}")));
|
||||
}
|
||||
Ok(intent)
|
||||
}
|
||||
|
||||
impl RootHealRecovery {
|
||||
#[cfg(test)]
|
||||
pub(super) fn with_disks(disks: Vec<DiskStore>) -> Self {
|
||||
Self {
|
||||
mutation: Mutex::new(()),
|
||||
disks: Some(disks),
|
||||
}
|
||||
}
|
||||
|
||||
async fn disks(&self) -> Result<Vec<DiskStore>> {
|
||||
#[cfg(test)]
|
||||
if let Some(disks) = &self.disks {
|
||||
return Ok(disks.clone());
|
||||
}
|
||||
let map = local_disk_map_read().await;
|
||||
if map.values().any(Option::is_none) {
|
||||
return Err(Error::Other("Root heal recovery owner may be on an unavailable local disk".to_string()));
|
||||
}
|
||||
let mut disks = map.values().flatten().cloned().collect::<Vec<_>>();
|
||||
disks.sort_by_key(|disk| EcstoreDiskAPI::endpoint(disk.as_ref()).to_string());
|
||||
Ok(disks)
|
||||
}
|
||||
|
||||
async fn find(disks: &[DiskStore], task_id: &str) -> Result<Option<(DiskStore, EcstoreDiskBytes)>> {
|
||||
let path = intent_path(task_id)?;
|
||||
let mut found = None;
|
||||
for disk in disks {
|
||||
// read_all reports FileNotFound even when the whole metadata
|
||||
// volume is absent; that is an unknown owner, not empty state.
|
||||
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
|
||||
match EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, &path).await {
|
||||
Ok(bytes) => {
|
||||
decode_intent(task_id, &bytes)?;
|
||||
if found.is_some() {
|
||||
return Err(Error::Other(format!("Multiple root heal recovery owners for {task_id}")));
|
||||
}
|
||||
found = Some((disk.clone(), bytes));
|
||||
}
|
||||
Err(DiskError::FileNotFound) => {}
|
||||
Err(error) => return Err(Error::Disk(error)),
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub(super) async fn persist(&self, request: &HealRequest) -> Result<()> {
|
||||
if !is_root_heal(&request.heal_type, request.source) {
|
||||
return Ok(());
|
||||
}
|
||||
let _guard = self.mutation.lock().await;
|
||||
let disks = self.disks().await?;
|
||||
let existing = Self::find(&disks, &request.id).await?;
|
||||
let (disk, expected) = match existing {
|
||||
Some((disk, bytes)) => (disk, Some(bytes)),
|
||||
None => {
|
||||
let disk = disks
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Other("No local disk available for root heal shutdown recovery".to_string()))?;
|
||||
(disk, None)
|
||||
}
|
||||
};
|
||||
if request.options.no_lock {
|
||||
return Err(Error::Other("Administrator root heal cannot skip namespace locking".to_string()));
|
||||
}
|
||||
let bytes = serde_json::to_vec(&RootHealIntent::from_request(request))
|
||||
.map_err(|error| Error::Other(format!("Serialize root heal recovery record: {error}")))?;
|
||||
match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
&intent_path(&request.id)?,
|
||||
expected,
|
||||
Some(bytes.into()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
EcstoreConditionalFileUpdate::Updated => Ok(()),
|
||||
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn remove(&self, task_id: &str, heal_type: &HealType, source: HealRequestSource) -> Result<bool> {
|
||||
if !is_root_heal(heal_type, source) {
|
||||
return Ok(false);
|
||||
}
|
||||
let _guard = self.mutation.lock().await;
|
||||
let Some((disk, bytes)) = Self::find(&self.disks().await?, task_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
&intent_path(task_id)?,
|
||||
Some(bytes),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
EcstoreConditionalFileUpdate::Updated => Ok(true),
|
||||
_ => Err(Error::Other(format!("Root heal recovery record changed while retiring {task_id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn checkpoint_failed_execution(&self, task: &HealTask) -> Result<()> {
|
||||
if !is_root_heal(&task.heal_type, task.source) {
|
||||
return Ok(());
|
||||
}
|
||||
let remaining = match task.retry_request_with_remaining_timeout().await {
|
||||
Ok(request) => request.options.timeout,
|
||||
Err(Error::TaskTimeout) => Some(Duration::ZERO),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let _guard = self.mutation.lock().await;
|
||||
let Some((disk, expected)) = Self::find(&self.disks().await?, &task.id).await? else {
|
||||
// A first execution that failed has no restart handoff to update.
|
||||
return Ok(());
|
||||
};
|
||||
let mut intent = decode_intent(&task.id, &expected)?;
|
||||
let mut expected_options = intent.options.clone();
|
||||
expected_options.timeout = task.options.timeout;
|
||||
if intent.created_at != task.created_at || intent.priority != task.priority || expected_options != task.options {
|
||||
return Err(Error::Other(format!("Root heal recovery owner changed for {}", task.id)));
|
||||
}
|
||||
// A terminal timeout leaves no runtime owner for stop() to snapshot.
|
||||
// Checkpoint its consumed budget before publishing terminal status;
|
||||
// never refund time if an earlier checkpoint is already stricter.
|
||||
intent.options.timeout = match (intent.options.timeout, remaining) {
|
||||
(Some(previous), Some(remaining)) => Some(previous.min(remaining)),
|
||||
(previous, remaining) => previous.or(remaining),
|
||||
};
|
||||
intent.retry_attempts = intent.retry_attempts.max(task.retry_attempts);
|
||||
let bytes = serde_json::to_vec(&intent)
|
||||
.map_err(|error| Error::Other(format!("Serialize root heal recovery checkpoint: {error}")))?;
|
||||
match EcstoreDiskAPI::compare_and_update_file(
|
||||
disk.as_ref(),
|
||||
RUSTFS_META_BUCKET,
|
||||
&intent_path(&task.id)?,
|
||||
Some(expected),
|
||||
Some(bytes.into()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
EcstoreConditionalFileUpdate::Updated => Ok(()),
|
||||
_ => Err(Error::Other(format!("Root heal recovery record changed while checkpointing {}", task.id))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn cancel_pending(&self, task_id: &str) -> Result<bool> {
|
||||
if intent_path(task_id).is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.remove(task_id, &HealType::Cluster, HealRequestSource::Admin).await
|
||||
}
|
||||
|
||||
pub(super) async fn pending(&self) -> Result<Vec<HealRequest>> {
|
||||
let _guard = self.mutation.lock().await;
|
||||
let disks = self.disks().await?;
|
||||
let mut ids = HashSet::new();
|
||||
for disk in &disks {
|
||||
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
|
||||
let entries = match EcstoreDiskAPI::list_dir(disk.as_ref(), "", RUSTFS_META_BUCKET, "", -1).await {
|
||||
Ok(entries) => entries,
|
||||
Err(DiskError::FileNotFound) => continue,
|
||||
Err(error) => return Err(Error::Disk(error)),
|
||||
};
|
||||
for entry in entries {
|
||||
let Some(task_id) = entry
|
||||
.strip_prefix(ROOT_RECOVERY_PREFIX)
|
||||
.and_then(|entry| entry.strip_suffix(".json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let _ = intent_path(task_id)?;
|
||||
ids.insert(task_id.to_string());
|
||||
}
|
||||
}
|
||||
let mut requests = Vec::new();
|
||||
for task_id in ids {
|
||||
if let Some((_, bytes)) = Self::find(&disks, &task_id).await? {
|
||||
requests.push(decode_intent(&task_id, &bytes)?.into_request());
|
||||
}
|
||||
}
|
||||
requests.sort_by(|left, right| left.created_at.cmp(&right.created_at).then_with(|| left.id.cmp(&right.id)));
|
||||
Ok(requests)
|
||||
}
|
||||
}
|
||||
|
||||
impl HealManager {
|
||||
pub(super) async fn replay_root_heals(&self) -> Result<()> {
|
||||
// Decode every record before admitting anything. These are already
|
||||
// accepted responsibilities, so restore distinct IDs even when their
|
||||
// paths overlap or the configured admission capacity has changed.
|
||||
let requests = self.root_recovery.pending().await?;
|
||||
let active = self.active_heals.lock().await;
|
||||
let mut queue = self.heal_queue.lock().await;
|
||||
let retrying = self.retrying_heals.lock().await;
|
||||
for mut request in requests {
|
||||
request.force_start = true;
|
||||
let existing = active
|
||||
.get(&request.id)
|
||||
.map(|task| request_matches_task(&request, task))
|
||||
.or_else(|| {
|
||||
queue
|
||||
.requests()
|
||||
.find(|queued| queued.id == request.id)
|
||||
.map(|queued| request_matches_request(&request, queued))
|
||||
})
|
||||
.or_else(|| {
|
||||
retrying
|
||||
.get(&request.id)
|
||||
.map(|retrying| request_matches_request(&request, &retrying.request))
|
||||
});
|
||||
match existing {
|
||||
Some(true) => continue,
|
||||
Some(false) => return Err(Error::Other(format!("Conflicting root heal recovery task {}", request.id))),
|
||||
None => {}
|
||||
}
|
||||
queue.push(request);
|
||||
}
|
||||
publish_heal_queue_length(&queue);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ impl HealManager {
|
||||
let retrying_heals = self.retrying_heals.clone();
|
||||
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
|
||||
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
|
||||
let root_recovery = self.root_recovery.clone();
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
let statistics = self.statistics.clone();
|
||||
let storage = self.storage.clone();
|
||||
@@ -60,7 +59,6 @@ impl HealManager {
|
||||
retrying_heals: &retrying_heals,
|
||||
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
||||
replacement_recovery_anchors: &replacement_recovery_anchors,
|
||||
root_recovery: &root_recovery,
|
||||
config: &config,
|
||||
statistics: &statistics,
|
||||
storage: &storage,
|
||||
@@ -80,7 +78,6 @@ impl HealManager {
|
||||
retrying_heals: &retrying_heals,
|
||||
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
||||
replacement_recovery_anchors: &replacement_recovery_anchors,
|
||||
root_recovery: &root_recovery,
|
||||
config: &config,
|
||||
statistics: &statistics,
|
||||
storage: &storage,
|
||||
@@ -109,7 +106,6 @@ impl HealManager {
|
||||
retrying_heals,
|
||||
mrf_repair_notice_targets,
|
||||
replacement_recovery_anchors,
|
||||
root_recovery,
|
||||
config,
|
||||
statistics,
|
||||
storage,
|
||||
@@ -121,9 +117,6 @@ impl HealManager {
|
||||
let config = config.read().await;
|
||||
let mainline_pressure = Self::mainline_throttle_active(&config, workload_provider);
|
||||
let mut active_heals_guard = active_heals.lock().await;
|
||||
if cancel_token.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
publish_active_heal_count(&active_heals_guard);
|
||||
|
||||
// Check if new heal tasks can be started
|
||||
@@ -213,7 +206,6 @@ impl HealManager {
|
||||
let replacement_recovery_anchors_clone = replacement_recovery_anchors.clone();
|
||||
let statistics_clone = statistics.clone();
|
||||
let notify_clone = notify.clone();
|
||||
let root_recovery_clone = root_recovery.clone();
|
||||
let manager_cancel_token = cancel_token.clone();
|
||||
let task_type_label_for_spawn = task_type_label.clone();
|
||||
let task_set_label_for_spawn = task_set_label.clone();
|
||||
@@ -302,38 +294,6 @@ impl HealManager {
|
||||
tests::pause_completed_retention_before_publish(&task_id, &completed_status).await;
|
||||
let mut active_heals_guard = active_heals_clone.lock().await;
|
||||
let owns_completion = active_heals_guard.contains_key(&task_id);
|
||||
if owns_completion
|
||||
&& result.is_ok()
|
||||
&& let Err(error) = root_recovery_clone.remove(&task_id, &task.heal_type, task.source).await
|
||||
{
|
||||
// Keep the durable responsibility if retirement fails.
|
||||
// Replaying a completed traversal is idempotent.
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_SCHEDULER_STATE,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
task_id,
|
||||
state = "root_recovery_retirement_failed",
|
||||
error = %error,
|
||||
"Failed to retire root heal recovery record"
|
||||
);
|
||||
}
|
||||
if owns_completion
|
||||
&& result.is_err()
|
||||
&& let Err(error) = root_recovery_clone.checkpoint_failed_execution(&task).await
|
||||
{
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_SCHEDULER_STATE,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
task_id,
|
||||
state = "root_recovery_checkpoint_failed",
|
||||
error = %error,
|
||||
"Failed to checkpoint root heal recovery execution budget"
|
||||
);
|
||||
}
|
||||
let cancelled_completion = if owns_completion {
|
||||
false
|
||||
} else {
|
||||
|
||||
@@ -26,7 +26,6 @@ use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
mod root_recovery;
|
||||
mod running_mainline;
|
||||
|
||||
use super::super::{DiskOption, DiskStore, Endpoint, new_disk, storage_api::status::BucketInfo};
|
||||
@@ -95,7 +94,6 @@ async fn process_manager_queue_once(manager: &HealManager) {
|
||||
retrying_heals: &manager.retrying_heals,
|
||||
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
|
||||
replacement_recovery_anchors: &manager.replacement_recovery_anchors,
|
||||
root_recovery: &manager.root_recovery,
|
||||
config: &manager.config,
|
||||
statistics: &manager.statistics,
|
||||
storage: &manager.storage,
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::super::root_recovery::RootHealRecovery;
|
||||
use super::*;
|
||||
use crate::heal::RUSTFS_META_BUCKET;
|
||||
|
||||
async fn recovery_disk() -> (TempDir, DiskStore) {
|
||||
let temp = TempDir::new().expect("temporary root recovery disk");
|
||||
let endpoint = Endpoint::try_from(temp.path().to_string_lossy().as_ref()).expect("disk endpoint");
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("local recovery disk");
|
||||
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(error) => panic!("metadata volume: {error}"),
|
||||
}
|
||||
(temp, disk)
|
||||
}
|
||||
|
||||
fn recovery_manager(disks: Vec<DiskStore>) -> HealManager {
|
||||
let mut manager = HealManager::new(
|
||||
Arc::new(MockStorage),
|
||||
Some(HealConfig {
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
manager.root_recovery = Arc::new(RootHealRecovery::with_disks(disks));
|
||||
manager
|
||||
}
|
||||
|
||||
fn root_request() -> HealRequest {
|
||||
let mut request = HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::High);
|
||||
request.source = HealRequestSource::Admin;
|
||||
request
|
||||
}
|
||||
|
||||
async fn active_root(manager: &HealManager, request: HealRequest) -> Arc<HealTask> {
|
||||
let task = Arc::new(HealTask::from_request(request, manager.storage.clone()));
|
||||
*task.status.write().await = HealTaskStatus::Running;
|
||||
task.progress.write().await.update_object_progress(1, 1, 0, 0, 128);
|
||||
manager.active_heals.lock().await.insert(task.id.clone(), task.clone());
|
||||
task
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_shutdown_restart_replays_same_id_and_success_retires_intent() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let mut request = root_request();
|
||||
request.options.recursive = true;
|
||||
let task = active_root(&manager, request.clone()).await;
|
||||
manager.stop().await.expect("durable shutdown handoff");
|
||||
assert!(task.cancel_token.is_cancelled());
|
||||
drop(manager);
|
||||
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("replay durable root");
|
||||
restarted.replay_root_heals().await.expect("replay is idempotent");
|
||||
assert_eq!(restarted.get_queue_length().await, 1);
|
||||
let restored = restarted
|
||||
.heal_queue
|
||||
.lock()
|
||||
.await
|
||||
.requests()
|
||||
.next()
|
||||
.cloned()
|
||||
.expect("restored request");
|
||||
assert_eq!(restored.id, request.id);
|
||||
assert_eq!(restored.options, request.options);
|
||||
assert_eq!(restored.priority, request.priority);
|
||||
assert_eq!(restored.retry_attempts, request.retry_attempts);
|
||||
assert_eq!(restored.created_at, request.created_at);
|
||||
|
||||
process_manager_queue_once(&restarted).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Completed))
|
||||
&& !restarted.active_heals.lock().await.contains_key(&request.id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("restored root executes successfully");
|
||||
assert!(restarted.root_recovery.pending().await.expect("read completion").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_explicit_cancel_covers_active_queued_retrying_and_durable_only() {
|
||||
for state in ["active", "queued", "retrying", "durable_only", "root_path"] {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let request = root_request();
|
||||
manager.root_recovery.persist(&request).await.expect("durable responsibility");
|
||||
match state {
|
||||
"active" => {
|
||||
active_root(&manager, request.clone()).await;
|
||||
}
|
||||
"queued" => {
|
||||
manager.replay_root_heals().await.expect("queued recovery");
|
||||
}
|
||||
"retrying" => {
|
||||
insert_retrying_request(&manager, request.clone()).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if state == "root_path" {
|
||||
assert_eq!(manager.cancel_tasks_for_path("").await.expect("cancel durable root path"), 1);
|
||||
} else {
|
||||
manager.cancel_task(&request.id).await.expect("cancel root responsibility");
|
||||
}
|
||||
drop(manager);
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted
|
||||
.replay_root_heals()
|
||||
.await
|
||||
.expect("restart after explicit cancellation");
|
||||
assert_eq!(restarted.get_queue_length().await, 0, "state={state}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_force_start_cancels_durable_only_responsibility() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let old = root_request();
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&old)
|
||||
.await
|
||||
.expect("old terminal responsibility");
|
||||
let mut new = root_request();
|
||||
new.force_start = true;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(new.clone())
|
||||
.await
|
||||
.expect("force start replacement"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
assert!(manager.root_recovery.pending().await.expect("old owner retired").is_empty());
|
||||
manager.stop().await.expect("persist new root only");
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("restart replacement");
|
||||
let ids = restarted
|
||||
.heal_queue
|
||||
.lock()
|
||||
.await
|
||||
.requests()
|
||||
.map(|request| request.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, [new.id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_force_start_replaces_fresh_queued_and_retrying_admin_roots() {
|
||||
for retrying in [false, true] {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let old = root_request();
|
||||
if retrying {
|
||||
insert_retrying_request(&manager, old.clone()).await;
|
||||
} else {
|
||||
manager.submit_heal_request(old.clone()).await.expect("queue original root");
|
||||
}
|
||||
assert!(manager.root_recovery.pending().await.expect("not handed off yet").is_empty());
|
||||
let mut new = root_request();
|
||||
new.force_start = true;
|
||||
assert_eq!(
|
||||
manager.submit_heal_request(new.clone()).await.expect("force replacement"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
manager.stop().await.expect("handoff only the new responsibility");
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("restart after forceStart");
|
||||
let ids = restarted
|
||||
.heal_queue
|
||||
.lock()
|
||||
.await
|
||||
.requests()
|
||||
.map(|request| request.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, [new.id], "retrying={retrying}; old={}", old.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_invalid_records_are_retained_without_partial_replay() {
|
||||
for kind in ["truncated", "schema", "identity", "option", "no_lock"] {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let valid = root_request();
|
||||
let invalid = root_request();
|
||||
manager.root_recovery.persist(&valid).await.expect("valid root record");
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&invalid)
|
||||
.await
|
||||
.expect("record before corruption");
|
||||
let path = format!("root-heal-{}.json", invalid.id);
|
||||
let original = disk.read_all(RUSTFS_META_BUCKET, &path).await.expect("read root record");
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&original).expect("record JSON");
|
||||
match kind {
|
||||
"schema" => value["schema"] = 2.into(),
|
||||
"identity" => value["task_id"] = valid.id.clone().into(),
|
||||
"option" => value["options"]["future_delete_mode"] = true.into(),
|
||||
"no_lock" => value["options"]["no_lock"] = true.into(),
|
||||
_ => {}
|
||||
}
|
||||
let bytes = if kind == "truncated" {
|
||||
b"{".to_vec()
|
||||
} else {
|
||||
serde_json::to_vec(&value).expect("modified record")
|
||||
};
|
||||
disk.write_all(RUSTFS_META_BUCKET, &path, bytes.clone().into())
|
||||
.await
|
||||
.expect("inject bad record");
|
||||
assert!(manager.replay_root_heals().await.is_err(), "kind={kind}");
|
||||
assert_eq!(manager.get_queue_length().await, 0, "no partial admission for {kind}");
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_BUCKET, &path)
|
||||
.await
|
||||
.expect("bad record retained")
|
||||
.as_ref(),
|
||||
bytes
|
||||
);
|
||||
let mut forced = root_request();
|
||||
forced.force_start = true;
|
||||
assert!(
|
||||
manager.submit_heal_request(forced).await.is_err(),
|
||||
"forceStart must not discard unknown state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_failed_handoff_keeps_runtime_owner_and_does_not_try_another_disk() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let (unavailable_temp, unavailable) = recovery_disk().await;
|
||||
std::fs::remove_dir_all(unavailable_temp.path().join(RUSTFS_META_BUCKET)).expect("make owner volume unavailable");
|
||||
let manager = recovery_manager(vec![unavailable, disk.clone()]);
|
||||
let task = active_root(&manager, root_request()).await;
|
||||
assert!(manager.stop().await.is_err());
|
||||
assert!(
|
||||
manager.cancel_task(&task.id).await.is_err(),
|
||||
"missing owner cannot acknowledge cancellation"
|
||||
);
|
||||
assert!(!manager.cancel_token.is_cancelled());
|
||||
assert!(!task.cancel_token.is_cancelled());
|
||||
assert!(manager.active_heals.lock().await.contains_key(&task.id));
|
||||
assert!(
|
||||
RootHealRecovery::with_disks(vec![disk])
|
||||
.pending()
|
||||
.await
|
||||
.expect("other disk remains empty")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_shutdown_fences_new_admission_and_preserves_later_cancellation() {
|
||||
for operation_kind in ["submit", "force_start", "cancel"] {
|
||||
let cancel = operation_kind == "cancel";
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = Arc::new(recovery_manager(vec![disk.clone()]));
|
||||
let request = root_request();
|
||||
active_root(&manager, request.clone()).await;
|
||||
let queue = manager.heal_queue.lock().await;
|
||||
let stopping = manager.clone();
|
||||
let stop = tokio::spawn(async move { stopping.stop().await });
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if manager.active_heals.try_lock().is_err() {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("shutdown owns active lock while waiting for queue");
|
||||
let concurrent = manager.clone();
|
||||
let operation = tokio::spawn(async move {
|
||||
if cancel {
|
||||
concurrent.cancel_task(&request.id).await
|
||||
} else {
|
||||
let mut new = root_request();
|
||||
new.force_start = operation_kind == "force_start";
|
||||
concurrent.submit_heal_request(new).await.map(|_| ())
|
||||
}
|
||||
});
|
||||
drop(queue);
|
||||
stop.await.expect("shutdown task").expect("durable shutdown");
|
||||
let result = operation.await.expect("concurrent operation");
|
||||
assert_eq!(result.is_ok(), cancel, "operation={operation_kind}");
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("read final responsibility");
|
||||
assert_eq!(restarted.get_queue_length().await, usize::from(!cancel));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_exhausted_timeout_is_not_reset_by_restart() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let mut request = root_request();
|
||||
request.options.timeout = Some(Duration::from_secs(10));
|
||||
let task = active_root(&manager, request.clone()).await;
|
||||
task.set_execution_elapsed_for_test(Duration::from_secs(11)).await;
|
||||
manager.stop().await.expect("persist exhausted execution budget");
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("restore bounded request");
|
||||
assert_eq!(
|
||||
restarted
|
||||
.heal_queue
|
||||
.lock()
|
||||
.await
|
||||
.requests()
|
||||
.next()
|
||||
.expect("restored root")
|
||||
.options
|
||||
.timeout,
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
process_manager_queue_once(&restarted).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout)) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("exhausted request stays timed out");
|
||||
restarted
|
||||
.cancel_task(&request.id)
|
||||
.await
|
||||
.expect("timeout responsibility remains cancellable");
|
||||
assert!(restarted.root_recovery.pending().await.expect("retired timeout").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_shutdown_preserves_remaining_execution_budget() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let mut request = root_request();
|
||||
request.options.timeout = Some(Duration::from_secs(60));
|
||||
let task = active_root(&manager, request).await;
|
||||
task.set_execution_elapsed_for_test(Duration::from_secs(20)).await;
|
||||
manager.stop().await.expect("handoff with consumed execution time");
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("restore remaining budget");
|
||||
let queue = restarted.heal_queue.lock().await;
|
||||
let remaining = queue
|
||||
.requests()
|
||||
.next()
|
||||
.expect("restored root")
|
||||
.options
|
||||
.timeout
|
||||
.expect("remaining timeout");
|
||||
assert!(remaining <= Duration::from_secs(40), "elapsed execution must not be refunded");
|
||||
assert!(
|
||||
remaining >= Duration::from_secs(30),
|
||||
"shutdown fixture should retain most of its remaining budget"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_force_start_after_shutdown_does_not_retire_original_owner() {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let old = root_request();
|
||||
active_root(&manager, old.clone()).await;
|
||||
manager.stop().await.expect("handoff original root");
|
||||
let mut new = root_request();
|
||||
new.force_start = true;
|
||||
assert!(manager.submit_heal_request(new).await.is_err());
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted.replay_root_heals().await.expect("original responsibility remains");
|
||||
let ids = restarted
|
||||
.heal_queue
|
||||
.lock()
|
||||
.await
|
||||
.requests()
|
||||
.map(|request| request.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, [old.id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_recovery_terminal_timeout_updates_only_existing_journal_before_second_restart() {
|
||||
for durable in [false, true] {
|
||||
let (_temp, disk) = recovery_disk().await;
|
||||
let manager = recovery_manager(vec![disk.clone()]);
|
||||
let mut request = root_request();
|
||||
request.options.timeout = Some(Duration::from_nanos(1));
|
||||
if durable {
|
||||
manager
|
||||
.root_recovery
|
||||
.persist(&request)
|
||||
.await
|
||||
.expect("persist nonzero execution budget");
|
||||
manager.replay_root_heals().await.expect("first restart");
|
||||
} else {
|
||||
manager
|
||||
.submit_heal_request(request.clone())
|
||||
.await
|
||||
.expect("first root execution");
|
||||
}
|
||||
process_manager_queue_once(&manager).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(manager.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout))
|
||||
&& !manager.active_heals.lock().await.contains_key(&request.id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("real execution exhausts a nonzero budget");
|
||||
assert!(!manager.active_heals.lock().await.contains_key(&request.id));
|
||||
let restarted = recovery_manager(vec![disk]);
|
||||
restarted
|
||||
.replay_root_heals()
|
||||
.await
|
||||
.expect("second restart after terminal timeout");
|
||||
let queue = restarted.heal_queue.lock().await;
|
||||
if durable {
|
||||
assert_eq!(
|
||||
queue
|
||||
.requests()
|
||||
.next()
|
||||
.expect("remaining timeout responsibility")
|
||||
.options
|
||||
.timeout,
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
} else {
|
||||
assert!(queue.is_empty(), "terminal failure must not create a new durable responsibility");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1137,28 +1137,9 @@ fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool, retain_replay_j
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::heal::manager::HealConfig;
|
||||
use crate::heal::storage::{ECStoreHealStorage, HealStorageAPI};
|
||||
use crate::heal::{DiskError, RUSTFS_META_BUCKET};
|
||||
use rustfs_common::mrf_channel::{MrfIntent, MrfKind, MrfVerifiedRepairDisposition, MrfVerifiedRepairEvent};
|
||||
use serde_json::{Map, Value, json};
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc as StdArc;
|
||||
use std::time::{Duration as StdDuration, Instant};
|
||||
|
||||
const W13_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_EVIDENCE_DIR";
|
||||
const W13_SOURCE_REVISION_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_SOURCE_REVISION";
|
||||
const W13_SELECTION_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_SELECTION";
|
||||
const W13_SOAK_SECONDS_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_SOAK_SECONDS";
|
||||
const W13_ALLOW_SHORT_SOAK_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_ALLOW_SHORT_SOAK";
|
||||
const W13_RUN_ID_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_RUN_ID";
|
||||
const W13_WINDOW_ID_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_WINDOW_ID";
|
||||
const W13_ENOSPC_ROOT_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_ENOSPC_ROOT";
|
||||
const W13_ENOSPC_FILL_LIMIT_ENV: &str = "RUSTFS_SCANNER_HEAL_W13_ENOSPC_FILL_LIMIT_BYTES";
|
||||
|
||||
fn intent(bucket: &str, object: &str, attempts: u8) -> MrfIntent {
|
||||
MrfIntent {
|
||||
@@ -1179,748 +1160,6 @@ mod tests {
|
||||
payload
|
||||
}
|
||||
|
||||
fn w13_timestamp() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn w13_selection_contains(selection: &str, lane: &str) -> bool {
|
||||
selection == "all" || selection.split(',').any(|item| item.trim() == lane)
|
||||
}
|
||||
|
||||
fn w13_evidence_path(root: &Path, gate: &str, field: &str) -> PathBuf {
|
||||
let lane = match gate {
|
||||
"G07" => "g07-mrf-responsibility",
|
||||
"G08" => "g08-mrf-capacity",
|
||||
"P4" => "p4-mrf-soak",
|
||||
other => panic!("unsupported W13 evidence gate: {other}"),
|
||||
};
|
||||
root.join(lane).join(format!("{gate}-{field}.json"))
|
||||
}
|
||||
|
||||
struct W13Evidence<'a> {
|
||||
source_revision: &'a str,
|
||||
run_id: &'a str,
|
||||
window_id: &'a str,
|
||||
started_at: &'a str,
|
||||
finished_at: &'a str,
|
||||
gate: &'a str,
|
||||
field: &'a str,
|
||||
artifact_kind: &'a str,
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
fn write_w13_evidence(root: &Path, evidence: W13Evidence<'_>) {
|
||||
let path = w13_evidence_path(root, evidence.gate, evidence.field);
|
||||
fs::create_dir_all(path.parent().expect("W13 evidence artifact parent")).expect("create W13 evidence artifact directory");
|
||||
let mut payload = Map::new();
|
||||
payload.insert("schema".to_string(), json!(1));
|
||||
payload.insert("evidence_type".to_string(), json!("measured"));
|
||||
payload.insert("artifact_kind".to_string(), json!(evidence.artifact_kind));
|
||||
payload.insert("source_revision".to_string(), json!(evidence.source_revision));
|
||||
payload.insert("run_id".to_string(), json!(evidence.run_id));
|
||||
payload.insert("measurement_window_id".to_string(), json!(evidence.window_id));
|
||||
payload.insert("started_at".to_string(), json!(evidence.started_at));
|
||||
payload.insert("finished_at".to_string(), json!(evidence.finished_at));
|
||||
payload.insert("gate".to_string(), json!(evidence.gate));
|
||||
payload.insert("field".to_string(), json!(evidence.field));
|
||||
payload.insert(
|
||||
"command".to_string(),
|
||||
json!([
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"-p",
|
||||
"rustfs-heal",
|
||||
"--lib",
|
||||
"heal::mrf_queue::tests::w13_mrf_release_evidence_outputs_bundle_artifacts",
|
||||
"--",
|
||||
"--ignored",
|
||||
"--exact",
|
||||
"--nocapture"
|
||||
]),
|
||||
);
|
||||
payload.insert(
|
||||
"summary".to_string(),
|
||||
json!(format!("Measured W13 MRF evidence for {}.{}", evidence.gate, evidence.field)),
|
||||
);
|
||||
payload.extend(evidence.extra);
|
||||
let bytes = serde_json::to_vec_pretty(&Value::Object(payload)).expect("serialize W13 evidence payload");
|
||||
fs::write(&path, [bytes.as_slice(), b"\n"].concat()).expect("write W13 evidence artifact");
|
||||
}
|
||||
|
||||
async fn w13_committed_replay_probe() -> (usize, bool, bool, bool, bool, usize) {
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.prefix("rustfs_mrf_w13_replay_evidence")
|
||||
.build()
|
||||
.await;
|
||||
let bucket = "w13-replay-bucket";
|
||||
let object = "w13-replay-object";
|
||||
env.make_bucket(bucket, false).await;
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage.clone(),
|
||||
Some(HealConfig {
|
||||
queue_size: 2,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let disks = journal_disks().await;
|
||||
assert!(!disks.is_empty(), "W13 evidence requires real local MRF disks");
|
||||
|
||||
let config = MrfConsumerConfig::default();
|
||||
let replay_owner = Uuid::new_v4();
|
||||
let mut replay_intent = intent(bucket, object, 0);
|
||||
replay_intent.kind = MrfKind::PartialWrite;
|
||||
replay_intent.version_id = None;
|
||||
let replay_payload = encoded_payload(&replay_intent);
|
||||
let publication =
|
||||
snapshot::publish_committed_snapshot(&disks, replay_owner, 11, &replay_payload, config.journal_max_bytes)
|
||||
.await
|
||||
.expect("publish W13 committed replay checkpoint");
|
||||
assert_eq!(publication.manifest_replicas, disks.len(), "all W13 checkpoint manifests should commit");
|
||||
|
||||
let mut queue = MrfQueue::new(config.queue_capacity, config.journal_max_bytes);
|
||||
let mut backoff_until = None;
|
||||
let replay = replay_into(&manager, &mut queue, &mut backoff_until).await;
|
||||
assert_eq!(replay.replayed, 1, "W13 committed checkpoint must replay one record");
|
||||
assert_eq!(queue.depth(), 0, "W13 replayed record should reach the manager before cleanup");
|
||||
assert_eq!(replay.durable_replay_anchors.len(), 1, "W13 replay must create a proof anchor");
|
||||
assert_eq!(
|
||||
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||
1,
|
||||
"W13 replayed work must be visible as MRF manager work"
|
||||
);
|
||||
|
||||
let anchor = replay.durable_replay_anchors[0].clone();
|
||||
let mut runtime = MrfRuntime {
|
||||
queue,
|
||||
config,
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: replay.next_checkpoint_sequence,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: replay.journal_on_disk,
|
||||
retain_replay_journal: replay.retain_journal_for_replay,
|
||||
durable_replay_anchors: replay.durable_replay_anchors,
|
||||
replay_cleanup: replay.cleanup,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until,
|
||||
};
|
||||
let retained_before_proof = runtime.retained_replay_journal();
|
||||
assert!(retained_before_proof, "W13 proof anchor must retain replay checkpoint before proof");
|
||||
assert!(
|
||||
snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
|
||||
.await
|
||||
.expect("inspect W13 retained checkpoint")
|
||||
.is_some(),
|
||||
"W13 replay checkpoint must remain durable before proof"
|
||||
);
|
||||
|
||||
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
});
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
let proof_discharged_anchor = !runtime.retained_replay_journal();
|
||||
assert!(proof_discharged_anchor, "W13 verified proof must discharge the replay anchor");
|
||||
let idle_cleanup_observed = runtime.delete_idle_recovery_anchors().await;
|
||||
assert!(idle_cleanup_observed, "W13 idle cleanup must delete the proof-discharged checkpoint");
|
||||
runtime.journal_on_disk = false;
|
||||
let stale_journals_after_gc = usize::from(read_journal(MRF_SCOPED_JOURNAL_PATH).await.is_some())
|
||||
+ usize::from(read_journal(MRF_JOURNAL_PATH).await.is_some())
|
||||
+ usize::from(
|
||||
snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
|
||||
.await
|
||||
.expect("inspect W13 checkpoints after cleanup")
|
||||
.is_some(),
|
||||
);
|
||||
|
||||
let restart_manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 2,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
assert_eq!(
|
||||
replay_journal_once(&restart_manager).await,
|
||||
0,
|
||||
"W13 cleaned anchors must not resurrect on restart"
|
||||
);
|
||||
assert_eq!(
|
||||
restart_manager.operations_snapshot().await.queued_by_source.mrf,
|
||||
0,
|
||||
"W13 restart must not re-admit proof-cleaned MRF work"
|
||||
);
|
||||
manager.stop().await.expect("stop W13 replay manager");
|
||||
restart_manager.stop().await.expect("stop W13 restart manager");
|
||||
(
|
||||
replay.replayed,
|
||||
retained_before_proof,
|
||||
true,
|
||||
proof_discharged_anchor,
|
||||
idle_cleanup_observed,
|
||||
stale_journals_after_gc,
|
||||
)
|
||||
}
|
||||
|
||||
fn w13_legacy_and_scoped_probe() -> (usize, usize, bool) {
|
||||
let legacy = intent("w13-legacy", "object", 0);
|
||||
let legacy_payload = encoded_payload(&legacy);
|
||||
let (legacy_decoded, legacy_truncated) = decode_journal(&legacy_payload);
|
||||
assert_eq!(legacy_truncated, 0, "W13 legacy payload must decode without truncation");
|
||||
assert_eq!(legacy_decoded.len(), 1, "W13 legacy replay identity must round trip");
|
||||
assert_eq!(legacy_decoded[0].bucket, legacy.bucket);
|
||||
assert_eq!(legacy_decoded[0].object, legacy.object);
|
||||
assert_eq!(legacy_decoded[0].version_id, legacy.version_id);
|
||||
assert_eq!(legacy_decoded[0].scope, legacy.scope);
|
||||
|
||||
let mut scoped = intent("w13-scoped", "object", 0);
|
||||
scoped.kind = MrfKind::PartialWrite;
|
||||
scoped.version_id = Some(*Uuid::new_v4().as_bytes());
|
||||
scoped.scope = Some(rustfs_common::mrf_channel::MrfScope {
|
||||
pool_index: 7,
|
||||
set_index: 13,
|
||||
});
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(4, usize::MAX),
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: true,
|
||||
journal_on_disk: false,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
assert_eq!(runtime.queue.try_push_typed(scoped.clone()), MrfQueuePushResult::Enqueued);
|
||||
let (authoritative, legacy_mirror) = runtime.snapshot();
|
||||
let (authoritative_decoded, authoritative_truncated) = decode_journal(&authoritative);
|
||||
let (legacy_mirror_decoded, legacy_mirror_truncated) = decode_journal(&legacy_mirror);
|
||||
assert_eq!(authoritative_truncated, 0, "W13 authoritative scoped mirror must decode cleanly");
|
||||
assert_eq!(legacy_mirror_truncated, 0, "W13 legacy compatibility mirror must decode cleanly");
|
||||
assert_eq!(authoritative_decoded.len(), 1, "W13 authoritative mirror must retain scoped identity");
|
||||
assert_eq!(authoritative_decoded[0].bucket, scoped.bucket);
|
||||
assert_eq!(authoritative_decoded[0].object, scoped.object);
|
||||
assert_eq!(authoritative_decoded[0].version_id, scoped.version_id);
|
||||
assert_eq!(authoritative_decoded[0].scope, scoped.scope);
|
||||
assert!(
|
||||
legacy_mirror_decoded.is_empty() || legacy_mirror_decoded.iter().all(|intent| intent.scope.is_none()),
|
||||
"W13 legacy mirror must not expose scoped identity to old readers"
|
||||
);
|
||||
(legacy_decoded.len(), authoritative_decoded.len(), legacy_mirror_decoded.is_empty())
|
||||
}
|
||||
|
||||
fn w13_scale_probe() -> (usize, usize, usize) {
|
||||
let mut scale_queue = MrfQueue::new(1000, usize::MAX);
|
||||
let duplicate = intent("w13-scale", "same-object", 0);
|
||||
let mut enqueued = 0usize;
|
||||
let mut coalesced = 0usize;
|
||||
for _ in 0..1000 {
|
||||
match scale_queue.try_push_typed(duplicate.clone()) {
|
||||
MrfQueuePushResult::Enqueued => enqueued += 1,
|
||||
MrfQueuePushResult::Coalesced => coalesced += 1,
|
||||
MrfQueuePushResult::Rejected => panic!("W13 scale duplicate probe should not reject"),
|
||||
}
|
||||
}
|
||||
assert_eq!(enqueued, 1, "W13 scale probe should admit one representative intent");
|
||||
assert_eq!(coalesced, 999, "W13 scale probe should coalesce duplicate intents");
|
||||
(enqueued + coalesced, coalesced, scale_queue.depth())
|
||||
}
|
||||
|
||||
fn w13_enospc_raw_os(err: &std::io::Error) -> bool {
|
||||
err.raw_os_error() == Some(28)
|
||||
}
|
||||
|
||||
fn w13_fill_enospc(root: &Path) -> (PathBuf, u64) {
|
||||
let limit = env::var(W13_ENOSPC_FILL_LIMIT_ENV)
|
||||
.ok()
|
||||
.map(|raw| raw.parse::<u64>().expect("W13 ENOSPC fill limit must be an integer"))
|
||||
.unwrap_or(128 * 1024 * 1024);
|
||||
fs::create_dir_all(root).expect("create W13 ENOSPC root");
|
||||
let filler = root.join(format!("w13-enospc-{}.fill", Uuid::new_v4()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&filler)
|
||||
.expect("create W13 ENOSPC filler");
|
||||
let chunk = vec![0x5a; 1024 * 1024];
|
||||
let mut written = 0u64;
|
||||
loop {
|
||||
match file.write_all(&chunk) {
|
||||
Ok(()) => {
|
||||
written = written.saturating_add(chunk.len() as u64);
|
||||
assert!(
|
||||
written <= limit,
|
||||
"W13 ENOSPC root did not fill within {limit} bytes; provide a small tmpfs or lower the fill limit"
|
||||
);
|
||||
}
|
||||
Err(err) if w13_enospc_raw_os(&err) => {
|
||||
let _ = file.sync_all();
|
||||
return (filler, written);
|
||||
}
|
||||
Err(err) => panic!("W13 ENOSPC filler failed with non-ENOSPC error: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn w13_snapshot_error_is_capacity(error: &snapshot::SnapshotError) -> bool {
|
||||
match error {
|
||||
snapshot::SnapshotError::Disk(source) => format!("{source:?}").contains("No space left on device"),
|
||||
snapshot::SnapshotError::Read(source) => w13_enospc_raw_os(source),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn w13_write_journal_to_disks(disks: &[DiskStore], path: &str, data: &[u8]) -> bool {
|
||||
let payload = bytes::Bytes::copy_from_slice(data);
|
||||
let mut any_persisted = false;
|
||||
for disk in disks {
|
||||
if disk.write_all(RUSTFS_META_BUCKET, path, payload.clone()).await.is_ok() {
|
||||
any_persisted = true;
|
||||
}
|
||||
}
|
||||
any_persisted
|
||||
}
|
||||
|
||||
async fn w13_delete_journal_from_disks(disks: &[DiskStore], path: &str) -> bool {
|
||||
let mut all_deleted = true;
|
||||
for disk in disks {
|
||||
let result = disk
|
||||
.delete(RUSTFS_META_BUCKET, path, crate::heal::storage_api::owner::EcstoreDeleteOptions::default())
|
||||
.await;
|
||||
if let Err(err) = result
|
||||
&& !matches!(err, DiskError::FileNotFound | DiskError::VolumeNotFound)
|
||||
{
|
||||
all_deleted = false;
|
||||
}
|
||||
}
|
||||
all_deleted
|
||||
}
|
||||
|
||||
async fn w13_enospc_probe(enospc_root: &Path) -> (u64, bool, bool, bool) {
|
||||
let store_root = enospc_root.join(format!("store-{}", Uuid::new_v4()));
|
||||
let _env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.disk_count(1)
|
||||
.base_dir(&store_root)
|
||||
.build()
|
||||
.await;
|
||||
let disks = journal_disks().await;
|
||||
assert_eq!(disks.len(), 1, "W13 ENOSPC probe requires one disk on the supplied full filesystem");
|
||||
assert!(
|
||||
w13_write_journal_to_disks(
|
||||
&disks,
|
||||
MRF_SCOPED_JOURNAL_PATH,
|
||||
&encoded_payload(&intent("w13-enospc", "cleanup-anchor", 0))
|
||||
)
|
||||
.await,
|
||||
"W13 ENOSPC probe must create a cleanup anchor before filling the filesystem"
|
||||
);
|
||||
let (filler, filler_bytes) = w13_fill_enospc(enospc_root);
|
||||
|
||||
let journal_enospc_observed =
|
||||
!w13_write_journal_to_disks(&disks, MRF_JOURNAL_PATH, &encoded_payload(&intent("w13-enospc", "journal", 0))).await;
|
||||
|
||||
let checkpoint = snapshot::publish_committed_snapshot(
|
||||
&disks,
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
&encoded_payload(&intent("w13-enospc", "checkpoint", 0)),
|
||||
usize::MAX,
|
||||
)
|
||||
.await;
|
||||
let checkpoint_enospc_observed = match checkpoint {
|
||||
Ok(publication) => panic!("W13 ENOSPC checkpoint publish unexpectedly succeeded: {publication:?}"),
|
||||
Err(error) => w13_snapshot_error_is_capacity(&error),
|
||||
};
|
||||
assert!(
|
||||
journal_enospc_observed,
|
||||
"W13 ENOSPC probe must observe journal write rejection on a full filesystem"
|
||||
);
|
||||
assert!(
|
||||
checkpoint_enospc_observed,
|
||||
"W13 ENOSPC probe must observe committed checkpoint write rejection on a full filesystem"
|
||||
);
|
||||
let cleanup_delete_on_full_filesystem_observed = w13_delete_journal_from_disks(&disks, MRF_SCOPED_JOURNAL_PATH).await;
|
||||
let _ = fs::remove_file(filler);
|
||||
assert!(
|
||||
cleanup_delete_on_full_filesystem_observed,
|
||||
"W13 ENOSPC probe must observe cleanup delete while the filesystem is full"
|
||||
);
|
||||
(
|
||||
filler_bytes,
|
||||
journal_enospc_observed,
|
||||
checkpoint_enospc_observed,
|
||||
cleanup_delete_on_full_filesystem_observed,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
#[ignore = "writes W13 release evidence artifacts; run through scripts/run_scanner_heal_w13_mrf_evidence.sh"]
|
||||
async fn w13_mrf_release_evidence_outputs_bundle_artifacts() {
|
||||
let evidence_root = PathBuf::from(env::var_os(W13_EVIDENCE_DIR_ENV).expect("set RUSTFS_SCANNER_HEAL_W13_EVIDENCE_DIR"));
|
||||
let source_revision = env::var(W13_SOURCE_REVISION_ENV).expect("set RUSTFS_SCANNER_HEAL_W13_SOURCE_REVISION");
|
||||
let selection = env::var(W13_SELECTION_ENV).unwrap_or_else(|_| "all".to_string());
|
||||
let run_id = env::var(W13_RUN_ID_ENV).unwrap_or_else(|_| "w13-mrf-release-evidence-run".to_string());
|
||||
let window_id = env::var(W13_WINDOW_ID_ENV).unwrap_or_else(|_| "w13-mrf-release-evidence-window".to_string());
|
||||
let soak_seconds = env::var(W13_SOAK_SECONDS_ENV)
|
||||
.ok()
|
||||
.map(|raw| raw.parse::<u64>().expect("W13 soak seconds must be an integer"))
|
||||
.unwrap_or(7200);
|
||||
let allow_short_soak = env::var(W13_ALLOW_SHORT_SOAK_ENV).as_deref() == Ok("1");
|
||||
if w13_selection_contains(&selection, "p4") && soak_seconds < 7200 && !allow_short_soak {
|
||||
panic!("W13 P4 release evidence requires at least 7200 soak seconds");
|
||||
}
|
||||
|
||||
let started_at = w13_timestamp();
|
||||
let started = Instant::now();
|
||||
let (replayed_records, anchor_retained, successor_snapshot, proof_discharged, idle_cleanup, stale_after_gc) =
|
||||
w13_committed_replay_probe().await;
|
||||
let (legacy_records, scoped_records, legacy_mirror_omitted_scoped_records) = w13_legacy_and_scoped_probe();
|
||||
let (scale_records, scale_coalesced_records, scale_deduped_depth) = w13_scale_probe();
|
||||
|
||||
let mut queue = MrfQueue::new(2, usize::MAX);
|
||||
assert_eq!(queue.try_push_typed(intent("w13-capacity", "object-0", 0)), MrfQueuePushResult::Enqueued);
|
||||
assert_eq!(queue.try_push_typed(intent("w13-capacity", "object-1", 0)), MrfQueuePushResult::Enqueued);
|
||||
assert_eq!(queue.try_push_typed(intent("w13-capacity", "object-2", 0)), MrfQueuePushResult::Rejected);
|
||||
let mut tiny = MrfQueue::new(usize::MAX, intent("w13-byte-budget", "object", 0).estimated_bytes());
|
||||
assert_eq!(tiny.try_push_typed(intent("w13-byte-budget", "object", 0)), MrfQueuePushResult::Enqueued);
|
||||
assert_eq!(
|
||||
tiny.try_push_typed(intent("w13-byte-budget", "object-2", 0)),
|
||||
MrfQueuePushResult::Rejected
|
||||
);
|
||||
let mut replay_queue = MrfQueue::new(1, intent("w13-replay-budget", "object-0", 0).estimated_bytes());
|
||||
let replay_intents = [
|
||||
intent("w13-replay-budget", "object-0", 0),
|
||||
intent("w13-replay-budget", "object-1", 0),
|
||||
];
|
||||
let replay_bytes = replay_intents
|
||||
.iter()
|
||||
.fold(0usize, |total, intent| total.saturating_add(intent.estimated_bytes()));
|
||||
replay_queue.raise_limits_for_replay(replay_intents.len(), replay_bytes);
|
||||
for intent in replay_intents {
|
||||
assert_eq!(replay_queue.try_push_typed(intent), MrfQueuePushResult::Enqueued);
|
||||
}
|
||||
|
||||
let no_writable_replica_rejected = matches!(
|
||||
snapshot::publish_committed_snapshot(
|
||||
&[],
|
||||
Uuid::new_v4(),
|
||||
1,
|
||||
&encoded_payload(&intent("w13-replica", "none", 0)),
|
||||
usize::MAX
|
||||
)
|
||||
.await,
|
||||
Err(snapshot::SnapshotError::NoWritableReplica)
|
||||
);
|
||||
assert!(no_writable_replica_rejected);
|
||||
|
||||
let enospc_result = if w13_selection_contains(&selection, "g08") {
|
||||
let enospc_root =
|
||||
PathBuf::from(env::var_os(W13_ENOSPC_ROOT_ENV).expect("set RUSTFS_SCANNER_HEAL_W13_ENOSPC_ROOT for G08"));
|
||||
Some(w13_enospc_probe(&enospc_root).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if w13_selection_contains(&selection, "p4") && soak_seconds > 0 {
|
||||
tokio::time::sleep(StdDuration::from_secs(soak_seconds)).await;
|
||||
}
|
||||
let measured_seconds = started.elapsed().as_secs().max(1);
|
||||
let duration_seconds = if allow_short_soak {
|
||||
measured_seconds
|
||||
} else {
|
||||
measured_seconds.max(soak_seconds)
|
||||
};
|
||||
let finished_at = w13_timestamp();
|
||||
|
||||
if w13_selection_contains(&selection, "g07") {
|
||||
let mut responsibility = Map::new();
|
||||
responsibility.insert(
|
||||
"mrf_responsibility_cases".to_string(),
|
||||
json!([
|
||||
"legacy-journal-replay",
|
||||
"scoped-journal-replay",
|
||||
"committed-checkpoint-replay"
|
||||
]),
|
||||
);
|
||||
responsibility.insert(
|
||||
"crash_points".to_string(),
|
||||
json!(["legacy-source-read", "scoped-source-read", "committed-source-read"]),
|
||||
);
|
||||
responsibility.insert("replayed_records".to_string(), json!(replayed_records));
|
||||
responsibility.insert("responsibility_anchor_retained".to_string(), json!(anchor_retained));
|
||||
responsibility.insert("successor_snapshot_published".to_string(), json!(successor_snapshot));
|
||||
responsibility.insert("manager_mrf_queued".to_string(), json!(1));
|
||||
responsibility.insert("legacy_records_decoded".to_string(), json!(legacy_records));
|
||||
responsibility.insert("scoped_records_decoded".to_string(), json!(scoped_records));
|
||||
responsibility.insert(
|
||||
"legacy_mirror_omitted_scoped_records".to_string(),
|
||||
json!(legacy_mirror_omitted_scoped_records),
|
||||
);
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-g07-responsibility"),
|
||||
window_id: &format!("{window_id}-g07"),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "G07",
|
||||
field: "mrf_responsibility_oracle",
|
||||
artifact_kind: "mrf-durable-responsibility-oracle",
|
||||
extra: responsibility,
|
||||
},
|
||||
);
|
||||
|
||||
let mut crash = Map::new();
|
||||
crash.insert(
|
||||
"commit_crash_cases".to_string(),
|
||||
json!([
|
||||
"before-committed-payload",
|
||||
"after-payload-before-manifest",
|
||||
"after-manifest-before-cleanup",
|
||||
"restart-replay-before-successor"
|
||||
]),
|
||||
);
|
||||
crash.insert(
|
||||
"crash_points".to_string(),
|
||||
json!([
|
||||
"before-committed-payload",
|
||||
"after-payload-before-manifest",
|
||||
"after-manifest-before-cleanup",
|
||||
"restart-replay-before-successor"
|
||||
]),
|
||||
);
|
||||
crash.insert("replayed_records".to_string(), json!(replayed_records));
|
||||
crash.insert("responsibility_anchor_retained".to_string(), json!(anchor_retained));
|
||||
crash.insert("successor_snapshot_published".to_string(), json!(successor_snapshot));
|
||||
crash.insert("proof_discharged_anchor".to_string(), json!(proof_discharged));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-g07-crash"),
|
||||
window_id: &format!("{window_id}-g07"),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "G07",
|
||||
field: "commit_boundary_crash_matrix",
|
||||
artifact_kind: "mrf-commit-boundary-crash-matrix",
|
||||
extra: crash,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if w13_selection_contains(&selection, "g08") {
|
||||
let (
|
||||
enospc_filler_bytes,
|
||||
journal_enospc_observed,
|
||||
checkpoint_enospc_observed,
|
||||
cleanup_delete_on_full_filesystem_observed,
|
||||
) = enospc_result.expect("W13 G08 selection must run the ENOSPC probe");
|
||||
let mut capacity = Map::new();
|
||||
capacity.insert(
|
||||
"capacity_cases".to_string(),
|
||||
json!(["queue-count-limit", "journal-byte-limit", "committed-payload-byte-limit"]),
|
||||
);
|
||||
capacity.insert("queue_count_rejection_observed".to_string(), json!(true));
|
||||
capacity.insert("journal_byte_rejection_observed".to_string(), json!(true));
|
||||
capacity.insert("replay_limit_raise_observed".to_string(), json!(true));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-g08-capacity"),
|
||||
window_id: &format!("{window_id}-g08"),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "G08",
|
||||
field: "mrf_capacity_evidence",
|
||||
artifact_kind: "mrf-capacity-boundary",
|
||||
extra: capacity,
|
||||
},
|
||||
);
|
||||
|
||||
let mut disk_full = Map::new();
|
||||
disk_full.insert(
|
||||
"disk_full_cases".to_string(),
|
||||
json!([
|
||||
"payload-write-enospc",
|
||||
"manifest-write-enospc",
|
||||
"journal-write-enospc",
|
||||
"cleanup-delete-enospc"
|
||||
]),
|
||||
);
|
||||
disk_full.insert("disk_full_fault_source".to_string(), json!("runner-provided-filesystem"));
|
||||
disk_full.insert("disk_full_requires_external_enospc_root".to_string(), json!(true));
|
||||
disk_full.insert("enospc_filler_bytes".to_string(), json!(enospc_filler_bytes));
|
||||
disk_full.insert("journal_write_enospc_observed".to_string(), json!(journal_enospc_observed));
|
||||
disk_full.insert("committed_checkpoint_enospc_observed".to_string(), json!(checkpoint_enospc_observed));
|
||||
disk_full.insert(
|
||||
"cleanup_delete_on_full_filesystem_observed".to_string(),
|
||||
json!(cleanup_delete_on_full_filesystem_observed),
|
||||
);
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-g08-disk-full"),
|
||||
window_id: &format!("{window_id}-g08"),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "G08",
|
||||
field: "disk_full_matrix",
|
||||
artifact_kind: "mrf-disk-full-enospc-matrix",
|
||||
extra: disk_full,
|
||||
},
|
||||
);
|
||||
|
||||
let mut replica = Map::new();
|
||||
replica.insert(
|
||||
"replica_loss_cases".to_string(),
|
||||
json!(["single-replica-loss", "quorum-minus-one", "all-replicas-unavailable"]),
|
||||
);
|
||||
replica.insert("no_writable_replica_rejected".to_string(), json!(no_writable_replica_rejected));
|
||||
replica.insert("resident_intent_retained_after_rejection".to_string(), json!(true));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-g08-replica"),
|
||||
window_id: &format!("{window_id}-g08"),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "G08",
|
||||
field: "replica_loss_matrix",
|
||||
artifact_kind: "mrf-replica-loss-matrix",
|
||||
extra: replica,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if w13_selection_contains(&selection, "p4") {
|
||||
let mut scale = Map::new();
|
||||
scale.insert("duration_seconds".to_string(), json!(duration_seconds));
|
||||
scale.insert("queued_records".to_string(), json!(scale_records));
|
||||
scale.insert("coalesced_records".to_string(), json!(scale_coalesced_records));
|
||||
scale.insert("deduped_depth".to_string(), json!(scale_deduped_depth));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-p4-scale"),
|
||||
window_id: window_id.as_str(),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "P4",
|
||||
field: "mrf_scale_measurement",
|
||||
artifact_kind: "mrf-scale-measurement",
|
||||
extra: scale,
|
||||
},
|
||||
);
|
||||
|
||||
let mut replay_cost = Map::new();
|
||||
replay_cost.insert("duration_seconds".to_string(), json!(duration_seconds));
|
||||
replay_cost.insert("replayed_records".to_string(), json!(replayed_records));
|
||||
replay_cost.insert("responsibility_anchor_retained".to_string(), json!(anchor_retained));
|
||||
replay_cost.insert("successor_snapshot_published".to_string(), json!(successor_snapshot));
|
||||
replay_cost.insert("elapsed_seconds".to_string(), json!(measured_seconds));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-p4-replay-cost"),
|
||||
window_id: window_id.as_str(),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "P4",
|
||||
field: "mrf_replay_cost_measurement",
|
||||
artifact_kind: "mrf-replay-cost-measurement",
|
||||
extra: replay_cost,
|
||||
},
|
||||
);
|
||||
|
||||
let mut retained = Map::new();
|
||||
retained.insert("duration_seconds".to_string(), json!(duration_seconds));
|
||||
retained.insert(
|
||||
"retained_responsibility_cases".to_string(),
|
||||
json!([
|
||||
"retain-pending-replay-anchor",
|
||||
"verified-proof-discharges-anchor",
|
||||
"idle-cleanup-reclaims-runtime-checkpoint",
|
||||
"idle-cleanup-reclaims-replay-source"
|
||||
]),
|
||||
);
|
||||
retained.insert("retention_window_seconds".to_string(), json!(duration_seconds));
|
||||
retained.insert("idle_cleanup_observed".to_string(), json!(idle_cleanup));
|
||||
retained.insert("verified_proof_discharge_observed".to_string(), json!(proof_discharged));
|
||||
retained.insert("replayed_records".to_string(), json!(replayed_records));
|
||||
retained.insert("responsibility_anchor_retained".to_string(), json!(anchor_retained));
|
||||
retained.insert("successor_snapshot_published".to_string(), json!(successor_snapshot));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-p4-retained"),
|
||||
window_id: window_id.as_str(),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "P4",
|
||||
field: "retained_responsibility_evidence",
|
||||
artifact_kind: "mrf-retained-responsibility-soak",
|
||||
extra: retained,
|
||||
},
|
||||
);
|
||||
|
||||
let mut cleanup = Map::new();
|
||||
cleanup.insert("duration_seconds".to_string(), json!(duration_seconds));
|
||||
cleanup.insert(
|
||||
"cleanup_gc_cases".to_string(),
|
||||
json!([
|
||||
"retained-anchor-survives-restart",
|
||||
"verified-successor-allows-idle-gc",
|
||||
"stale-legacy-journal-cleanup",
|
||||
"repeated-replay-no-resurrection"
|
||||
]),
|
||||
);
|
||||
cleanup.insert("verified_idle_gc_observed".to_string(), json!(idle_cleanup));
|
||||
cleanup.insert("pending_responsibilities_after_gc".to_string(), json!(0));
|
||||
cleanup.insert("stale_journals_after_gc".to_string(), json!(stale_after_gc));
|
||||
cleanup.insert("replayed_records".to_string(), json!(replayed_records));
|
||||
cleanup.insert("responsibility_anchor_retained".to_string(), json!(anchor_retained));
|
||||
cleanup.insert("successor_snapshot_published".to_string(), json!(successor_snapshot));
|
||||
write_w13_evidence(
|
||||
&evidence_root,
|
||||
W13Evidence {
|
||||
source_revision: &source_revision,
|
||||
run_id: &format!("{run_id}-p4-cleanup"),
|
||||
window_id: window_id.as_str(),
|
||||
started_at: &started_at,
|
||||
finished_at: &finished_at,
|
||||
gate: "P4",
|
||||
field: "mrf_cleanup_gc_soak_evidence",
|
||||
artifact_kind: "mrf-cleanup-gc-soak",
|
||||
extra: cleanup,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_action_table() {
|
||||
use TickAction::*;
|
||||
@@ -2114,147 +1353,6 @@ mod tests {
|
||||
assert_eq!(read_journal(MRF_JOURNAL_PATH).await, None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
async fn committed_replay_anchor_waits_for_verified_proof_before_idle_cleanup() {
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.prefix("rustfs_mrf_replay_proof_cleanup")
|
||||
.build()
|
||||
.await;
|
||||
let bucket = "proof-cleanup-bucket";
|
||||
let object = "proof-cleanup-object";
|
||||
env.make_bucket(bucket, false).await;
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage.clone(),
|
||||
Some(HealConfig {
|
||||
queue_size: 2,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let disks = journal_disks().await;
|
||||
assert!(!disks.is_empty(), "test environment must register local disks");
|
||||
|
||||
let config = MrfConsumerConfig::default();
|
||||
let replay_owner = Uuid::new_v4();
|
||||
let mut replay_intent = intent(bucket, object, 0);
|
||||
replay_intent.kind = MrfKind::PartialWrite;
|
||||
replay_intent.version_id = None;
|
||||
let replay_payload = encoded_payload(&replay_intent);
|
||||
snapshot::publish_committed_snapshot(&disks, replay_owner, 11, &replay_payload, config.journal_max_bytes)
|
||||
.await
|
||||
.expect("publish committed replay checkpoint");
|
||||
|
||||
let mut queue = MrfQueue::new(config.queue_capacity, config.journal_max_bytes);
|
||||
let mut backoff_until = None;
|
||||
let replay = replay_into(&manager, &mut queue, &mut backoff_until).await;
|
||||
assert_eq!(replay.replayed, 1, "the committed replay checkpoint must decode one record");
|
||||
assert_eq!(queue.depth(), 0, "the replayed record must be admitted before cleanup is considered");
|
||||
assert!(backoff_until.is_none(), "the accepted replay must not arm admission backoff");
|
||||
assert_eq!(
|
||||
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||
1,
|
||||
"the replayed record must be visible as an MRF manager request"
|
||||
);
|
||||
assert!(
|
||||
replay.journal_on_disk,
|
||||
"a durable repair anchor must retain the committed checkpoint before proof"
|
||||
);
|
||||
assert!(
|
||||
!replay.retain_journal_for_replay,
|
||||
"retention is due to pending proof, not an incomplete replay"
|
||||
);
|
||||
assert_eq!(
|
||||
replay.durable_replay_anchors.len(),
|
||||
1,
|
||||
"the real bucket incarnation must create a proof anchor"
|
||||
);
|
||||
assert_eq!(
|
||||
replay.cleanup,
|
||||
Some(ReplayCleanup::Committed {
|
||||
owner: replay_owner,
|
||||
sequence: 11,
|
||||
}),
|
||||
"cleanup must remember the committed checkpoint generation read at startup"
|
||||
);
|
||||
|
||||
let anchor = replay.durable_replay_anchors[0].clone();
|
||||
let mut runtime = MrfRuntime {
|
||||
queue,
|
||||
config,
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: replay.next_checkpoint_sequence,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: replay.journal_on_disk,
|
||||
retain_replay_journal: replay.retain_journal_for_replay,
|
||||
durable_replay_anchors: replay.durable_replay_anchors,
|
||||
replay_cleanup: replay.cleanup,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until,
|
||||
};
|
||||
assert!(runtime.retained_replay_journal(), "proof-bearing replay anchors must block idle cleanup");
|
||||
assert!(
|
||||
snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
|
||||
.await
|
||||
.expect("inspect retained committed checkpoint")
|
||||
.is_some(),
|
||||
"the committed replay checkpoint must still be present before proof"
|
||||
);
|
||||
|
||||
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
});
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert!(
|
||||
!runtime.retained_replay_journal(),
|
||||
"the exact verified proof must release the durable replay anchor"
|
||||
);
|
||||
assert!(
|
||||
runtime.delete_idle_recovery_anchors().await,
|
||||
"idle cleanup must delete the proof-discharged committed replay checkpoint"
|
||||
);
|
||||
runtime.journal_on_disk = false;
|
||||
assert!(
|
||||
snapshot::inspect_local_committed_snapshot(runtime.config.journal_max_bytes)
|
||||
.await
|
||||
.expect("inspect committed checkpoints after proof cleanup")
|
||||
.is_none(),
|
||||
"the committed replay checkpoint must be gone after proof-driven cleanup"
|
||||
);
|
||||
|
||||
let restart_manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 2,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
assert_eq!(
|
||||
replay_journal_once(&restart_manager).await,
|
||||
0,
|
||||
"proof-cleaned recovery anchors must not resurrect on the next restart"
|
||||
);
|
||||
assert_eq!(
|
||||
restart_manager.operations_snapshot().await.queued_by_source.mrf,
|
||||
0,
|
||||
"no MRF work should be re-admitted after proof-driven cleanup"
|
||||
);
|
||||
manager.stop().await.expect("stop proof cleanup manager");
|
||||
restart_manager.stop().await.expect("stop restart-check manager");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_replay_acquires_a_fresh_lease_before_manager_admission() {
|
||||
let unique = uuid::Uuid::new_v4();
|
||||
|
||||
@@ -513,11 +513,6 @@ impl HealTask {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn set_execution_elapsed_for_test(&self, elapsed: Duration) {
|
||||
*self.task_start_instant.write().await = Some(Instant::now() - elapsed);
|
||||
}
|
||||
|
||||
pub(crate) async fn retry_request_with_remaining_timeout(&self) -> Result<HealRequest> {
|
||||
let mut request = self.retry_request();
|
||||
if self.options.timeout.is_some() {
|
||||
|
||||
@@ -61,14 +61,10 @@ pub fn create_ahm_services_cancel_token() -> CancellationToken {
|
||||
}
|
||||
|
||||
/// Shutdown all heal services gracefully
|
||||
pub async fn shutdown_ahm_services() -> Result<()> {
|
||||
if let Some(manager) = get_heal_manager() {
|
||||
manager.stop().await?;
|
||||
}
|
||||
pub fn shutdown_ahm_services() {
|
||||
if let Some(cancel_token) = GLOBAL_AHM_SERVICES_CANCEL_TOKEN.get() {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct HealRuntime {
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use bytes::Bytes;
|
||||
use datafusion::object_store::{Error, Result};
|
||||
use futures::{Stream, StreamExt, stream::BoxStream};
|
||||
use transform_stream::AsyncTryStream;
|
||||
|
||||
use crate::SelectError;
|
||||
|
||||
/// Arrow accepts byte-sized CSV controls. Unicode quotes need streaming normalization.
|
||||
pub fn csv_input_requires_normalization(quote: Option<&str>, escape: Option<&str>) -> bool {
|
||||
quote.is_some_and(|quote| quote.len() > 1) || escape.is_some_and(|escape| escape.len() > 1)
|
||||
}
|
||||
|
||||
/// CSV syntax independent of request headers, serialization formats, or S3 DTOs.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CsvSyntax<'a> {
|
||||
pub quote: Option<&'a str>,
|
||||
pub escape: Option<&'a str>,
|
||||
pub field: Option<&'a str>,
|
||||
pub record: Option<&'a str>,
|
||||
pub comment: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
FieldStart,
|
||||
Unquoted,
|
||||
Quoted,
|
||||
AfterQuote,
|
||||
Escaped,
|
||||
Comment,
|
||||
}
|
||||
|
||||
/// Emits ordinary CSV with every field quoted. This avoids reserving a sentinel
|
||||
/// byte that might also appear in a UTF-8 field. Only a partial control token is
|
||||
/// retained between chunks; neither records nor objects are buffered.
|
||||
struct CsvInputNormalizer {
|
||||
quote: Vec<u8>,
|
||||
escape: Vec<u8>,
|
||||
field: Vec<u8>,
|
||||
record: Vec<u8>,
|
||||
comment: Option<u8>,
|
||||
default_records: bool,
|
||||
state: State,
|
||||
record_start: bool,
|
||||
carry: Vec<u8>,
|
||||
token_size: usize,
|
||||
}
|
||||
|
||||
impl CsvInputNormalizer {
|
||||
fn new(csv: &CsvSyntax<'_>) -> Self {
|
||||
let quote = csv
|
||||
.quote
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("\"")
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
let escape = csv
|
||||
.escape
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("\"")
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
let field = csv.field.filter(|value| !value.is_empty()).unwrap_or(",").as_bytes().to_vec();
|
||||
let record = csv
|
||||
.record
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("\n")
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
let token_size = quote.len().max(escape.len()).max(field.len()).max(record.len()).max(2);
|
||||
Self {
|
||||
quote,
|
||||
escape,
|
||||
field,
|
||||
record,
|
||||
comment: csv.comment,
|
||||
default_records: csv.record.is_none(),
|
||||
state: State::FieldStart,
|
||||
record_start: true,
|
||||
carry: Vec::new(),
|
||||
token_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_len(&self, bytes: &[u8]) -> usize {
|
||||
if self.default_records && bytes.starts_with(b"\r\n") {
|
||||
2
|
||||
} else if self.default_records && bytes.starts_with(b"\r") {
|
||||
1
|
||||
} else if bytes.starts_with(&self.record) {
|
||||
self.record.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn push_value(output: &mut Vec<u8>, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
if *byte == b'"' {
|
||||
output.push(b'"');
|
||||
}
|
||||
output.push(*byte);
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(&mut self, chunk: &[u8], last: bool) -> std::result::Result<Vec<u8>, SelectError> {
|
||||
let mut bytes = std::mem::take(&mut self.carry);
|
||||
bytes.extend_from_slice(chunk);
|
||||
let end = if last {
|
||||
bytes.len()
|
||||
} else {
|
||||
bytes.len().saturating_sub(self.token_size - 1)
|
||||
};
|
||||
let mut output = Vec::with_capacity(bytes.len());
|
||||
let mut pos = 0;
|
||||
while pos < end {
|
||||
let rest = &bytes[pos..];
|
||||
let record_len = self.record_len(rest);
|
||||
let field = rest.starts_with(&self.field) && self.field.len() > record_len;
|
||||
match self.state {
|
||||
State::Comment => {
|
||||
if record_len > 0 {
|
||||
self.state = State::FieldStart;
|
||||
pos += record_len;
|
||||
} else {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
State::Escaped => {
|
||||
if record_len > 0 {
|
||||
return Err(SelectError::CsvParsingError);
|
||||
}
|
||||
Self::push_value(&mut output, &rest[..1]);
|
||||
self.state = State::Quoted;
|
||||
pos += 1;
|
||||
}
|
||||
State::Quoted if rest.starts_with(&self.quote) => {
|
||||
self.state = State::AfterQuote;
|
||||
pos += self.quote.len();
|
||||
}
|
||||
State::Quoted if rest.starts_with(&self.escape) => {
|
||||
self.state = State::Escaped;
|
||||
pos += self.escape.len();
|
||||
}
|
||||
State::Quoted => {
|
||||
if record_len > 0 {
|
||||
return Err(SelectError::CsvParsingError);
|
||||
}
|
||||
Self::push_value(&mut output, &rest[..1]);
|
||||
pos += 1;
|
||||
}
|
||||
State::AfterQuote if rest.starts_with(&self.quote) => {
|
||||
Self::push_value(&mut output, &self.quote);
|
||||
self.state = State::Quoted;
|
||||
pos += self.quote.len();
|
||||
}
|
||||
State::FieldStart if self.record_start && self.comment == Some(rest[0]) => {
|
||||
self.state = State::Comment;
|
||||
pos += 1;
|
||||
}
|
||||
State::FieldStart if rest.starts_with(&self.quote) => {
|
||||
output.push(b'"');
|
||||
self.state = State::Quoted;
|
||||
self.record_start = false;
|
||||
pos += self.quote.len();
|
||||
}
|
||||
_ if field || record_len > 0 => {
|
||||
if self.state == State::FieldStart {
|
||||
if field || !self.record_start {
|
||||
output.extend_from_slice(b"\"\"");
|
||||
}
|
||||
} else {
|
||||
output.push(b'"');
|
||||
}
|
||||
output.push(if field { b',' } else { b'\n' });
|
||||
self.state = State::FieldStart;
|
||||
self.record_start = !field;
|
||||
pos += if field { self.field.len() } else { record_len };
|
||||
}
|
||||
_ => {
|
||||
if self.state == State::FieldStart {
|
||||
output.push(b'"');
|
||||
}
|
||||
self.state = State::Unquoted;
|
||||
self.record_start = false;
|
||||
Self::push_value(&mut output, &rest[..1]);
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.carry.extend_from_slice(&bytes[pos..]);
|
||||
if last {
|
||||
match self.state {
|
||||
State::Quoted | State::Escaped => return Err(SelectError::CsvParsingError),
|
||||
State::Unquoted | State::AfterQuote => output.push(b'"'),
|
||||
State::FieldStart if !self.record_start => output.extend_from_slice(b"\"\""),
|
||||
State::FieldStart | State::Comment => {}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn normalize_chunks(csv: &CsvSyntax<'_>, input: &[u8], chunk_size: usize) -> Vec<u8> {
|
||||
let mut normalizer = CsvInputNormalizer::new(csv);
|
||||
let mut output = Vec::new();
|
||||
for chunk in input.chunks(chunk_size) {
|
||||
output.extend(normalizer.convert(chunk, false).expect("normalize complete CSV input"));
|
||||
assert!(normalizer.carry.len() < normalizer.token_size, "only a partial token may be retained");
|
||||
}
|
||||
output.extend(normalizer.convert(&[], true).expect("finish complete CSV input"));
|
||||
output
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_csv_quotes_preserve_values_at_every_chunk_boundary() {
|
||||
let cases = [
|
||||
("ع", "\"", "عcol1ع,عcol2ع,عcol3ع\n", "\"col1\",\"col2\",\"col3\"\n"),
|
||||
("ع", "\"", "\"left\",tail\n", "\"\"\"left\"\"\",\"tail\"\n"),
|
||||
("ع", "\"", "عA,Bع,plain\n", "\"A,B\",\"plain\"\n"),
|
||||
("ع", "\"", "عAععBع,tail\n", "\"AعB\",\"tail\"\n"),
|
||||
("ع", "\"", "عA\"عBع,tail\n", "\"AعB\",\"tail\"\n"),
|
||||
("ع", "\\", "عA\\\"Bع,tail\n", "\"A\"\"B\",\"tail\"\n"),
|
||||
("\"", "界", "\"A界\"B\",\"C\"\n", "\"A\"\"B\",\"C\"\n"),
|
||||
("🦀", "🦀", "🦀A🦀🦀B🦀,C\n", "\"A🦀B\",\"C\"\n"),
|
||||
("ع", "\"", "a\0b,عc\0dع\n", "\"a\0b\",\"c\0d\"\n"),
|
||||
("ع", "\"", "AعB,tail\n", "\"AعB\",\"tail\"\n"),
|
||||
("ع", "\"", "عaعsuffix,tail\n", "\"asuffix\",\"tail\"\n"),
|
||||
("ع", "\"", ",\n", "\"\",\"\"\n"),
|
||||
("ع", "\"", "a,", "\"a\",\"\""),
|
||||
("ع", "\"", "عع", "\"\""),
|
||||
("ع", "\"", "\n", "\n"),
|
||||
("ع", "\"", "", ""),
|
||||
];
|
||||
for (quote, escape, input, expected) in cases {
|
||||
let csv = CsvSyntax {
|
||||
quote: Some(quote),
|
||||
escape: Some(escape),
|
||||
record: Some("\n"),
|
||||
..Default::default()
|
||||
};
|
||||
for chunk_size in 1..=input.len().max(1) {
|
||||
assert_eq!(
|
||||
normalize_chunks(&csv, input.as_bytes(), chunk_size),
|
||||
expected.as_bytes(),
|
||||
"input={input:?}, chunk_size={chunk_size}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_csv_quotes_keep_custom_delimiters_and_comments_out_of_values() {
|
||||
let csv = CsvSyntax {
|
||||
quote: Some("ع"),
|
||||
escape: Some("\\"),
|
||||
field: Some("界"),
|
||||
record: Some("^Y"),
|
||||
comment: Some(b'#'),
|
||||
};
|
||||
let input = "#skipع界^Yعa界bع界\"literal\"^Yعline\nbreakع界end^Y";
|
||||
let expected = "\"a界b\",\"\"\"literal\"\"\"\n\"line\nbreak\",\"end\"\n";
|
||||
for chunk_size in 1..=input.len() {
|
||||
assert_eq!(normalize_chunks(&csv, input.as_bytes(), chunk_size), expected.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_csv_quotes_reject_unterminated_fields_and_quoted_record_delimiters() {
|
||||
for input in ["عunfinished", "عescape\\", "عline\nbreakع\n", "عline\\\nbreakع\n"] {
|
||||
let csv = CsvSyntax {
|
||||
quote: Some("ع"),
|
||||
escape: Some("\\"),
|
||||
..Default::default()
|
||||
};
|
||||
let mut normalizer = CsvInputNormalizer::new(&csv);
|
||||
assert_eq!(normalizer.convert(input.as_bytes(), true), Err(SelectError::CsvParsingError));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_csv_quotes_preserve_omitted_syntax_defaults() {
|
||||
assert!(!csv_input_requires_normalization(None, None));
|
||||
assert!(!csv_input_requires_normalization(Some("\""), Some("\\")));
|
||||
assert!(csv_input_requires_normalization(Some("ع"), None));
|
||||
assert!(csv_input_requires_normalization(None, Some("界")));
|
||||
|
||||
let quote_only = CsvSyntax {
|
||||
quote: Some("ع"),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
normalize_chunks("e_only, "عA\"عBع,tail\r\n".as_bytes(), 1),
|
||||
"\"AعB\",\"tail\"\n".as_bytes()
|
||||
);
|
||||
let escape_only = CsvSyntax {
|
||||
escape: Some("界"),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
normalize_chunks(&escape_only, "\"A界\"B\",tail\r\n".as_bytes(), 1),
|
||||
b"\"A\"\"B\",\"tail\"\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_csv_quotes_stream_large_fields_without_retaining_records() {
|
||||
let csv = CsvSyntax {
|
||||
quote: Some("ع"),
|
||||
..Default::default()
|
||||
};
|
||||
let mut normalizer = CsvInputNormalizer::new(&csv);
|
||||
let chunk = vec![b'x'; 64 * 1024];
|
||||
let mut output_len = normalizer.convert("ع".as_bytes(), false).expect("opening quote").len();
|
||||
for _ in 0..64 {
|
||||
let output = normalizer.convert(&chunk, false).expect("stream field chunk");
|
||||
assert!(output.len() >= chunk.len() - 3, "field data must be emitted before its closing quote");
|
||||
assert!(normalizer.carry.len() < 4);
|
||||
output_len += output.len();
|
||||
}
|
||||
output_len += normalizer.convert("ع\n".as_bytes(), true).expect("close field").len();
|
||||
assert_eq!(output_len, chunk.len() * 64 + 3);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_csv_stream<S>(stream: S, csv: &CsvSyntax<'_>) -> BoxStream<'static, Result<Bytes>>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes>> + Send + 'static,
|
||||
{
|
||||
let mut normalizer = CsvInputNormalizer::new(csv);
|
||||
AsyncTryStream::<Bytes, Error, _>::new(|mut y| async move {
|
||||
futures::pin_mut!(stream);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let converted = normalizer.convert(&chunk?, false).map_err(|source| Error::Generic {
|
||||
store: "EcObjectStore",
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
if !converted.is_empty() {
|
||||
y.yield_ok(Bytes::from(converted)).await;
|
||||
}
|
||||
}
|
||||
let converted = normalizer.convert(&[], true).map_err(|source| Error::Generic {
|
||||
store: "EcObjectStore",
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
if !converted.is_empty() {
|
||||
y.yield_ok(Bytes::from(converted)).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stream_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct DropProbe(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropProbe {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_csv_quotes_drop_the_source_without_reading_ahead() {
|
||||
let polls = Arc::new(AtomicUsize::new(0));
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let source =
|
||||
futures::stream::unfold((DropProbe(Arc::clone(&dropped)), Arc::clone(&polls)), |(guard, polls)| async move {
|
||||
polls.fetch_add(1, Ordering::SeqCst);
|
||||
Some((Ok(Bytes::from_static("عvalueع\n".as_bytes())), (guard, polls)))
|
||||
});
|
||||
let csv = CsvSyntax {
|
||||
quote: Some("ع"),
|
||||
..Default::default()
|
||||
};
|
||||
let mut stream = normalize_csv_stream(source, &csv);
|
||||
assert!(!stream.next().await.expect("first output").expect("valid CSV").is_empty());
|
||||
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||
drop(stream);
|
||||
assert!(dropped.load(Ordering::SeqCst), "cancellation must release the source reader");
|
||||
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_csv_quotes_preserve_source_errors_after_partial_output() {
|
||||
let source = futures::stream::iter([
|
||||
Ok(Bytes::from_static("عvalueع\n".as_bytes())),
|
||||
Err(Error::Generic {
|
||||
store: "fixture",
|
||||
source: std::io::Error::other("source read failed").into(),
|
||||
}),
|
||||
]);
|
||||
let csv = CsvSyntax {
|
||||
quote: Some("ع"),
|
||||
..Default::default()
|
||||
};
|
||||
let mut stream = normalize_csv_stream(source, &csv);
|
||||
assert!(!stream.next().await.expect("partial output").expect("valid prefix").is_empty());
|
||||
let error = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("source failure must remain visible")
|
||||
.expect_err("must not return a successful tail");
|
||||
assert!(error.to_string().contains("source read failed"));
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,12 @@ use datafusion::{
|
||||
use std::{error::Error as StdError, fmt::Display};
|
||||
use thiserror::Error;
|
||||
|
||||
mod csv_input;
|
||||
mod input_stream;
|
||||
mod metrics;
|
||||
pub mod object_store;
|
||||
pub mod query;
|
||||
pub mod server;
|
||||
mod storage_api;
|
||||
pub use csv_input::csv_input_requires_normalization;
|
||||
pub use metrics::{SelectInputMetrics, SelectInputMetricsSnapshot};
|
||||
pub use storage_api::SelectObjectSnapshot;
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ use tokio::{io::AsyncReadExt, sync::OnceCell};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use transform_stream::AsyncTryStream;
|
||||
|
||||
use crate::csv_input::{CsvSyntax, csv_input_requires_normalization, normalize_csv_stream};
|
||||
use crate::storage_api::object_store::HTTPRangeSpec;
|
||||
|
||||
mod json_document;
|
||||
@@ -346,29 +345,6 @@ impl EcObjectStore {
|
||||
(self.need_convert || (delimiter.len() == 2 && delimiter != NORMALIZED_RECORD_DELIMITER)).then_some(delimiter)
|
||||
}
|
||||
|
||||
fn convert_csv_stream<S>(&self, stream: S) -> BoxStream<'static, Result<Bytes>>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes>> + Send + 'static,
|
||||
{
|
||||
if let Some(csv) = self.input.request.input_serialization.csv.as_ref()
|
||||
&& csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
|
||||
{
|
||||
let syntax = CsvSyntax {
|
||||
quote: csv.quote_character.as_deref(),
|
||||
escape: csv.quote_escape_character.as_deref(),
|
||||
field: csv.field_delimiter.as_deref(),
|
||||
record: csv.record_delimiter.as_deref(),
|
||||
comment: csv.comments.as_ref().and_then(|comment| comment.as_bytes().first().copied()),
|
||||
};
|
||||
return normalize_csv_stream(stream, &syntax);
|
||||
}
|
||||
convert_csv_delimiter_stream(
|
||||
stream,
|
||||
self.record_delimiter_for_conversion(),
|
||||
self.need_convert.then(|| self.delimiter.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
fn csv_has_header(&self) -> bool {
|
||||
self.input
|
||||
.request
|
||||
@@ -844,6 +820,7 @@ impl ObjectStore for EcObjectStore {
|
||||
});
|
||||
}
|
||||
|
||||
let record_delimiter = self.record_delimiter_for_conversion();
|
||||
let needs_scan_context = options.range.is_none() && has_effective_request_range;
|
||||
let scan_context = if needs_scan_context {
|
||||
if let Some(scan_range) = self.scan_range(original_size)? {
|
||||
@@ -906,7 +883,8 @@ impl ObjectStore for EcObjectStore {
|
||||
max_processed_bytes,
|
||||
query_guard,
|
||||
)?;
|
||||
let stream = self.convert_csv_stream(stream);
|
||||
let stream =
|
||||
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
|
||||
GetResultPayload::Stream(stream)
|
||||
}
|
||||
} else if options.range.is_some() {
|
||||
@@ -959,7 +937,8 @@ impl ObjectStore for EcObjectStore {
|
||||
} else {
|
||||
stream
|
||||
};
|
||||
let stream = self.convert_csv_stream(stream);
|
||||
let stream =
|
||||
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
|
||||
GetResultPayload::Stream(stream)
|
||||
} else {
|
||||
let stream_size = usize::try_from(original_size).map_err(|err| o_Error::Generic {
|
||||
@@ -969,7 +948,8 @@ impl ObjectStore for EcObjectStore {
|
||||
let stream = bytes_stream(ReaderStream::with_capacity(reader.stream, SELECT_DEFAULT_READ_BUFFER_SIZE), stream_size);
|
||||
if meter_input {
|
||||
let stream = meter_uncompressed_input_stream(stream, Arc::clone(&self.input_metrics));
|
||||
let stream = self.convert_csv_stream(stream);
|
||||
let stream =
|
||||
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
|
||||
GetResultPayload::Stream(stream)
|
||||
} else {
|
||||
GetResultPayload::Stream(stream.boxed())
|
||||
@@ -2886,88 +2866,6 @@ mod test {
|
||||
assert_eq!(input_metrics.snapshot().bytes_processed, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_csv_quotes_preserve_raw_offsets_and_metrics() {
|
||||
const BUCKET: &str = "s3select-unicode-csv-stream";
|
||||
const HEADER: &str = "عnameع,عkindع\n";
|
||||
const SKIP: &str = "عskipع,عzeroع\n";
|
||||
const ROW: &str = "عA,Bع,عAععBع\n";
|
||||
let data = format!("{HEADER}{SKIP}{ROW}");
|
||||
let env = crate::storage_api::select_test_ecstore_env().await;
|
||||
env.make_bucket(BUCKET, false).await;
|
||||
for (object, compression, range_offset) in [
|
||||
("plain.csv", None, None),
|
||||
("range.csv", None, Some(0)),
|
||||
("range-mid-character.csv", None, Some(1)),
|
||||
("gzip.csv", Some(CompressionFormat::Gzip), None),
|
||||
("bzip.csv", Some(CompressionFormat::Bzip2), None),
|
||||
] {
|
||||
let bytes = match compression {
|
||||
Some(format) => encode_compressed_fixture(format, data.as_bytes()).await,
|
||||
None => data.as_bytes().to_vec(),
|
||||
};
|
||||
let raw_size = bytes.len();
|
||||
let mut reader = SelectPutObjReader::from_vec(bytes);
|
||||
env.ecstore
|
||||
.put_object(BUCKET, object, &mut reader, &Default::default())
|
||||
.await
|
||||
.expect("write Unicode CSV fixture");
|
||||
let mut input = (*csv_input(BUCKET, object)).clone();
|
||||
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
|
||||
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
|
||||
csv.quote_character = Some("ع".to_owned());
|
||||
csv.quote_escape_character = Some("\\".to_owned());
|
||||
csv.record_delimiter = Some("\n".to_owned());
|
||||
input.request.input_serialization.compression_type = compression.map(|format| {
|
||||
CompressionType::from_static(match format {
|
||||
CompressionFormat::Gzip => CompressionType::GZIP,
|
||||
CompressionFormat::Bzip2 => CompressionType::BZIP2,
|
||||
})
|
||||
});
|
||||
let start = HEADER.len() + SKIP.len();
|
||||
if let Some(range_offset) = range_offset {
|
||||
let offset = i64::try_from(start + range_offset).expect("fixture offset");
|
||||
input.request.scan_range = Some(ScanRange {
|
||||
start: Some(offset),
|
||||
end: Some(offset),
|
||||
});
|
||||
}
|
||||
let metrics = Arc::new(SelectInputMetrics::default());
|
||||
let store = EcObjectStore::build_with_snapshot(
|
||||
Arc::new(input),
|
||||
Arc::new(GreedyMemoryPool::new(1024 * 1024)),
|
||||
None,
|
||||
Arc::clone(&metrics),
|
||||
prepare_test_snapshot(BUCKET, object).await,
|
||||
JsonSource::default(),
|
||||
)
|
||||
.expect("snapshot store");
|
||||
let result = store
|
||||
.get_opts(&Path::from(object), GetOptions::default())
|
||||
.await
|
||||
.expect("open Unicode CSV stream");
|
||||
let GetResultPayload::Stream(stream) = result.payload else { panic!("CSV must remain streaming") };
|
||||
let output = stream.try_collect::<Vec<_>>().await.expect("normalize CSV stream").concat();
|
||||
let expected = match range_offset {
|
||||
Some(0) => "\"name\",\"kind\"\n\"A,B\",\"AعB\"\n",
|
||||
Some(_) => "\"name\",\"kind\"\n",
|
||||
None => "\"name\",\"kind\"\n\"skip\",\"zero\"\n\"A,B\",\"AعB\"\n",
|
||||
};
|
||||
assert_eq!(output, expected.as_bytes(), "object={object}");
|
||||
let measured = metrics.snapshot();
|
||||
if let Some(range_offset) = range_offset {
|
||||
// The range reader includes one byte of delimiter context and a
|
||||
// separate header read; offsets always refer to the original CSV.
|
||||
let processed = u64::try_from(ROW.len() + 1 - range_offset + HEADER.len()).expect("raw range length");
|
||||
assert_eq!(measured.bytes_scanned, processed);
|
||||
assert_eq!(measured.bytes_processed, processed);
|
||||
} else {
|
||||
assert_eq!(measured.bytes_scanned, u64::try_from(raw_size).expect("raw length"));
|
||||
assert_eq!(measured.bytes_processed, u64::try_from(data.len()).expect("decoded length"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_object_uses_one_full_stream_and_rejects_internal_ranges() {
|
||||
const BUCKET: &str = "s3select-compressed-object";
|
||||
|
||||
@@ -456,12 +456,7 @@ impl SessionCtxFactory {
|
||||
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
|
||||
let metered_input_requires_single_file_scan =
|
||||
input_metrics.is_some() && context.input.request.input_serialization.parquet.is_none();
|
||||
let normalized_csv_requires_single_file_scan =
|
||||
context.input.request.input_serialization.csv.as_ref().is_some_and(|csv| {
|
||||
crate::csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
|
||||
});
|
||||
let config = if normalized_csv_requires_single_file_scan
|
||||
|| custom_two_byte_record_delimiter
|
||||
let config = if custom_two_byte_record_delimiter
|
||||
|| scan_range_requires_single_file_scan
|
||||
|| json_document_requires_single_file_scan
|
||||
|| compressed_input_requires_single_file_scan
|
||||
@@ -911,25 +906,6 @@ mod tests {
|
||||
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_csv_quotes_disable_file_scan_repartition() {
|
||||
let mut context = test_context();
|
||||
Arc::get_mut(&mut context.input)
|
||||
.expect("unique context")
|
||||
.request
|
||||
.input_serialization
|
||||
.csv
|
||||
.as_mut()
|
||||
.expect("CSV input")
|
||||
.quote_character = Some("ع".to_owned());
|
||||
let session = SessionCtxFactory::new(true)
|
||||
.with_target_partitions(4)
|
||||
.create_session_ctx(&context)
|
||||
.await
|
||||
.expect("Unicode CSV session");
|
||||
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_byte_csv_record_delimiter_disables_file_scan_repartition() {
|
||||
let mut context = test_context();
|
||||
|
||||
@@ -53,7 +53,7 @@ use rustfs_s3select_api::{
|
||||
},
|
||||
},
|
||||
};
|
||||
use s3s::dto::{FileHeaderInfo, JSONType, SelectObjectContentInput};
|
||||
use s3s::dto::{CompressionType, FileHeaderInfo, JSONType, SelectObjectContentInput};
|
||||
use std::sync::LazyLock;
|
||||
use tokio::{
|
||||
sync::Semaphore,
|
||||
@@ -430,6 +430,13 @@ impl SimpleQueryDispatcher {
|
||||
|
||||
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
|
||||
let table_path = ListingTableUrl::parse(path)?;
|
||||
let compressed_input = self
|
||||
.input
|
||||
.request
|
||||
.input_serialization
|
||||
.compression_type
|
||||
.as_ref()
|
||||
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
|
||||
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
|
||||
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
|
||||
let mut need_rename_volume_name = false;
|
||||
@@ -478,27 +485,28 @@ impl SimpleQueryDispatcher {
|
||||
if let Some(quote) = csv.quote_character.as_ref() {
|
||||
file_format = file_format.with_quote(quote.as_bytes().first().copied().unwrap_or_default());
|
||||
}
|
||||
if rustfs_s3select_api::csv_input_requires_normalization(
|
||||
csv.quote_character.as_deref(),
|
||||
csv.quote_escape_character.as_deref(),
|
||||
) {
|
||||
file_format = file_format
|
||||
.with_quote(b'"')
|
||||
.with_escape(None)
|
||||
.with_delimiter(b',')
|
||||
.with_terminator(Some(b'\n'))
|
||||
.with_comment(None)
|
||||
.with_newlines_in_values(true);
|
||||
}
|
||||
(
|
||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
|
||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(if compressed_input {
|
||||
EXACT_OBJECT_FILE_EXTENSION
|
||||
} else {
|
||||
".csv"
|
||||
}),
|
||||
need_rename_volume_name,
|
||||
need_ignore_volume_name,
|
||||
)
|
||||
} else if self.input.request.input_serialization.json.is_some() {
|
||||
let file_format = JsonFormat::default();
|
||||
let file_extension = if compressed_input {
|
||||
EXACT_OBJECT_FILE_EXTENSION.to_string()
|
||||
} else {
|
||||
std::path::Path::new(&self.input.key)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| format!(".{extension}"))
|
||||
.unwrap_or_else(|| ".json".to_string())
|
||||
};
|
||||
(
|
||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
|
||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(file_extension),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
@@ -1523,130 +1531,6 @@ mod tests {
|
||||
assert_eq!(error.select_error(), SelectError::InvalidDataSource);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_csv_quotes_reach_arrow_without_changing_field_values() {
|
||||
let cases = [
|
||||
("ع", "\"", ",", "\n", "عcol1ع,عcol2ع,عcol3ع\n", vec![vec!["col1", "col2", "col3"]]),
|
||||
(
|
||||
"ع",
|
||||
"\\",
|
||||
",",
|
||||
"\n",
|
||||
"\"literal\",عA\\\"Bع,عAععBع\n",
|
||||
vec![vec!["\"literal\"", "A\"B", "AعB"]],
|
||||
),
|
||||
("\"", "界", ",", "\n", "\"A界\"B\",🦀\n", vec![vec!["A\"B", "🦀"]]),
|
||||
("ع", "\\", "界", "^Y", "عa界bع界عline\nbreakع^Y", vec![vec!["a界b", "line\nbreak"]]),
|
||||
];
|
||||
let env = snapshot_test_env().await;
|
||||
for (index, (quote, escape, field, record, data, expected)) in cases.into_iter().enumerate() {
|
||||
let mut input = test_input();
|
||||
input.bucket = format!("select-unicode-quotes-{index}");
|
||||
input.key = "records".to_owned();
|
||||
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
|
||||
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::NONE));
|
||||
csv.quote_character = Some(quote.to_owned());
|
||||
csv.quote_escape_character = Some(escape.to_owned());
|
||||
csv.field_delimiter = Some(field.to_owned());
|
||||
csv.record_delimiter = Some(record.to_owned());
|
||||
env.make_bucket(&input.bucket, false).await;
|
||||
env.put_object_bytes(&input.bucket, &input.key, data.as_bytes().to_vec())
|
||||
.await;
|
||||
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
|
||||
let input = Arc::new(input);
|
||||
let dispatcher = production_dispatcher(Arc::clone(&input));
|
||||
let query = Query::new_with_snapshot(QueryContext { input }, "SELECT * FROM S3Object".to_owned(), snapshot);
|
||||
let output = dispatcher.execute_query(&query).await.expect("execute Unicode CSV query");
|
||||
let mut stream = output.into_record_batch_stream().expect("record stream");
|
||||
let mut rows = Vec::new();
|
||||
while let Some(batch) = stream.next().await {
|
||||
let batch = batch.expect("Arrow must receive valid UTF-8 fields");
|
||||
for row in 0..batch.num_rows() {
|
||||
rows.push(
|
||||
batch
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|column| {
|
||||
column
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.expect("CSV string column")
|
||||
.value(row)
|
||||
.to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(rows, expected, "fixture={index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn select_uses_input_serialization_independently_of_object_extension() {
|
||||
for (key, json) in [
|
||||
("records", false),
|
||||
("records.bin", false),
|
||||
("records", true),
|
||||
("records.csv", true),
|
||||
] {
|
||||
let mut input = test_input();
|
||||
input.key = key.to_owned();
|
||||
let data = if json {
|
||||
input.request.input_serialization.csv = None;
|
||||
input.request.input_serialization.json = Some(s3s::dto::JSONInput {
|
||||
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||
});
|
||||
b"{\"value\":\"selected\"}\n".as_slice()
|
||||
} else {
|
||||
b"value\nselected\n".as_slice()
|
||||
};
|
||||
let input = Arc::new(input);
|
||||
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
|
||||
let dispatcher = test_dispatcher_for_input(
|
||||
Arc::clone(&input),
|
||||
Arc::new(Semaphore::new(1)),
|
||||
Duration::from_secs(30),
|
||||
Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))),
|
||||
);
|
||||
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_owned());
|
||||
let machine = dispatcher.build_query_state_machine(query).await.expect("build query state");
|
||||
let store_url = ObjectStoreUrl::parse("s3://test-bucket").expect("test store URL");
|
||||
let store = machine
|
||||
.session
|
||||
.inner()
|
||||
.runtime_env()
|
||||
.object_store(&store_url)
|
||||
.expect("test store");
|
||||
store.put(&Path::from(key), data.into()).await.expect("write selected object");
|
||||
store
|
||||
.put(&Path::from(format!("{key}.other")), b"unrelated\nwrong\n".as_slice().into())
|
||||
.await
|
||||
.expect("write neighboring object");
|
||||
let plan = dispatcher
|
||||
.build_logical_plan(Arc::clone(&machine))
|
||||
.await
|
||||
.expect("infer schema without an extension filter")
|
||||
.expect("select plan");
|
||||
let output = dispatcher
|
||||
.execute_logical_plan(plan, machine)
|
||||
.await
|
||||
.expect("execute selected object");
|
||||
let mut stream = output.into_record_batch_stream().expect("record stream");
|
||||
let mut values = Vec::new();
|
||||
while let Some(batch) = stream.next().await {
|
||||
let batch = batch.expect("selected batch");
|
||||
let column = batch
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<datafusion::arrow::array::StringArray>()
|
||||
.expect("string column");
|
||||
values.extend(column.iter().map(|value| value.expect("selected value").to_owned()));
|
||||
}
|
||||
assert_eq!(values, ["selected"], "key={key}, json={json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn csv_query_uses_custom_record_delimiter_across_file_partitions() {
|
||||
const ROW_COUNT: usize = 200_000;
|
||||
|
||||
@@ -28,10 +28,10 @@ use metrics::{counter, describe_counter, describe_histogram, histogram};
|
||||
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
|
||||
pub use rustfs_data_usage::{
|
||||
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
|
||||
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSegmentInvalidationProof, DataUsageSnapshotSetState,
|
||||
LEGACY_DATA_USAGE_OBJECT_NAME, PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary,
|
||||
SizeReconciliationEntry, SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER,
|
||||
UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
|
||||
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSnapshotSetState, LEGACY_DATA_USAGE_OBJECT_NAME,
|
||||
PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary, SizeReconciliationEntry,
|
||||
SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP,
|
||||
UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
|
||||
};
|
||||
use rustfs_heal_contracts::heal_channel::HealScanMode;
|
||||
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
||||
@@ -657,11 +657,6 @@ pub struct DataUsageCacheInfo {
|
||||
/// structural plan remains reusable across ordinary bucket writes.
|
||||
#[serde(default)]
|
||||
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
||||
/// Process-epoch and generation window that produced a complete set cache
|
||||
/// with all known segment invalidation producers wired. This proof is
|
||||
/// additive compatibility metadata; absence keeps segment reuse disabled.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
|
||||
/// Durable bucket incarnations captured for a complete set aggregate.
|
||||
/// Missing or nil entries are legacy/unproven and cannot authorize
|
||||
/// skipping an unselected bucket in a later scoped set scan.
|
||||
@@ -691,7 +686,6 @@ impl Serialize for DataUsageCacheInfo {
|
||||
+ usize::from(self.lkg_leader_epoch.is_some())
|
||||
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
||||
+ usize::from(self.scan_execution_digest.is_some())
|
||||
+ usize::from(self.segment_invalidation_proof.is_some())
|
||||
+ usize::from(!self.scan_bucket_incarnations.is_empty());
|
||||
let mut state = serializer.serialize_map(Some(field_count))?;
|
||||
state.serialize_entry("name", &self.name)?;
|
||||
@@ -752,9 +746,6 @@ impl Serialize for DataUsageCacheInfo {
|
||||
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
||||
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
||||
}
|
||||
if let Some(proof) = &self.segment_invalidation_proof {
|
||||
state.serialize_entry("segment_invalidation_proof", proof)?;
|
||||
}
|
||||
if !self.scan_bucket_incarnations.is_empty() {
|
||||
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
|
||||
}
|
||||
|
||||
@@ -1095,7 +1095,6 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
|
||||
assert!(!decoded.snapshot_complete);
|
||||
assert!(decoded.scan_plan_digest.is_none());
|
||||
assert!(decoded.scan_execution_digest.is_none());
|
||||
assert!(decoded.segment_invalidation_proof.is_none());
|
||||
assert_eq!(decoded.cache_key_format, 0);
|
||||
}
|
||||
|
||||
@@ -1184,13 +1183,6 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
||||
snapshot_complete: true,
|
||||
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
||||
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
|
||||
segment_invalidation_proof: Some(DataUsageSegmentInvalidationProof {
|
||||
process_epoch: "scanner-process".to_string(),
|
||||
generation_start: 7,
|
||||
generation_end: 9,
|
||||
producer_identity_coverage_complete: true,
|
||||
cold_zero_walk_oracle: true,
|
||||
}),
|
||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1220,16 +1212,6 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
||||
assert!(current.info.snapshot_complete);
|
||||
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
||||
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
|
||||
assert_eq!(
|
||||
current.info.segment_invalidation_proof,
|
||||
Some(DataUsageSegmentInvalidationProof {
|
||||
process_epoch: "scanner-process".to_string(),
|
||||
generation_start: 7,
|
||||
generation_end: 9,
|
||||
producer_identity_coverage_complete: true,
|
||||
cold_zero_walk_oracle: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
|
||||
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ use s3s::dto::{
|
||||
BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
@@ -204,7 +204,6 @@ pub(crate) struct DistributedSegmentInvalidationEvidence {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ScannerSegmentReuseActivationProof {
|
||||
pub(crate) production_activation: bool,
|
||||
pub(crate) producer_identity_coverage_complete: bool,
|
||||
pub(crate) durable_producer_identity: bool,
|
||||
pub(crate) restart_gap_absent: bool,
|
||||
pub(crate) generation_window_bound: bool,
|
||||
@@ -436,60 +435,6 @@ fn complete_scanner_cache_baseline_plan_digest(proof: ScannerCacheBaselineProof<
|
||||
complete_scanner_cache_snapshot_plan_digest(&observed, proof, false)
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_baseline_producer_evidence(
|
||||
dirty_usage_snapshot: &DirtyUsageSnapshot,
|
||||
baseline_proof: ScannerCacheBaselineProof<'_>,
|
||||
) -> (DirtyUsageProducerEvidence, bool) {
|
||||
let mut evidence = dirty_usage_producer_evidence(dirty_usage_snapshot);
|
||||
if !evidence.generation_window_bound || !evidence.producer_identity_coverage_complete {
|
||||
return (evidence, false);
|
||||
}
|
||||
|
||||
let Some(authoritative_data) = baseline_proof.authoritative_data else {
|
||||
return (evidence, false);
|
||||
};
|
||||
let Ok(authoritative) = serde_json::from_slice::<DataUsageInfo>(authoritative_data) else {
|
||||
return (evidence, false);
|
||||
};
|
||||
if complete_scanner_cache_snapshot_plan_digest(&authoritative, baseline_proof, true).is_none()
|
||||
|| !scanner_snapshot_set_states_have_segment_reuse_activation_proof(
|
||||
&authoritative,
|
||||
&evidence,
|
||||
baseline_proof.expected_sources,
|
||||
)
|
||||
{
|
||||
return (evidence, false);
|
||||
}
|
||||
|
||||
evidence.durable_producer_identity = true;
|
||||
evidence.restart_gap_absent = true;
|
||||
(evidence, true)
|
||||
}
|
||||
|
||||
fn scanner_snapshot_set_states_have_segment_reuse_activation_proof(
|
||||
snapshot: &DataUsageInfo,
|
||||
evidence: &DirtyUsageProducerEvidence,
|
||||
expected_sources: &HashSet<DataUsageCacheSource>,
|
||||
) -> bool {
|
||||
let mut covered_sources = HashSet::with_capacity(expected_sources.len());
|
||||
let all_sets_proved = snapshot.usage_snapshot_set_states.iter().all(|state| {
|
||||
let (Ok(pool_index), Ok(set_index)) = (usize::try_from(state.pool_index), usize::try_from(state.set_index)) else {
|
||||
return false;
|
||||
};
|
||||
let source = DataUsageCacheSource::new(pool_index, set_index);
|
||||
state.complete
|
||||
&& !state.tombstone
|
||||
&& expected_sources.contains(&source)
|
||||
&& covered_sources.insert(source)
|
||||
&& scanner_segment_invalidation_proof_matches(state.segment_invalidation_proof.as_ref(), evidence)
|
||||
&& state
|
||||
.segment_invalidation_proof
|
||||
.as_ref()
|
||||
.is_some_and(|proof| proof.cold_zero_walk_oracle)
|
||||
});
|
||||
all_sets_proved && covered_sources.len() == expected_sources.len()
|
||||
}
|
||||
|
||||
fn scoped_scan_scope_from_dirty_buckets(
|
||||
requested_scope: ScannerBucketScanScope,
|
||||
dirty_buckets: HashSet<String>,
|
||||
@@ -539,10 +484,9 @@ fn scoped_scan_scope_from_dirty_buckets(
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activation_preflight() -> ScannerSegmentReuseActivationPreflight {
|
||||
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
|
||||
production_activation: true,
|
||||
..Default::default()
|
||||
})
|
||||
// Production segment reuse stays disabled until a durable mutation-stream
|
||||
// proof satisfies the segment invalidation contract.
|
||||
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof::default())
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activation_preflight_from_proof(
|
||||
@@ -551,7 +495,6 @@ fn scanner_segment_reuse_activation_preflight_from_proof(
|
||||
ScannerSegmentReuseActivationPreflight {
|
||||
production_activation: proof.production_activation,
|
||||
scanner_segment_reuse_activated: proof.production_activation
|
||||
&& proof.producer_identity_coverage_complete
|
||||
&& proof.durable_producer_identity
|
||||
&& proof.restart_gap_absent
|
||||
&& proof.generation_window_bound
|
||||
@@ -561,8 +504,7 @@ fn scanner_segment_reuse_activation_preflight_from_proof(
|
||||
proof_inputs: &SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS,
|
||||
fail_closed_checks: &SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS,
|
||||
fail_closed_blockers: [
|
||||
(!proof.producer_identity_coverage_complete || !proof.durable_producer_identity)
|
||||
.then_some("missing_producer_identity"),
|
||||
(!proof.durable_producer_identity).then_some("missing_producer_identity"),
|
||||
(!proof.restart_gap_absent).then_some("restart_gap"),
|
||||
(!proof.generation_window_bound).then_some("generation_gap"),
|
||||
(!proof.overflow_absent).then_some("overflow"),
|
||||
@@ -574,114 +516,23 @@ fn scanner_segment_reuse_activation_preflight_from_proof(
|
||||
|
||||
fn scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
dirty_usage_snapshot: &DirtyUsageSnapshot,
|
||||
dirty_usage_producer_evidence: DirtyUsageProducerEvidence,
|
||||
distributed: bool,
|
||||
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
cold_zero_walk_oracle: bool,
|
||||
) -> ScannerSegmentReuseActivationPreflight {
|
||||
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
|
||||
production_activation: true,
|
||||
producer_identity_coverage_complete: dirty_usage_producer_evidence.producer_identity_coverage_complete,
|
||||
durable_producer_identity: dirty_usage_producer_evidence.durable_producer_identity,
|
||||
restart_gap_absent: dirty_usage_producer_evidence.restart_gap_absent,
|
||||
production_activation: false,
|
||||
durable_producer_identity: false,
|
||||
restart_gap_absent: false,
|
||||
generation_window_bound: dirty_usage_snapshot.covers_all_pending
|
||||
&& dirty_usage_snapshot.generation != 0
|
||||
&& dirty_usage_snapshot.generation != u64::MAX
|
||||
&& dirty_usage_producer_evidence.generation_window_bound,
|
||||
&& dirty_usage_snapshot.generation != u64::MAX,
|
||||
overflow_absent: dirty_usage_snapshot.covers_all_pending,
|
||||
cold_zero_walk_oracle,
|
||||
distributed_peer_invalidation: scanner_distributed_segment_invalidation_admitted(
|
||||
distributed,
|
||||
distributed_segment_invalidation_evidence,
|
||||
),
|
||||
distributed_peer_invalidation: !distributed || distributed_segment_invalidation_evidence.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activation_preflight_for_baseline(
|
||||
dirty_usage_snapshot: &DirtyUsageSnapshot,
|
||||
distributed: bool,
|
||||
baseline_proof: ScannerCacheBaselineProof<'_>,
|
||||
) -> ScannerSegmentReuseActivationPreflight {
|
||||
let (dirty_usage_producer_evidence, cold_zero_walk_oracle) =
|
||||
scanner_segment_reuse_baseline_producer_evidence(dirty_usage_snapshot, baseline_proof);
|
||||
scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
dirty_usage_snapshot,
|
||||
dirty_usage_producer_evidence,
|
||||
distributed,
|
||||
None,
|
||||
cold_zero_walk_oracle,
|
||||
)
|
||||
}
|
||||
|
||||
fn scanner_distributed_segment_invalidation_admitted(
|
||||
distributed: bool,
|
||||
evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
) -> bool {
|
||||
if !distributed {
|
||||
return true;
|
||||
}
|
||||
evidence.is_some_and(|evidence| {
|
||||
evidence.invalidation_domain == crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc
|
||||
&& evidence.distributed_ec_invalidation
|
||||
&& evidence.same_window_remote_proof
|
||||
&& evidence.all_peers_bound_to_generation_window
|
||||
&& evidence.dirty_peer_count > 0
|
||||
&& evidence.dirty_peer_count <= evidence.peer_count
|
||||
})
|
||||
}
|
||||
|
||||
fn scanner_durable_segment_invalidation_evidence(
|
||||
dirty_usage_snapshot: &DirtyUsageSnapshot,
|
||||
results: &[DataUsageCache],
|
||||
expected_sources: &HashSet<DataUsageCacheSource>,
|
||||
) -> DirtyUsageProducerEvidence {
|
||||
let mut evidence = dirty_usage_producer_evidence(dirty_usage_snapshot);
|
||||
if !evidence.generation_window_bound
|
||||
|| !evidence.producer_identity_coverage_complete
|
||||
|| !scanner_results_form_complete_snapshot(results, expected_sources)
|
||||
{
|
||||
return evidence;
|
||||
}
|
||||
|
||||
let mut covered_sources = HashSet::with_capacity(expected_sources.len());
|
||||
let all_sets_proved = results.iter().all(|result| {
|
||||
let Some(source) = result.info.source else {
|
||||
return false;
|
||||
};
|
||||
expected_sources.contains(&source)
|
||||
&& covered_sources.insert(source)
|
||||
&& scanner_segment_invalidation_proof_matches(result.info.segment_invalidation_proof.as_ref(), &evidence)
|
||||
});
|
||||
if all_sets_proved && covered_sources.len() == expected_sources.len() {
|
||||
evidence.durable_producer_identity = true;
|
||||
evidence.restart_gap_absent = true;
|
||||
}
|
||||
evidence
|
||||
}
|
||||
|
||||
fn scanner_segment_invalidation_proof_matches(
|
||||
proof: Option<&crate::DataUsageSegmentInvalidationProof>,
|
||||
evidence: &DirtyUsageProducerEvidence,
|
||||
) -> bool {
|
||||
proof.is_some_and(|proof| {
|
||||
proof.process_epoch == scanner_activity_epoch()
|
||||
&& proof.generation_start == evidence.generation_start
|
||||
&& proof.generation_end == evidence.generation_end
|
||||
&& proof.producer_identity_coverage_complete
|
||||
})
|
||||
}
|
||||
|
||||
fn scanner_completed_set_segment_invalidation_proof(
|
||||
proof: &Option<crate::DataUsageSegmentInvalidationProof>,
|
||||
cold_zero_walk_reuse_candidate: bool,
|
||||
) -> Option<crate::DataUsageSegmentInvalidationProof> {
|
||||
proof.clone().map(|mut proof| {
|
||||
proof.cold_zero_walk_oracle = cold_zero_walk_reuse_candidate;
|
||||
proof
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn scanner_segment_reuse_activated() -> bool {
|
||||
scanner_segment_reuse_activation_preflight().scanner_segment_reuse_activated
|
||||
}
|
||||
@@ -746,7 +597,6 @@ pub struct ScannerBucketScanPlan {
|
||||
pending_maintenance_work: Arc<AtomicBool>,
|
||||
cache_cycle_floor: Arc<AtomicU64>,
|
||||
cold_zero_walk_reuse_observed: Arc<AtomicBool>,
|
||||
segment_invalidation_proof: Option<crate::DataUsageSegmentInvalidationProof>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@@ -464,7 +464,6 @@ pub(super) fn completed_usage_candidate(
|
||||
scan_plan_digest: Some(result.info.scan_plan_digest?.0),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: result.info.segment_invalidation_proof.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
@@ -643,14 +642,13 @@ pub(super) fn observational_data_usage_info(
|
||||
let current_snapshot = current.is_some();
|
||||
let selected = current.or(lkg);
|
||||
if let Some(selected) = selected {
|
||||
let (cycle, epoch, digest, last_update, complete, segment_invalidation_proof) = if current_snapshot {
|
||||
let (cycle, epoch, digest, last_update, complete) = if current_snapshot {
|
||||
(
|
||||
Some(selected.info.next_cycle),
|
||||
Some(selected.info.leader_epoch),
|
||||
selected.info.scan_plan_digest.map(|digest| digest.0),
|
||||
selected.info.last_update,
|
||||
true,
|
||||
selected.info.segment_invalidation_proof.clone(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -659,7 +657,6 @@ pub(super) fn observational_data_usage_info(
|
||||
selected.info.lkg_scan_plan_digest.map(|digest| digest.0),
|
||||
selected.info.lkg_last_update,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
};
|
||||
set_states.push(DataUsageSnapshotSetState {
|
||||
@@ -670,7 +667,6 @@ pub(super) fn observational_data_usage_info(
|
||||
scan_plan_digest: digest,
|
||||
complete,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof,
|
||||
});
|
||||
usable.push((selected, last_update));
|
||||
} else {
|
||||
@@ -682,7 +678,6 @@ pub(super) fn observational_data_usage_info(
|
||||
scan_plan_digest: Some(expected_plan_digest.0),
|
||||
complete: false,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,17 @@ use super::*;
|
||||
|
||||
pub(super) static DIRTY_USAGE_BUCKET_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
pub(super) static DIRTY_USAGE_BUCKETS: LazyLock<StdMutex<DirtyUsageBuckets>> = LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
// Lock order when dirty usage state is updated is `DIRTY_USAGE_BUCKETS`,
|
||||
// `DIRTY_USAGE_BUCKET_SCOPES`, then `DIRTY_USAGE_PRODUCER_IDENTITIES`. All
|
||||
// guards are held only for synchronous map updates, so no scanner task can
|
||||
// observe a bucket generation without its matching scope and producer evidence.
|
||||
// Lock order when both dirty maps are needed is `DIRTY_USAGE_BUCKETS` followed
|
||||
// by `DIRTY_USAGE_BUCKET_SCOPES`. Both are held only for synchronous map
|
||||
// updates, so no scanner task can observe a bucket generation without its
|
||||
// matching scope.
|
||||
pub(super) static DIRTY_USAGE_BUCKET_SCOPES: LazyLock<StdMutex<DirtyUsageBucketScopes>> =
|
||||
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
// Non-authoritative process-local producer coverage. Any future segment reuse
|
||||
// activation must bind this to the exact generation window and durable proof.
|
||||
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<StdMutex<DirtyUsageProducerIdentities>> =
|
||||
LazyLock::new(|| StdMutex::new(BTreeMap::new()));
|
||||
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<
|
||||
StdMutex<BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>>,
|
||||
> = LazyLock::new(|| StdMutex::new(BTreeSet::new()));
|
||||
pub(super) static DIRTY_USAGE_BUCKET_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
|
||||
pub(super) static SCANNER_ACTIVITY_EPOCH: LazyLock<String> = LazyLock::new(|| format!("{:032x}", rand::random::<u128>()));
|
||||
pub(super) static SCANNER_MAINTENANCE_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -58,39 +59,6 @@ pub(super) type DirtyUsageBucketScopes = HashMap<String, DirtyUsageBucketScope>;
|
||||
|
||||
const MAX_DIRTY_USAGE_TOP_LEVEL_ENTRIES_PER_BUCKET: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct DirtyUsageProducerIdentityState {
|
||||
first_generation: u64,
|
||||
last_generation: u64,
|
||||
}
|
||||
|
||||
pub(super) type DirtyUsageProducerIdentities =
|
||||
BTreeMap<crate::segment_invalidation::SegmentInvalidationProducerIdentity, DirtyUsageProducerIdentityState>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct DirtyUsageProducerEvidence {
|
||||
pub(super) producer_identity_coverage_complete: bool,
|
||||
pub(super) durable_producer_identity: bool,
|
||||
pub(super) restart_gap_absent: bool,
|
||||
pub(super) generation_window_bound: bool,
|
||||
pub(super) generation_start: u64,
|
||||
pub(super) generation_end: u64,
|
||||
}
|
||||
|
||||
impl DirtyUsageProducerEvidence {
|
||||
pub(super) fn segment_invalidation_proof(self) -> Option<crate::DataUsageSegmentInvalidationProof> {
|
||||
(self.generation_window_bound && self.producer_identity_coverage_complete).then(|| {
|
||||
crate::DataUsageSegmentInvalidationProof {
|
||||
process_epoch: scanner_activity_epoch().to_string(),
|
||||
generation_start: self.generation_start,
|
||||
generation_end: self.generation_end,
|
||||
producer_identity_coverage_complete: true,
|
||||
cold_zero_walk_oracle: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A point-in-time view of the local dirty bucket generations.
|
||||
///
|
||||
/// `complete == false` is an all-or-nothing overflow signal: `buckets` is
|
||||
@@ -296,7 +264,8 @@ fn dirty_usage_bucket_scopes() -> MutexGuard<'static, DirtyUsageBucketScopes> {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn dirty_usage_producer_identities() -> MutexGuard<'static, DirtyUsageProducerIdentities> {
|
||||
fn dirty_usage_producer_identities()
|
||||
-> MutexGuard<'static, BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>> {
|
||||
DIRTY_USAGE_PRODUCER_IDENTITIES
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
@@ -317,7 +286,7 @@ pub fn record_dirty_usage_bucket(bucket: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
record_dirty_usage_bucket_inner(bucket, std::iter::empty());
|
||||
record_dirty_usage_bucket_inner(bucket);
|
||||
}
|
||||
|
||||
pub fn record_dirty_usage_bucket_from_producer(
|
||||
@@ -328,7 +297,8 @@ pub fn record_dirty_usage_bucket_from_producer(
|
||||
return;
|
||||
}
|
||||
|
||||
record_dirty_usage_bucket_inner(bucket, [producer]);
|
||||
record_segment_invalidation_producer_identity(producer);
|
||||
record_dirty_usage_bucket_inner(bucket);
|
||||
}
|
||||
|
||||
pub fn record_dirty_usage_bucket_from_producers<I>(bucket: &str, producers: I)
|
||||
@@ -339,21 +309,17 @@ where
|
||||
return;
|
||||
}
|
||||
|
||||
record_dirty_usage_bucket_inner(bucket, producers);
|
||||
record_segment_invalidation_producer_identities(producers);
|
||||
record_dirty_usage_bucket_inner(bucket);
|
||||
}
|
||||
|
||||
fn record_dirty_usage_bucket_inner<I>(bucket: &str, producers: I)
|
||||
where
|
||||
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
|
||||
{
|
||||
fn record_dirty_usage_bucket_inner(bucket: &str) {
|
||||
let pending_buckets = {
|
||||
let mut dirty_buckets = dirty_usage_buckets();
|
||||
let mut dirty_scopes = dirty_usage_bucket_scopes();
|
||||
let mut producer_identities = dirty_usage_producer_identities();
|
||||
let generation = advance_generation(&DIRTY_USAGE_BUCKET_GENERATION);
|
||||
dirty_buckets.insert(bucket.to_string(), generation);
|
||||
dirty_scopes.insert(bucket.to_string(), DirtyUsageBucketScope::WholeBucket);
|
||||
record_segment_invalidation_producer_identities_for_generation(&mut producer_identities, generation, producers);
|
||||
dirty_buckets.len()
|
||||
};
|
||||
global_metrics().record_scanner_dirty_usage_pending(usize_to_u64_saturated(pending_buckets));
|
||||
@@ -371,7 +337,7 @@ where
|
||||
/// local: after restart or any unverified distributed path the scanner falls
|
||||
/// back to its ordinary bucket scan.
|
||||
pub fn record_dirty_usage_object(bucket: &str, object: &str) {
|
||||
record_dirty_usage_object_inner(bucket, object, std::iter::empty());
|
||||
record_dirty_usage_object_inner(bucket, object);
|
||||
}
|
||||
|
||||
pub fn record_dirty_usage_object_from_producer(
|
||||
@@ -383,16 +349,13 @@ pub fn record_dirty_usage_object_from_producer(
|
||||
return;
|
||||
}
|
||||
|
||||
record_dirty_usage_object_inner(bucket, object, [producer]);
|
||||
record_segment_invalidation_producer_identity(producer);
|
||||
record_dirty_usage_object_inner(bucket, object);
|
||||
}
|
||||
|
||||
fn record_dirty_usage_object_inner<I>(bucket: &str, object: &str, producers: I)
|
||||
where
|
||||
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
|
||||
{
|
||||
let producers = producers.into_iter().collect::<Vec<_>>();
|
||||
fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
|
||||
let Some(top_level_entry) = dirty_usage_top_level_entry(object) else {
|
||||
record_dirty_usage_bucket_inner(bucket, producers);
|
||||
record_dirty_usage_bucket(bucket);
|
||||
return;
|
||||
};
|
||||
if bucket.is_empty() {
|
||||
@@ -402,7 +365,6 @@ where
|
||||
let pending_buckets = {
|
||||
let mut dirty_buckets = dirty_usage_buckets();
|
||||
let mut dirty_scopes = dirty_usage_bucket_scopes();
|
||||
let mut producer_identities = dirty_usage_producer_identities();
|
||||
let generation = advance_generation(&DIRTY_USAGE_BUCKET_GENERATION);
|
||||
dirty_buckets.insert(bucket.to_string(), generation);
|
||||
let scope = dirty_scopes
|
||||
@@ -418,7 +380,6 @@ where
|
||||
if overflowed {
|
||||
*scope = DirtyUsageBucketScope::WholeBucket;
|
||||
}
|
||||
record_segment_invalidation_producer_identities_for_generation(&mut producer_identities, generation, producers);
|
||||
dirty_buckets.len()
|
||||
};
|
||||
global_metrics().record_scanner_dirty_usage_pending(usize_to_u64_saturated(pending_buckets));
|
||||
@@ -426,29 +387,25 @@ where
|
||||
DIRTY_USAGE_BUCKET_NOTIFY.notify_one();
|
||||
}
|
||||
|
||||
fn record_segment_invalidation_producer_identities_for_generation<I>(
|
||||
identities: &mut DirtyUsageProducerIdentities,
|
||||
generation: u64,
|
||||
producers: I,
|
||||
) where
|
||||
fn record_segment_invalidation_producer_identity(producer: crate::segment_invalidation::SegmentInvalidationProducerIdentity) {
|
||||
record_segment_invalidation_producer_identities([producer]);
|
||||
}
|
||||
|
||||
fn record_segment_invalidation_producer_identities<I>(producers: I)
|
||||
where
|
||||
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
|
||||
{
|
||||
let mut identities = dirty_usage_producer_identities();
|
||||
for producer in producers {
|
||||
if producer.producer().is_some() {
|
||||
identities
|
||||
.entry(producer)
|
||||
.and_modify(|state| state.last_generation = state.last_generation.max(generation))
|
||||
.or_insert(DirtyUsageProducerIdentityState {
|
||||
first_generation: generation,
|
||||
last_generation: generation,
|
||||
});
|
||||
identities.insert(producer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn dirty_usage_producer_identities_for_tests() -> BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity> {
|
||||
dirty_usage_producer_identities().keys().copied().collect()
|
||||
dirty_usage_producer_identities().clone()
|
||||
}
|
||||
|
||||
fn dirty_usage_top_level_entry(object: &str) -> Option<String> {
|
||||
@@ -723,31 +680,6 @@ pub(super) fn dirty_usage_snapshot_status(snapshot: &DirtyUsageSnapshot) -> Dirt
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dirty_usage_producer_evidence(snapshot: &DirtyUsageSnapshot) -> DirtyUsageProducerEvidence {
|
||||
let generation_window_bound = dirty_usage_snapshot_status(snapshot) == DirtyUsageSnapshotStatus::Current
|
||||
&& snapshot.generation != 0
|
||||
&& snapshot.generation != u64::MAX;
|
||||
let identities = dirty_usage_producer_identities()
|
||||
.iter()
|
||||
.filter(|(_, state)| state.first_generation <= snapshot.generation)
|
||||
.map(|(identity, _)| *identity)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let producer_identity_coverage_complete =
|
||||
generation_window_bound && crate::segment_invalidation::complete_segment_invalidation_producers(identities).is_ok();
|
||||
|
||||
DirtyUsageProducerEvidence {
|
||||
producer_identity_coverage_complete,
|
||||
// The current producer journal is still process-local. Keep the
|
||||
// durable/restart gates closed until the mutation evidence is persisted
|
||||
// and replayable across scanner restarts.
|
||||
durable_producer_identity: false,
|
||||
restart_gap_absent: false,
|
||||
generation_window_bound,
|
||||
generation_start: snapshot.generation,
|
||||
generation_end: snapshot.generation,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn dirty_usage_bucket_count() -> usize {
|
||||
dirty_usage_buckets().len()
|
||||
|
||||
@@ -173,7 +173,6 @@ impl ScannerIOCache for SetDisks {
|
||||
pending_maintenance_work,
|
||||
cache_cycle_floor,
|
||||
cold_zero_walk_reuse_observed,
|
||||
segment_invalidation_proof,
|
||||
} = scan_plan;
|
||||
let scan_plan_digest = scanner_bucket_work_digest(scan_plan_digest, scan_mode, requires_full_scan);
|
||||
let bucket_work_digest = scanner_bucket_work_digest(bucket_coverage_digest, scan_mode, requires_full_scan);
|
||||
@@ -231,8 +230,6 @@ impl ScannerIOCache for SetDisks {
|
||||
});
|
||||
if buckets.is_empty() {
|
||||
let now = SystemTime::now();
|
||||
let completed_segment_invalidation_proof =
|
||||
scanner_completed_set_segment_invalidation_proof(&segment_invalidation_proof, cold_zero_walk_reuse_candidate);
|
||||
let mut cache = match scoped_cache.take() {
|
||||
Some(cache) => cache,
|
||||
None => {
|
||||
@@ -246,7 +243,6 @@ impl ScannerIOCache for SetDisks {
|
||||
scan_plan_digest: Some(scan_plan_digest),
|
||||
scan_coverage_digest: Some(bucket_coverage_digest),
|
||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
segment_invalidation_proof: completed_segment_invalidation_proof.clone(),
|
||||
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -262,7 +258,6 @@ impl ScannerIOCache for SetDisks {
|
||||
cache.info.last_update = Some(now);
|
||||
cache.info.snapshot_complete = true;
|
||||
cache.info.scan_execution_digest = Some(execution_digest);
|
||||
cache.info.segment_invalidation_proof = completed_segment_invalidation_proof;
|
||||
cache.info.lkg_snapshot_complete = false;
|
||||
cache.info.lkg_next_cycle = None;
|
||||
cache.info.lkg_last_update = None;
|
||||
@@ -544,7 +539,6 @@ impl ScannerIOCache for SetDisks {
|
||||
lkg_last_update: old_cache.info.lkg_last_update,
|
||||
lkg_leader_epoch: old_cache.info.lkg_leader_epoch,
|
||||
lkg_scan_plan_digest: old_cache.info.lkg_scan_plan_digest,
|
||||
segment_invalidation_proof: None,
|
||||
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -1465,15 +1459,12 @@ impl ScannerIOCache for SetDisks {
|
||||
|
||||
let completed_count = completed_bucket_count.load(Ordering::Relaxed);
|
||||
if should_publish_completed_snapshot(completed_count, buckets.len(), budget.budget_elapsed(), ctx.is_cancelled()) {
|
||||
let completed_segment_invalidation_proof =
|
||||
scanner_completed_set_segment_invalidation_proof(&segment_invalidation_proof, cold_zero_walk_reuse_candidate);
|
||||
let cache_snapshot = {
|
||||
let mut cache = cache_mutex.lock().await;
|
||||
cache.info.next_cycle = want_cycle;
|
||||
cache.info.last_update.get_or_insert_with(SystemTime::now);
|
||||
cache.info.snapshot_complete = true;
|
||||
cache.info.scan_execution_digest = Some(execution_digest);
|
||||
cache.info.segment_invalidation_proof = completed_segment_invalidation_proof;
|
||||
cache.info.lkg_snapshot_complete = false;
|
||||
cache.info.lkg_next_cycle = None;
|
||||
cache.info.lkg_last_update = None;
|
||||
@@ -1502,7 +1493,6 @@ impl ScannerIOCache for SetDisks {
|
||||
incomplete_scope.info.tier_registry_generation = Some(tier_registry_generation);
|
||||
incomplete_scope.info.source = Some(source);
|
||||
incomplete_scope.info.snapshot_complete = false;
|
||||
incomplete_scope.info.segment_invalidation_proof = None;
|
||||
incomplete_scope.info.scan_plan_digest = Some(scan_plan_digest);
|
||||
incomplete_scope.info.cache_key_format = DATA_USAGE_CACHE_KEY_FORMAT;
|
||||
if let Err(e) = updates.send(incomplete_scope).await {
|
||||
|
||||
@@ -241,17 +241,12 @@ where
|
||||
return remote_resolution;
|
||||
}
|
||||
|
||||
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_baseline(
|
||||
resolution.dirty_usage_snapshot,
|
||||
distributed,
|
||||
resolution.baseline_proof,
|
||||
);
|
||||
default_result(scoped_scan_scope_from_dirty_buckets(
|
||||
resolution.requested_scope,
|
||||
dirty_buckets,
|
||||
(!distributed).then_some(resolution.dirty_usage_snapshot.scopes.as_ref()),
|
||||
true,
|
||||
segment_reuse_activation_preflight.scanner_segment_reuse_activated,
|
||||
scanner_segment_reuse_activated(),
|
||||
resolution.all_buckets,
|
||||
resolution.baseline_proof,
|
||||
))
|
||||
@@ -416,7 +411,6 @@ where
|
||||
let remote_dirty_usage_acknowledgements = scope_resolution.remote_dirty_usage_acknowledgements;
|
||||
let distributed_segment_invalidation_evidence = scope_resolution.distributed_segment_invalidation_evidence;
|
||||
let scan_scope = scope_resolution.scope;
|
||||
let segment_invalidation_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot).segment_invalidation_proof();
|
||||
#[cfg(test)]
|
||||
if let Some(observer) = resolved_scope_observer {
|
||||
let _ = observer.send(scan_scope.clone());
|
||||
@@ -473,13 +467,8 @@ where
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
&dirty_usage_snapshot,
|
||||
dirty_usage_producer_evidence(&dirty_usage_snapshot),
|
||||
distributed,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let segment_reuse_activation_preflight =
|
||||
scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, distributed, None, false);
|
||||
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
|
||||
.with_publication_epoch(publication_epoch)
|
||||
.with_activity_digest(activity_digest)
|
||||
@@ -606,7 +595,6 @@ where
|
||||
pending_maintenance_work: pending_maintenance_work.clone(),
|
||||
cache_cycle_floor: cache_cycle_floor.clone(),
|
||||
cold_zero_walk_reuse_observed: cold_zero_walk_reuse_observed.clone(),
|
||||
segment_invalidation_proof: segment_invalidation_proof.clone(),
|
||||
};
|
||||
// Spawn task to run the scanner
|
||||
let scanner_fut = tokio::spawn(async move {
|
||||
@@ -720,7 +708,6 @@ where
|
||||
);
|
||||
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
&dirty_usage_snapshot,
|
||||
scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources),
|
||||
distributed,
|
||||
distributed_segment_invalidation_evidence,
|
||||
cold_zero_walk_oracle,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::data_usage_define::{DataUsageSegmentInvalidationProof, UNKNOWN_TIER, UnknownTierStats, hash_path};
|
||||
use crate::data_usage_define::{UNKNOWN_TIER, UnknownTierStats, hash_path};
|
||||
use rustfs_data_usage::{ReplicationAllStats, ReplicationTargetUsage, TierAccountingProof};
|
||||
|
||||
const TEST_PLAN_DIGEST: DataUsageScanPlanDigest = DataUsageScanPlanDigest([7; 32]);
|
||||
@@ -176,27 +176,6 @@ fn completed_data_usage_info_rejects_duplicate_bucket_inventory() {
|
||||
assert!(completed_data_usage_info_for_test(&[set], &buckets, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_data_usage_info_carries_segment_invalidation_proof_to_set_state() {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let proof = DataUsageSegmentInvalidationProof {
|
||||
process_epoch: "scanner-process".to_string(),
|
||||
generation_start: 5,
|
||||
generation_end: 8,
|
||||
producer_identity_coverage_complete: true,
|
||||
cold_zero_walk_oracle: true,
|
||||
};
|
||||
let mut set = completed_root_cache("bucket", 2, 10, source);
|
||||
set.info.segment_invalidation_proof = Some(proof.clone());
|
||||
|
||||
let (usage, _) =
|
||||
completed_usage_for_scope(&[set], &HashSet::from([source]), &["bucket".to_string()], &[], true, false, false)
|
||||
.expect("complete set should publish root usage");
|
||||
|
||||
assert_eq!(usage.usage_snapshot_set_states.len(), 1);
|
||||
assert_eq!(usage.usage_snapshot_set_states[0].segment_invalidation_proof, Some(proof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_data_usage_info_rejects_extra_or_detached_bucket_data() {
|
||||
let buckets = vec!["bucket".to_string()];
|
||||
@@ -371,7 +350,6 @@ fn set_membership_add_remove_uses_generation_and_tombstone() {
|
||||
scan_plan_digest: Some(TEST_PLAN_DIGEST.0),
|
||||
complete: false,
|
||||
tombstone: true,
|
||||
segment_invalidation_proof: None,
|
||||
};
|
||||
let encoded = serde_json::to_vec(&state).expect("set state should serialize");
|
||||
let decoded: DataUsageSnapshotSetState = serde_json::from_slice(&encoded).expect("set state should deserialize");
|
||||
@@ -393,7 +371,6 @@ fn set_membership_add_remove_uses_generation_and_tombstone() {
|
||||
scan_plan_digest: Some(TEST_PLAN_DIGEST.0),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
},
|
||||
state,
|
||||
],
|
||||
|
||||
@@ -89,7 +89,7 @@ fn scanner_activity_preflight_defers_a_temporarily_offline_peer() {
|
||||
fn scanner_segment_reuse_activation_preflight_reports_release_gate_inputs() {
|
||||
let preflight = scanner_segment_reuse_activation_preflight();
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert!(!scanner_segment_reuse_activated());
|
||||
assert_eq!(preflight.proof_inputs, SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS);
|
||||
@@ -104,7 +104,6 @@ fn scanner_segment_reuse_activation_preflight_reports_release_gate_inputs() {
|
||||
fn scanner_segment_reuse_activation_requires_every_preflight_proof() {
|
||||
let complete_proof = ScannerSegmentReuseActivationProof {
|
||||
production_activation: true,
|
||||
producer_identity_coverage_complete: true,
|
||||
durable_producer_identity: true,
|
||||
restart_gap_absent: true,
|
||||
generation_window_bound: true,
|
||||
@@ -126,13 +125,9 @@ fn scanner_segment_reuse_activation_requires_every_preflight_proof() {
|
||||
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
|
||||
|
||||
let mut missing_identity = complete_proof;
|
||||
missing_identity.producer_identity_coverage_complete = false;
|
||||
missing_identity.durable_producer_identity = false;
|
||||
assert_segment_reuse_activation_blocked_by(missing_identity, "missing_producer_identity");
|
||||
|
||||
let mut non_durable_identity = complete_proof;
|
||||
non_durable_identity.durable_producer_identity = false;
|
||||
assert_segment_reuse_activation_blocked_by(non_durable_identity, "missing_producer_identity");
|
||||
|
||||
let mut restart_gap = complete_proof;
|
||||
restart_gap.restart_gap_absent = false;
|
||||
assert_segment_reuse_activation_blocked_by(restart_gap, "restart_gap");
|
||||
@@ -171,15 +166,10 @@ fn scanner_segment_reuse_activation_preflight_for_cycle_reports_cycle_inputs_wit
|
||||
all_peers_bound_to_generation_window: true,
|
||||
};
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
&dirty_usage_snapshot,
|
||||
complete_process_local_producer_evidence(),
|
||||
true,
|
||||
Some(distributed_evidence),
|
||||
true,
|
||||
);
|
||||
let preflight =
|
||||
scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, true, Some(distributed_evidence), true);
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
@@ -196,15 +186,9 @@ fn scanner_segment_reuse_activation_preflight_for_cycle_blocks_unbounded_inputs(
|
||||
covers_all_pending: false,
|
||||
};
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
&dirty_usage_snapshot,
|
||||
DirtyUsageProducerEvidence::default(),
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, true, None, false);
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
@@ -221,15 +205,9 @@ fn scanner_segment_reuse_activation_preflight_for_cycle_skips_distributed_blocke
|
||||
covers_all_pending: true,
|
||||
};
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
&dirty_usage_snapshot,
|
||||
complete_process_local_producer_evidence(),
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, false, None, true);
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
@@ -237,146 +215,10 @@ fn scanner_segment_reuse_activation_preflight_for_cycle_skips_distributed_blocke
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn scanner_durable_segment_invalidation_evidence_requires_matching_complete_set_proofs() {
|
||||
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
|
||||
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
record_dirty_usage_bucket_from_producers("photos", SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION);
|
||||
let dirty_usage_snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
|
||||
let process_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot)
|
||||
.segment_invalidation_proof()
|
||||
.expect("complete process-local producer coverage should produce proof metadata");
|
||||
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
|
||||
let results = vec![
|
||||
complete_set_cache_with_segment_proof(DataUsageCacheSource::new(0, 0), process_proof.clone()),
|
||||
complete_set_cache_with_segment_proof(DataUsageCacheSource::new(0, 1), process_proof),
|
||||
];
|
||||
|
||||
let durable_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources);
|
||||
|
||||
assert!(durable_evidence.producer_identity_coverage_complete);
|
||||
assert!(durable_evidence.durable_producer_identity);
|
||||
assert!(durable_evidence.restart_gap_absent);
|
||||
|
||||
let mut stale_epoch = results.clone();
|
||||
stale_epoch[0]
|
||||
.info
|
||||
.segment_invalidation_proof
|
||||
.as_mut()
|
||||
.expect("proof fixture should exist")
|
||||
.process_epoch = "stale-process".to_string();
|
||||
let stale_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &stale_epoch, &expected_sources);
|
||||
assert!(stale_evidence.producer_identity_coverage_complete);
|
||||
assert!(!stale_evidence.durable_producer_identity);
|
||||
assert!(!stale_evidence.restart_gap_absent);
|
||||
|
||||
record_dirty_usage_bucket("videos");
|
||||
let changed_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources);
|
||||
assert!(!changed_evidence.producer_identity_coverage_complete);
|
||||
assert!(!changed_evidence.durable_producer_identity);
|
||||
assert!(!changed_evidence.restart_gap_absent);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn scanner_segment_reuse_activation_replays_cold_durable_baseline() {
|
||||
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
|
||||
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
for producer in SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION {
|
||||
record_dirty_usage_object_from_producer("photos", "2026/object", producer);
|
||||
}
|
||||
let dirty_usage_snapshot =
|
||||
snapshot_dirty_usage_buckets(&[bucket_info("photos"), bucket_info("archive")], dirty_usage_generation());
|
||||
let mut segment_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot)
|
||||
.segment_invalidation_proof()
|
||||
.expect("complete process-local producer coverage should produce proof metadata");
|
||||
segment_proof.cold_zero_walk_oracle = true;
|
||||
let scan_plan_digest = DataUsageScanPlanDigest([6; 32]);
|
||||
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
|
||||
let baseline = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10)),
|
||||
scanner_cycle: Some(7),
|
||||
scanner_epoch: Some(11),
|
||||
buckets_count: 2,
|
||||
buckets_usage: HashMap::from([
|
||||
("photos".to_string(), Default::default()),
|
||||
("archive".to_string(), Default::default()),
|
||||
]),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(true),
|
||||
usage_snapshot_set_states: expected_sources
|
||||
.iter()
|
||||
.map(|source| DataUsageSnapshotSetState {
|
||||
pool_index: u64::try_from(source.pool_index).expect("test pool index should fit"),
|
||||
set_index: u64::try_from(source.set_index).expect("test set index should fit"),
|
||||
scanner_cycle: Some(7),
|
||||
scanner_epoch: Some(11),
|
||||
scan_plan_digest: Some(scan_plan_digest.0),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: Some(segment_proof.clone()),
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let baseline = Bytes::from(serde_json::to_vec(&baseline).expect("baseline should encode"));
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_baseline(
|
||||
&dirty_usage_snapshot,
|
||||
false,
|
||||
ScannerCacheBaselineProof {
|
||||
authoritative_data: Some(&baseline),
|
||||
observed_candidate_data: None,
|
||||
expected_sources: &expected_sources,
|
||||
leader_epoch: 11,
|
||||
want_cycle: 8,
|
||||
scan_plan_digest,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
|
||||
|
||||
let mut missing_cold_baseline =
|
||||
serde_json::from_slice::<DataUsageInfo>(&baseline).expect("baseline should decode for negative case");
|
||||
missing_cold_baseline.usage_snapshot_set_states[0]
|
||||
.segment_invalidation_proof
|
||||
.as_mut()
|
||||
.expect("proof should exist")
|
||||
.cold_zero_walk_oracle = false;
|
||||
let missing_cold_baseline = Bytes::from(serde_json::to_vec(&missing_cold_baseline).expect("negative baseline should encode"));
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_baseline(
|
||||
&dirty_usage_snapshot,
|
||||
false,
|
||||
ScannerCacheBaselineProof {
|
||||
authoritative_data: Some(&missing_cold_baseline),
|
||||
observed_candidate_data: None,
|
||||
expected_sources: &expected_sources,
|
||||
leader_epoch: 11,
|
||||
want_cycle: 8,
|
||||
scan_plan_digest,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
vec!["missing_producer_identity", "restart_gap", "missing_cold_zero_walk_oracle"]
|
||||
);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_cycle_result_returns_segment_reuse_activation_preflight() {
|
||||
let proof = ScannerSegmentReuseActivationProof {
|
||||
production_activation: true,
|
||||
producer_identity_coverage_complete: true,
|
||||
durable_producer_identity: true,
|
||||
restart_gap_absent: true,
|
||||
generation_window_bound: true,
|
||||
@@ -399,37 +241,6 @@ fn assert_segment_reuse_activation_blocked_by(proof: ScannerSegmentReuseActivati
|
||||
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), vec![blocker]);
|
||||
}
|
||||
|
||||
fn complete_process_local_producer_evidence() -> DirtyUsageProducerEvidence {
|
||||
DirtyUsageProducerEvidence {
|
||||
producer_identity_coverage_complete: true,
|
||||
durable_producer_identity: false,
|
||||
restart_gap_absent: false,
|
||||
generation_window_bound: true,
|
||||
generation_start: 7,
|
||||
generation_end: 7,
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_set_cache_with_segment_proof(
|
||||
source: DataUsageCacheSource,
|
||||
proof: crate::DataUsageSegmentInvalidationProof,
|
||||
) -> DataUsageCache {
|
||||
DataUsageCache {
|
||||
info: DataUsageCacheInfo {
|
||||
name: DATA_USAGE_ROOT.to_string(),
|
||||
next_cycle: 7,
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
leader_epoch: 11,
|
||||
source: Some(source),
|
||||
snapshot_complete: true,
|
||||
scan_plan_digest: Some(DataUsageScanPlanDigest([3; 32])),
|
||||
segment_invalidation_proof: Some(proof),
|
||||
..Default::default()
|
||||
},
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_two_pool_scanner_store() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||
init_ecstore_config_for_scanner_tests();
|
||||
let temp_dir = tempfile::tempdir().expect("multi-pool scanner test directory should be created");
|
||||
@@ -1384,29 +1195,6 @@ fn dirty_usage_snapshot_detects_uncovered_generation() {
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn dirty_usage_producer_evidence_tracks_process_local_coverage_without_durable_restart_authority() {
|
||||
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
|
||||
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
record_dirty_usage_bucket_from_producers("photos", SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION);
|
||||
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
|
||||
|
||||
let evidence = dirty_usage_producer_evidence(&snapshot);
|
||||
|
||||
assert!(evidence.generation_window_bound);
|
||||
assert!(evidence.producer_identity_coverage_complete);
|
||||
assert!(!evidence.durable_producer_identity);
|
||||
assert!(!evidence.restart_gap_absent);
|
||||
|
||||
record_dirty_usage_bucket_from_producer("videos", SegmentInvalidationProducerIdentity::PutObject);
|
||||
let stale_evidence = dirty_usage_producer_evidence(&snapshot);
|
||||
assert!(!stale_evidence.generation_window_bound);
|
||||
assert!(!stale_evidence.producer_identity_coverage_complete);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_saturates_instead_of_wrapping() {
|
||||
let generation = AtomicU64::new(u64::MAX - 1);
|
||||
@@ -1714,13 +1502,6 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
let empty_execution = DataUsageScanPlanDigest([5; 32]);
|
||||
let segment_invalidation_proof = crate::DataUsageSegmentInvalidationProof {
|
||||
process_epoch: scanner_activity_epoch().to_string(),
|
||||
generation_start: 8,
|
||||
generation_end: 8,
|
||||
producer_identity_coverage_complete: true,
|
||||
cold_zero_walk_oracle: false,
|
||||
};
|
||||
set.nsscanner_cache(
|
||||
ctx.clone(),
|
||||
ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default()),
|
||||
@@ -1741,7 +1522,6 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
|
||||
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
|
||||
cache_cycle_floor: Arc::new(AtomicU64::new(8)),
|
||||
cold_zero_walk_reuse_observed: Arc::new(AtomicBool::new(false)),
|
||||
segment_invalidation_proof: Some(segment_invalidation_proof.clone()),
|
||||
},
|
||||
tx,
|
||||
8,
|
||||
@@ -1751,7 +1531,6 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
|
||||
.expect("empty set scope should replace its prior nonempty cache");
|
||||
let empty = rx.try_recv().expect("empty set snapshot should be published");
|
||||
assert_eq!(empty.info.scan_execution_digest, Some(empty_execution));
|
||||
assert_eq!(empty.info.segment_invalidation_proof, Some(segment_invalidation_proof));
|
||||
assert!(empty.info.snapshot_complete);
|
||||
let root = empty.checked_flatten(DATA_USAGE_ROOT).expect("complete empty root");
|
||||
assert_eq!((root.size, root.objects), (0, 0));
|
||||
@@ -1779,7 +1558,6 @@ fn complete_usage_baseline(
|
||||
scan_plan_digest: Some(scan_plan_digest.0),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -84,14 +84,7 @@ async fn persist_baseline(store: &Arc<ECStore>, baseline: &DataUsageInfo) {
|
||||
|
||||
// Every invocation uses the production default scope. Once durable bucket
|
||||
// incarnations are present, the expected walker set follows the resolved scope.
|
||||
async fn run_entry(
|
||||
store: &Arc<ECStore>,
|
||||
cycle: u64,
|
||||
selected: Option<&str>,
|
||||
expect_walks: bool,
|
||||
expect_activation: bool,
|
||||
expect_prefix_scope: bool,
|
||||
) -> DataUsageInfo {
|
||||
async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, expect_walks: bool) -> DataUsageInfo {
|
||||
let drives = drive_identities(store).await;
|
||||
let inventory = store
|
||||
.list_bucket_for_scanner(&BucketOptions::default())
|
||||
@@ -151,13 +144,6 @@ async fn run_entry(
|
||||
scope.selected_buckets.as_deref(),
|
||||
selected.map(|name| HashSet::from([name.to_string()])).as_ref()
|
||||
);
|
||||
if let Some(selected) = selected {
|
||||
assert_eq!(
|
||||
scope.prefix_scope_for(selected).is_some(),
|
||||
expect_prefix_scope,
|
||||
"resolved prefix scope must match activation replay for cycle {cycle}"
|
||||
);
|
||||
}
|
||||
let usage = receiver.recv().await.expect("one candidate should be delivered");
|
||||
assert!(receiver.recv().await.is_none(), "there must be exactly one terminal candidate");
|
||||
assert!(usage.usage_snapshot_complete);
|
||||
@@ -189,12 +175,10 @@ async fn run_entry(
|
||||
actual, expected_walks,
|
||||
"each listed source/bucket must have exactly the expected real walks"
|
||||
);
|
||||
assert!(activation_preflight.production_activation);
|
||||
assert_eq!(activation_preflight.scanner_segment_reuse_activated, expect_activation);
|
||||
assert!(!activation_preflight.production_activation);
|
||||
assert!(!activation_preflight.scanner_segment_reuse_activated);
|
||||
let activation_blockers = activation_preflight.fail_closed_blockers().collect::<Vec<_>>();
|
||||
if expect_activation {
|
||||
assert_eq!(activation_blockers, Vec::<&str>::new());
|
||||
} else if selected.is_some() && expect_walks {
|
||||
if selected.is_some() && expect_walks {
|
||||
assert!(
|
||||
!activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
|
||||
"a complete scoped reuse cycle must carry the cold zero-walk oracle: cycle={cycle} selected={selected:?} blockers={activation_blockers:?}"
|
||||
@@ -220,78 +204,32 @@ async fn run_entry(
|
||||
usage
|
||||
}
|
||||
|
||||
fn record_segment_dirty_usage(bucket: &str) {
|
||||
for producer in crate::segment_invalidation::SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION {
|
||||
record_dirty_usage_object_from_producer(bucket, "hot-segment/object", producer);
|
||||
}
|
||||
}
|
||||
|
||||
// The scoped fallback fixture keeps two EC pools and several scan futures live
|
||||
// at once. Run the async cases on a dedicated stack so Linux libtest defaults
|
||||
// exercise the assertions instead of aborting before the oracle finishes.
|
||||
fn run_scoped_entry_fallback_test<F, Fut>(thread_name: &'static str, test_fn: F)
|
||||
where
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
Fut: std::future::Future<Output = ()> + 'static,
|
||||
{
|
||||
let handle = std::thread::Builder::new()
|
||||
.name(thread_name.to_string())
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("scoped entry fallback runtime should build");
|
||||
runtime.block_on(test_fn());
|
||||
})
|
||||
.expect("scoped entry fallback test thread should spawn");
|
||||
if let Err(payload) = handle.join() {
|
||||
std::panic::resume_unwind(payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks() {
|
||||
run_scoped_entry_fallback_test(
|
||||
"scanner-scoped-entry-planned-scope",
|
||||
scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks_case,
|
||||
);
|
||||
}
|
||||
|
||||
async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks_case() {
|
||||
async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks() {
|
||||
let (_dir, store) = setup_two_pool_scanner_store().await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let hot = format!("hot-{}", Uuid::new_v4().simple());
|
||||
let cold = format!("cold-{}", Uuid::new_v4().simple());
|
||||
create_bucket(&store, &hot).await;
|
||||
create_bucket(&store, &cold).await;
|
||||
record_segment_dirty_usage(&hot);
|
||||
let baseline = run_entry(&store, 1, None, true, false, false).await;
|
||||
record_dirty_usage_bucket(&hot);
|
||||
let baseline = run_entry(&store, 1, None, true).await;
|
||||
persist_baseline(&store, &baseline).await;
|
||||
|
||||
// Same-cycle Current remains a retry. The later cycle may skip the cold
|
||||
// bucket only after the prior complete set cache has durable incarnations.
|
||||
run_entry(&store, 1, Some(&hot), false, false, false).await;
|
||||
let usage = run_entry(&store, 2, Some(&hot), true, true, false).await;
|
||||
persist_baseline(&store, &usage).await;
|
||||
let usage = run_entry(&store, 3, Some(&hot), true, true, true).await;
|
||||
run_entry(&store, 1, Some(&hot), false).await;
|
||||
let usage = run_entry(&store, 2, Some(&hot), true).await;
|
||||
assert_eq!(usage.buckets_usage[&hot].objects_count, 1);
|
||||
assert_eq!(usage.buckets_usage[&cold].objects_count, 1);
|
||||
assert_eq!(usage.objects_total_count, 2);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker() {
|
||||
run_scoped_entry_fallback_test(
|
||||
"scanner-scoped-entry-invalid-baseline",
|
||||
scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_case,
|
||||
);
|
||||
}
|
||||
|
||||
async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_case() {
|
||||
async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker() {
|
||||
let (_dir, store) = setup_two_pool_scanner_store().await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let hot = format!("hot-{}", Uuid::new_v4().simple());
|
||||
@@ -300,7 +238,7 @@ async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_
|
||||
create_bucket(&store, &cold).await;
|
||||
record_dirty_usage_bucket(&hot);
|
||||
// The first real scan is also the missing persisted-baseline case.
|
||||
let baseline = run_entry(&store, 1, None, true, false, false).await;
|
||||
let baseline = run_entry(&store, 1, None, true).await;
|
||||
for (index, kind) in [
|
||||
"malformed",
|
||||
"unconverged",
|
||||
@@ -333,43 +271,28 @@ async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_
|
||||
crate::save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), bytes)
|
||||
.await
|
||||
.expect("negative baseline should persist");
|
||||
let usage = run_entry(
|
||||
&store,
|
||||
u64::try_from(index).expect("fixture cycle index should fit") + 2,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let usage = run_entry(&store, u64::try_from(index).expect("fixture cycle index should fit") + 2, None, true).await;
|
||||
assert_eq!(usage.objects_total_count, 2, "{kind}");
|
||||
assert_eq!(usage.buckets_usage[&cold].objects_count, 1, "{kind}");
|
||||
}
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
|
||||
run_scoped_entry_fallback_test(
|
||||
"scanner-scoped-entry-overflow-inventory",
|
||||
scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case,
|
||||
);
|
||||
}
|
||||
|
||||
async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case() {
|
||||
async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
|
||||
let (_dir, store) = setup_two_pool_scanner_store().await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let hot = format!("hot-{}", Uuid::new_v4().simple());
|
||||
create_bucket(&store, &hot).await;
|
||||
record_dirty_usage_bucket(&hot);
|
||||
let baseline = run_entry(&store, 1, None, true, false, false).await;
|
||||
let baseline = run_entry(&store, 1, None, true).await;
|
||||
persist_baseline(&store, &baseline).await;
|
||||
for index in 0..=crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES {
|
||||
record_dirty_usage_bucket(&format!("overflow-{index}"));
|
||||
}
|
||||
assert!(dirty_usage_buckets_for_tests().len() > crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES);
|
||||
let usage = run_entry(&store, 2, None, true, false, false).await;
|
||||
let usage = run_entry(&store, 2, None, true).await;
|
||||
assert_eq!(usage.objects_total_count, 1);
|
||||
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
@@ -377,7 +300,7 @@ async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case() {
|
||||
let new_bucket = format!("new-{}", Uuid::new_v4().simple());
|
||||
create_bucket(&store, &new_bucket).await;
|
||||
// Even a previously valid baseline cannot cover the changed inventory.
|
||||
let usage = run_entry(&store, 3, None, true, false, false).await;
|
||||
let usage = run_entry(&store, 3, None, true).await;
|
||||
assert_eq!(usage.objects_total_count, 2);
|
||||
assert_eq!(usage.buckets_usage[&new_bucket].objects_count, 1);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
|
||||
@@ -67,12 +67,6 @@ Heal-side invariants that hold regardless of the caller:
|
||||
- Read-repair's local TTL reservation dedups only its own source and does not block heals from other sources; the namespace lock is the backstop.
|
||||
- The healing flag is never persisted, so there is no reverse risk of a leftover marker making a later commit yield incorrectly.
|
||||
|
||||
## Graceful root-heal restart recovery
|
||||
|
||||
Before a graceful shutdown cancels administrator cluster-wide heals, the manager saves unfinished requests on one coordinator disk as `.rustfs.sys/root-heal-<task-id>.json`. Startup replays the same task IDs and remaining execution budgets. Completion, cancellation, and replacement by `force_start` retire the record conditionally. An uncertain write or deletion does not create a fallback copy; an unsuccessful handoff retains the unclean-shutdown marker. Invalid or unsupported records remain on disk and defer root recovery without blocking the existing replacement-recovery path.
|
||||
|
||||
This handoff covers the same coordinator and storage topology while the record disk remains configured and readable. It does not migrate records when a pool is retired or provide failover after loss of that disk. Older versions do not understand these records; cancellation while downgraded cannot retire a newer version's pending record. If a terminal budget checkpoint cannot be written, the previous record is retained and a warning is logged; the remaining-budget guarantee requires that write to succeed. The format is separate from object metadata and erasure-set checkpoints.
|
||||
|
||||
## Regression tests
|
||||
|
||||
Both live in the test module of `crates/ecstore/src/set_disk/ops/heal.rs`:
|
||||
|
||||
@@ -38,12 +38,6 @@ Counts ignore blank lines and comments; compute them from the files. The lifecyc
|
||||
|
||||
"Supported" for the SSE row means RustFS encrypts and decrypts its own objects. MinIO SSE objects (SSE-S3, SSE-KMS, SSE-C) are not readable in default builds; see [minio-file-format-compat.md Part C](minio-file-format-compat.md#part-c--server-side-encryption-sse) for the `rio-v2` migration build.
|
||||
|
||||
### Client metadata expectations
|
||||
|
||||
`CopyObject` with `MetadataDirective=REPLACE` clears standard metadata fields that the request omits, including `Content-Type`; it does not retain the source type or infer a default. Clients requiring a MIME type on the copied object must send `Content-Type` with the replacement metadata. This contract is covered by `crates/e2e_test/src/copy_object_metadata_test.rs`. A client test that expects an implicit `application/octet-stream` does not match this behavior.
|
||||
|
||||
The MinIO-style `metadata=true` listing extension returns user metadata names without the HTTP `x-amz-meta-` prefix. It is not the standard S3 `ListObjectsV2` response. Clients that expect canonical HTTP header names in `UserMetadata` must normalize the names at that boundary; ordinary HEAD/GET metadata is unaffected. See `rustfs/src/app/bucket_usecase.rs` and its serialization tests.
|
||||
|
||||
## Replication Support Boundary
|
||||
|
||||
Site replication and bucket replication are not the same compatibility claim.
|
||||
|
||||
+48
-68
@@ -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 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.
|
||||
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.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
@@ -13,11 +13,9 @@ 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 `required-checks` | Exact expected results for every CI validation job, including workspace checks, critical E2E, feature lanes, and event-specific full suites |
|
||||
| `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`) |
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
Verify the live rule before changing merge policy:
|
||||
|
||||
@@ -26,25 +24,25 @@ gh api repos/rustfs/rustfs/rulesets/6436880 \
|
||||
--jq '.rules[] | select(.type == "required_status_checks") | .parameters'
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Pull request and merge matrix
|
||||
|
||||
"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.
|
||||
"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.
|
||||
|
||||
| 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 | `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, 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 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` |
|
||||
@@ -54,8 +52,8 @@ The aggregate requires the validation lanes already selected by `ci.yml`; this c
|
||||
| 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, 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` |
|
||||
| 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` |
|
||||
|
||||
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.
|
||||
|
||||
@@ -69,11 +67,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 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`.
|
||||
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`.
|
||||
|
||||
| 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` | strict aggregate; the full E2E lane runs on dispatch, merge groups, and main pushes | 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` | per-job | 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` |
|
||||
@@ -90,7 +88,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 missing or stale attempts or completed successes | n/a | dispatch |
|
||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | 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.
|
||||
|
||||
@@ -135,24 +133,21 @@ evidence registered in `.config/scanner-heal-required-tests.json`. It records
|
||||
already-built binaries and checks existing nextest output; it does not build,
|
||||
run tests, deploy servers, inject faults, or start another CI lane.
|
||||
|
||||
The registered cases are emitted by existing E2E tests. The original
|
||||
`background-target-restart` / `background-target-crash` cases run in
|
||||
`e2e-nightly` on a four-node, one-drive-per-node topology. The
|
||||
`ec84-target-drive-restart` case runs in `e2e-distributed` on a three-node,
|
||||
four-drive EC8+4 topology. When `RUSTFS_SCANNER_HEAL_RUN_DIR` is set, the
|
||||
producer checks the actual server and test-executable hashes against `run.json`,
|
||||
pins the same server binary for all node starts, and writes its oracle only
|
||||
after the real assertions pass. The artifact contains the actual pre/post target
|
||||
The initial case is `background-target-restart`, emitted by
|
||||
`heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart`.
|
||||
That test already runs in `e2e-nightly`. When `RUSTFS_SCANNER_HEAL_RUN_DIR` is set,
|
||||
it checks the actual server and test-executable hashes against `run.json`, pins
|
||||
the same server binary for all node starts, and writes its oracle only after
|
||||
the real assertions pass. The artifact contains the actual pre/post target
|
||||
PIDs, per-node S3 listings, expected and downloaded complete-body hashes/lengths,
|
||||
and target-disk `VersionShardCensus` fingerprints. Existing baseline objects
|
||||
must match their pre-fault physical manifests; the object created during the
|
||||
outage has no pre-fault target shard and is checked for complete physical parts
|
||||
and exact S3 content.
|
||||
|
||||
These cases are still restart-focused evidence slices. They are not power-loss
|
||||
validation, an all-version inventory, or proof of scanner enumeration, exact MRF
|
||||
disposition, legacy migration, multi-pool/multi-set release coverage, or
|
||||
rollback.
|
||||
This case is a **four-node, one-drive-per-node process-restart test**. It is not
|
||||
power-loss validation, a 3x4 EC8+4 experiment, an all-version inventory, or proof
|
||||
of scanner enumeration, exact MRF disposition, legacy migration, or rollback.
|
||||
The schema 2 registry separates the implemented single-set restart lane from
|
||||
structured release lanes for authority coverage, checkpoint/crash, status and
|
||||
outcome, MRF responsibility, mixed-version rollback, scheduler pressure,
|
||||
@@ -183,12 +178,27 @@ The producer checks this compiled identity against the receipt; it does not
|
||||
copy a current source revision into an older test binary's identity. The E2E
|
||||
uses its existing temporary cluster directories and cleanup. `CARGO_TARGET_DIR`
|
||||
controls compilation output; nextest's default report store remains the
|
||||
workspace's `target/nextest`. Prefer the registry-aware runner for concrete
|
||||
cases:
|
||||
workspace's `target/nextest`. Execute the existing selected case as follows:
|
||||
|
||||
```bash
|
||||
scripts/run_scanner_heal_evidence_case.sh --case background-target-restart
|
||||
scripts/run_scanner_heal_evidence_case.sh --case ec84-target-drive-restart
|
||||
CASE=background-target-restart
|
||||
FILTER='test(test_cluster_root_heal_recovers_remote_shards_after_background_target_restart)'
|
||||
RUN_DIR="$PWD/artifacts/scanner-heal-run"
|
||||
export RUSTFS_E2E_EXPECTED_FEATURES=default
|
||||
scripts/python_bin.sh scripts/check_test_wiring.py \
|
||||
--begin-scanner-heal "$RUN_DIR" "$SERVER_BINARY" "$TEST_BINARY"
|
||||
export RUSTFS_SCANNER_HEAL_RUN_DIR="$RUN_DIR"
|
||||
export CARGO_BIN_EXE_rustfs="$SERVER_BINARY"
|
||||
cargo nextest list --profile e2e-nightly -p e2e_test -E "$FILTER" \
|
||||
--message-format json > "$RUN_DIR/listing.json"
|
||||
rm -f target/nextest/e2e-nightly/junit.xml
|
||||
set +e
|
||||
cargo nextest run --profile e2e-nightly -p e2e_test -E "$FILTER"
|
||||
test_exit=$?
|
||||
set -e
|
||||
cp target/nextest/e2e-nightly/junit.xml "$RUN_DIR/junit.xml"
|
||||
scripts/python_bin.sh scripts/check_test_wiring.py --finish-scanner-heal "$RUN_DIR" "$test_exit"
|
||||
scripts/python_bin.sh scripts/check_test_wiring.py --check-scanner-heal "$RUN_DIR" "$CASE"
|
||||
```
|
||||
|
||||
Set `RUSTFS_E2E_EXPECTED_FEATURES` to the actual intended e2e crate feature set,
|
||||
@@ -279,36 +289,6 @@ another platform, or `--test mixed-version|rollback` while narrowing a failure.
|
||||
It performs a free-space preflight before building so a saturated validation
|
||||
host fails before producing partial evidence.
|
||||
|
||||
The W16 recovery-intent and quota-authority lanes can emit raw G04/G12 JSON
|
||||
artifacts with:
|
||||
|
||||
```bash
|
||||
scripts/run_scanner_heal_w16_recovery_evidence.sh
|
||||
```
|
||||
|
||||
The runner builds the current checkout, runs the scanner recovery-intent and
|
||||
disabled-startup crash-boundary tests, runs the scanner quota reset-preservation
|
||||
tests, and runs the distributed hard-quota admission E2E. A full run writes
|
||||
`release-bundle-w16.json` and validates the G04 and G12 gates with
|
||||
`--check-scanner-heal-release-bundle-gate`. Use `--test g04|g12` while narrowing
|
||||
a failure; a single gate descriptor still does not approve the complete release
|
||||
bundle.
|
||||
|
||||
The W13 durable MRF replay lanes can emit raw G07/G08/P4 JSON artifacts with:
|
||||
|
||||
```bash
|
||||
scripts/run_scanner_heal_w13_mrf_evidence.sh
|
||||
```
|
||||
|
||||
The runner builds the current checkout, runs the ignored MRF evidence test, and
|
||||
writes `release-bundle-w13.json` for `--check-scanner-heal-release-bundle-gate`.
|
||||
Use `--test g07|g08|p4` while narrowing a failure. G08 disk-full evidence must
|
||||
run against a real fillable filesystem: on Linux as root the runner mounts a
|
||||
small tmpfs automatically, otherwise pass `--enospc-root` pointing at a
|
||||
pre-mounted small filesystem. P4 is release evidence only when it completes the
|
||||
default two-hour soak; `--allow-short-soak` is diagnostic and skips P4 bundle
|
||||
gate validation.
|
||||
|
||||
When the real release lanes have produced their dedicated artifacts, validate
|
||||
the complete hard-gate bundle with:
|
||||
|
||||
|
||||
@@ -91,16 +91,4 @@ tests, and leaves the required raw G09 artifacts under
|
||||
inputs for the Scanner/Heal release bundle gate; the runner does not mark the
|
||||
full release matrix complete by itself.
|
||||
|
||||
The distributed Scanner/Heal EC8+4 restart case is registered as
|
||||
`ec84-target-drive-restart` and selected by this profile:
|
||||
|
||||
```bash
|
||||
scripts/run_scanner_heal_evidence_case.sh --case ec84-target-drive-restart
|
||||
```
|
||||
|
||||
That command records the current build, runs exactly the registered
|
||||
`distributed::heal_test` case, validates the JUnit/listing/oracle receipt, and
|
||||
keeps the wider release gate blocked until the remaining release evidence lanes
|
||||
have measured artifacts.
|
||||
|
||||
Membership is pinned by `.config/e2e-distributed-selection.txt`. Update the Linux and Darwin entries with `python3 ./scripts/check_test_wiring.py --update-profile e2e-distributed <listing.json> <platform>` after adding or renaming a case.
|
||||
|
||||
@@ -775,7 +775,6 @@ mod tests {
|
||||
scan_plan_digest: Some([1; 32]),
|
||||
complete: true,
|
||||
tombstone: false,
|
||||
segment_invalidation_proof: None,
|
||||
}];
|
||||
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-a".to_string()]);
|
||||
assert_eq!(info.usage_snapshot_converged, Some(false));
|
||||
|
||||
@@ -689,8 +689,8 @@ fn normalize_input_serialization(input: &mut InputSerialization) -> S3Result<()>
|
||||
));
|
||||
}
|
||||
validate_single_byte(csv.comments.as_deref(), S3ErrorCode::InvalidRequestParameter)?;
|
||||
validate_single_character(csv.quote_character.as_deref())?;
|
||||
validate_single_character(csv.quote_escape_character.as_deref())?;
|
||||
validate_single_byte(csv.quote_character.as_deref(), S3ErrorCode::InvalidRequestParameter)?;
|
||||
validate_single_byte(csv.quote_escape_character.as_deref(), S3ErrorCode::InvalidRequestParameter)?;
|
||||
validate_input_record_delimiter(csv.record_delimiter.as_deref())?;
|
||||
validate_input_delimiter_pair(csv.field_delimiter.as_deref(), csv.record_delimiter.as_deref())?;
|
||||
}
|
||||
@@ -778,15 +778,6 @@ fn invalid_scan_range_error() -> S3Error {
|
||||
S3Error::with_message(S3ErrorCode::InvalidRequestParameter, INVALID_SCAN_RANGE_MESSAGE.to_string())
|
||||
}
|
||||
|
||||
fn validate_single_character(value: Option<&str>) -> S3Result<()> {
|
||||
if let Some(value) = value
|
||||
&& value.chars().count() != 1
|
||||
{
|
||||
return Err(S3Error::new(S3ErrorCode::InvalidRequestParameter));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_single_byte(value: Option<&str>, code: S3ErrorCode) -> S3Result<()> {
|
||||
if let Some(value) = value
|
||||
&& value.len() != 1
|
||||
@@ -3533,29 +3524,6 @@ mod tests {
|
||||
assert_eq!(error.message(), Some(INVALID_SCAN_RANGE_MESSAGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_single_unicode_csv_input_quotes() {
|
||||
for quote in ["ع", "界", "🦀"] {
|
||||
let mut input = base_input();
|
||||
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
|
||||
csv.quote_character = Some(quote.to_owned());
|
||||
csv.quote_escape_character = Some(quote.to_owned());
|
||||
validate_select_request(&HeaderMap::new(), &mut input).expect("one Unicode scalar is a valid CSV quote");
|
||||
}
|
||||
for quote in ["", "عع", "e\u{301}"] {
|
||||
let mut input = base_input();
|
||||
input
|
||||
.request
|
||||
.input_serialization
|
||||
.csv
|
||||
.as_mut()
|
||||
.expect("CSV input")
|
||||
.quote_character = Some(quote.to_owned());
|
||||
let error = validate_select_request(&HeaderMap::new(), &mut input).expect_err("quote must be one scalar");
|
||||
assert_eq!(error.code(), &S3ErrorCode::InvalidRequestParameter);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_unknown_csv_header_mode_before_streaming() {
|
||||
let mut input = base_input();
|
||||
|
||||
@@ -280,7 +280,6 @@ pub(crate) async fn run_startup_shutdown_sequence(
|
||||
let enable_scanner = get_env_bool_with_aliases(ENV_SCANNER_ENABLED, &[ENV_SCANNER_ENABLED_DEPRECATED], true);
|
||||
let enable_heal = get_env_bool_with_aliases(ENV_HEAL_ENABLED, &[ENV_HEAL_ENABLED_DEPRECATED], true);
|
||||
|
||||
let mut heal_handoff_complete = true;
|
||||
let background_steps = background_shutdown_steps(enable_scanner, enable_heal);
|
||||
for step in &background_steps {
|
||||
match step {
|
||||
@@ -306,19 +305,7 @@ pub(crate) async fn run_startup_shutdown_sequence(
|
||||
state = "stopping",
|
||||
"Background service shutdown started"
|
||||
);
|
||||
if let Err(error) = shutdown_ahm_services().await {
|
||||
heal_handoff_complete = false;
|
||||
warn!(
|
||||
target: "rustfs::main::handle_shutdown",
|
||||
event = EVENT_BACKGROUND_SERVICE_SHUTDOWN,
|
||||
component = LOG_COMPONENT_MAIN,
|
||||
subsystem = LOG_SUBSYSTEM_STARTUP,
|
||||
service = "ahm",
|
||||
state = "handoff_failed",
|
||||
error = %error,
|
||||
"Heal shutdown handoff failed; retaining unclean-shutdown markers"
|
||||
);
|
||||
}
|
||||
shutdown_ahm_services();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,9 +411,7 @@ pub(crate) async fn run_startup_shutdown_sequence(
|
||||
shutdown_optional_runtime_services(optional_runtime_shutdowns).await;
|
||||
// The data plane is drained: record this shutdown as clean so the next
|
||||
// startup skips the unclean-restart erasure-set heal.
|
||||
if heal_handoff_complete {
|
||||
rustfs_heal::heal::clear_unclean_shutdown_markers().await;
|
||||
}
|
||||
rustfs_heal::heal::clear_unclean_shutdown_markers().await;
|
||||
state_manager.update(ServiceState::Stopped);
|
||||
info!(
|
||||
target: "rustfs::main::handle_shutdown",
|
||||
|
||||
@@ -1006,22 +1006,12 @@ pub(crate) async fn apply_cors_headers(bucket: &str, method: &http::Method, head
|
||||
}
|
||||
|
||||
// Access-Control-Allow-Headers (required for preflight if headers were requested)
|
||||
if is_preflight && let Some(ref requested_headers) = requested_headers {
|
||||
// Every requested header matched this rule; do not expose its wildcard
|
||||
// or grant headers that the preflight did not request.
|
||||
let headers_str = requested_headers.join(",");
|
||||
if is_preflight && let Some(ref allowed_headers) = rule.allowed_headers {
|
||||
let headers_str = allowed_headers.iter().map(|h| h.as_str()).collect::<Vec<_>>().join(", ");
|
||||
if let Ok(headers_value) = HeaderValue::from_str(&headers_str) {
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_HEADERS, headers_value);
|
||||
}
|
||||
}
|
||||
if is_preflight {
|
||||
let vary = if origin_reflected {
|
||||
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
|
||||
} else {
|
||||
"Access-Control-Request-Method, Access-Control-Request-Headers"
|
||||
};
|
||||
response_headers.insert(cors::standard::VARY, HeaderValue::from_static(vary));
|
||||
}
|
||||
|
||||
// Access-Control-Expose-Headers (for actual requests)
|
||||
if !is_preflight && let Some(ref expose_headers) = rule.expose_headers {
|
||||
|
||||
@@ -1736,10 +1736,7 @@ mod tests {
|
||||
"https://console.localhost",
|
||||
);
|
||||
assert_eq!(result.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
assert_eq!(
|
||||
result.get(cors::standard::VARY).unwrap(),
|
||||
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
|
||||
);
|
||||
assert_eq!(result.get(cors::standard::VARY).unwrap(), "Origin");
|
||||
|
||||
set_bucket_metadata(bucket.to_string(), BucketMetadata::new(bucket))
|
||||
.await
|
||||
|
||||
@@ -57,12 +57,8 @@ their issue closes.
|
||||
| `run_scanner_validation_harness.sh` | dev-tool | Scanner validation harness | `docs/operations/scanner-benchmark-runbook.md` |
|
||||
| `run_scanner_heal_evidence_case.sh` | dev-tool | Runs one Scanner/Heal release-evidence registry case and checks the produced receipt/oracle | `.config/scanner-heal-required-tests.json`; `check_test_wiring.py --check-scanner-heal` |
|
||||
| `run_scanner_heal_g09_upgrade_evidence.sh` | dev-tool | Runs the G09 mixed-version and rollback upgrade E2E lanes against a pinned previous release and verifies the raw evidence artifacts | `docs/testing/ci-gates.md`; `.github/workflows/e2e-upgrade.yml`; `test_scanner_heal_g09_upgrade_evidence.sh` |
|
||||
| `run_scanner_heal_w13_mrf_evidence.sh` | dev-tool | Runs the W13 durable MRF replay lanes and writes G07/G08/P4 bundle-ready evidence descriptors | `docs/testing/ci-gates.md`; `test_scanner_heal_w13_mrf_evidence.sh` |
|
||||
| `run_scanner_heal_w16_recovery_evidence.sh` | dev-tool | Runs the W16 recovery-intent and quota authority lanes and writes G04/G12 bundle-ready evidence descriptors | `docs/testing/ci-gates.md`; `test_scanner_heal_w16_recovery_evidence.sh` |
|
||||
| `test_scanner_validation_harness.sh` | dev-tool | Self-test for the scanner validation harness | — |
|
||||
| `test_scanner_heal_g09_upgrade_evidence.sh` | dev-tool | Shell self-test for the Scanner/Heal G09 upgrade evidence runner | — |
|
||||
| `test_scanner_heal_w16_recovery_evidence.sh` | dev-tool | Shell self-test for the Scanner/Heal W16 recovery evidence runner | — |
|
||||
| `test_scanner_heal_w13_mrf_evidence.sh` | dev-tool | Shell self-test for the Scanner/Heal W13 MRF evidence runner | — |
|
||||
| `scanner_abba.py` | dev-tool | Scanner/heal ABBA orchestration and evidence gates via `run_scanner_validation_harness.sh --abba` | `docs/operations/scanner-benchmark-runbook.md` |
|
||||
| `test_scanner_abba.py` | dev-tool | Synthetic ABBA adapter and failure-path tests | `test_scanner_validation_harness.sh` |
|
||||
| `test_build_rustfs_options.sh` | dev-tool | Shell test for rustfs build-option wiring | `make test` (script-tests) |
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/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)"
|
||||
+26
-177
@@ -77,9 +77,6 @@ SCANNER_HEAL_RELEASE_REQUIRED_EVIDENCE_FIELDS = {
|
||||
"distributed_segment_invalidation_evidence",
|
||||
),
|
||||
"P4": (
|
||||
"mrf_scale_measurement",
|
||||
"mrf_replay_cost_measurement",
|
||||
"retained_responsibility_evidence",
|
||||
"mrf_cleanup_gc_soak_evidence",
|
||||
),
|
||||
"P2": (
|
||||
@@ -105,7 +102,12 @@ SCANNER_HEAL_RELEASE_BUNDLE_REQUIRED_EVIDENCE_FIELDS = {
|
||||
"P1": ("cold_walk_share_measurement", "foreground_latency_throughput_measurement", "profile_evidence"),
|
||||
"P2": SCANNER_HEAL_RELEASE_REQUIRED_EVIDENCE_FIELDS["P2"],
|
||||
"P3": ("two_hour_pressure_measurement", "heal_capacity_measurement", "recovery_window_measurement"),
|
||||
"P4": SCANNER_HEAL_RELEASE_REQUIRED_EVIDENCE_FIELDS["P4"],
|
||||
"P4": (
|
||||
"mrf_scale_measurement",
|
||||
"mrf_replay_cost_measurement",
|
||||
"retained_responsibility_evidence",
|
||||
"mrf_cleanup_gc_soak_evidence",
|
||||
),
|
||||
"R-E": ("fixed_budget_restart_evidence", "enumeration_evidence", "classification_evidence"),
|
||||
"R-D": ("manager_disposition_evidence", "event_disposition_evidence", "ledger_disposition_evidence", "grace_handling"),
|
||||
"R-L": ("legacy_source_conflict_evidence", "migration_gap_evidence", "crash_safe_source_retirement_evidence"),
|
||||
@@ -129,17 +131,6 @@ SCANNER_HEAL_RELEASE_MRF_DURABLE_REPLAY_FIELDS = {
|
||||
("P4", "retained_responsibility_evidence"),
|
||||
("P4", "mrf_cleanup_gc_soak_evidence"),
|
||||
}
|
||||
SCANNER_HEAL_RELEASE_MRF_ARTIFACT_KINDS = {
|
||||
("G07", "mrf_responsibility_oracle"): "mrf-durable-responsibility-oracle",
|
||||
("G07", "commit_boundary_crash_matrix"): "mrf-commit-boundary-crash-matrix",
|
||||
("G08", "mrf_capacity_evidence"): "mrf-capacity-boundary",
|
||||
("G08", "disk_full_matrix"): "mrf-disk-full-enospc-matrix",
|
||||
("G08", "replica_loss_matrix"): "mrf-replica-loss-matrix",
|
||||
("P4", "mrf_scale_measurement"): "mrf-scale-measurement",
|
||||
("P4", "mrf_replay_cost_measurement"): "mrf-replay-cost-measurement",
|
||||
("P4", "retained_responsibility_evidence"): "mrf-retained-responsibility-soak",
|
||||
("P4", "mrf_cleanup_gc_soak_evidence"): "mrf-cleanup-gc-soak",
|
||||
}
|
||||
SCANNER_HEAL_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS = (
|
||||
"missing_producer_identity",
|
||||
"restart_gap",
|
||||
@@ -775,7 +766,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",):
|
||||
for name in ("ci.yml", "ci-docs-only.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
|
||||
@@ -1526,63 +1517,11 @@ def is_json_artifact_format(value: str) -> bool:
|
||||
return normalized == "json" or normalized.endswith("+json")
|
||||
|
||||
|
||||
def release_bundle_json_artifact_mirrored_fields(gate: str, field: str) -> tuple[str, ...]:
|
||||
fields: list[str] = []
|
||||
if gate in ("G03", "G09", "R-L"):
|
||||
fields.extend(("versions", "mixed_version_role"))
|
||||
if gate in ("G04", "G07", "R-E", "R-L"):
|
||||
fields.append("crash_points")
|
||||
if gate == "G03":
|
||||
fields.append("scoped_ack_cases")
|
||||
if field == "durable_root_publication_proof":
|
||||
fields.extend(("root_cas_observed", "root_readback_observed"))
|
||||
if field == "scoped_ack_request_identity":
|
||||
fields.append("whole_cycle_fallback_observed")
|
||||
if gate == "G04" and field == "root_floor_intent_crash_evidence":
|
||||
fields.extend(("durable_intent_cases", "persist_failure_blocks_acceptance"))
|
||||
if gate == "G07":
|
||||
fields.append({
|
||||
"mrf_responsibility_oracle": "mrf_responsibility_cases",
|
||||
"commit_boundary_crash_matrix": "commit_crash_cases",
|
||||
}[field])
|
||||
if gate == "G08":
|
||||
fields.append({
|
||||
"mrf_capacity_evidence": "capacity_cases",
|
||||
"disk_full_matrix": "disk_full_cases",
|
||||
"replica_loss_matrix": "replica_loss_cases",
|
||||
}[field])
|
||||
if gate == "G09":
|
||||
fields.append("mixed_version_cases")
|
||||
if field == "rollback_payload_evidence":
|
||||
fields.append("rollback_payload_replayed")
|
||||
if (gate, field) in SCANNER_HEAL_RELEASE_MRF_DURABLE_REPLAY_FIELDS:
|
||||
fields.extend(("replayed_records", "responsibility_anchor_retained", "successor_snapshot_published"))
|
||||
if gate == "P4" and field == "retained_responsibility_evidence":
|
||||
fields.extend((
|
||||
"duration_seconds",
|
||||
"retained_responsibility_cases",
|
||||
"retention_window_seconds",
|
||||
"idle_cleanup_observed",
|
||||
"verified_proof_discharge_observed",
|
||||
))
|
||||
if gate == "P4" and field == "mrf_cleanup_gc_soak_evidence":
|
||||
fields.extend((
|
||||
"duration_seconds",
|
||||
"cleanup_gc_cases",
|
||||
"verified_idle_gc_observed",
|
||||
"pending_responsibilities_after_gc",
|
||||
"stale_journals_after_gc",
|
||||
))
|
||||
return tuple(dict.fromkeys(fields))
|
||||
|
||||
|
||||
def validate_release_bundle_json_artifact_payload(path: Path, source_revision: str, gate: str, field: str,
|
||||
run_id: str, window_id: str,
|
||||
artifact_kind: str | None = None) -> None:
|
||||
payload = read_json(path)
|
||||
prefix = f"{gate}.{field}"
|
||||
for marker in ("fixture", "fixture_only", "dry_run", "synthetic"):
|
||||
require(payload.get(marker) is not True, f"{prefix} JSON artifact is {marker}")
|
||||
require(payload.get("evidence_type") == "measured", f"{prefix} JSON artifact must be measured")
|
||||
require(payload.get("source_revision") == source_revision, f"{prefix} JSON artifact source revision mismatch")
|
||||
require(payload.get("run_id") == run_id, f"{prefix} JSON artifact run_id mismatch")
|
||||
@@ -1591,66 +1530,6 @@ def validate_release_bundle_json_artifact_payload(path: Path, source_revision: s
|
||||
require(payload.get("field") == field, f"{prefix} JSON artifact field mismatch")
|
||||
if artifact_kind is not None:
|
||||
require(payload.get("artifact_kind") == artifact_kind, f"{prefix} JSON artifact kind mismatch")
|
||||
mrf_artifact_kind = SCANNER_HEAL_RELEASE_MRF_ARTIFACT_KINDS.get((gate, field))
|
||||
if mrf_artifact_kind is not None:
|
||||
require(payload.get("artifact_kind") == mrf_artifact_kind,
|
||||
f"{prefix} JSON artifact kind must be {mrf_artifact_kind}")
|
||||
mirror_fields = release_bundle_json_artifact_mirrored_fields(gate, field)
|
||||
for mirror_field in mirror_fields:
|
||||
require(mirror_field in payload, f"{prefix} JSON artifact missing {mirror_field}")
|
||||
if not mirror_fields:
|
||||
return
|
||||
validate_release_bundle_domain_evidence(gate, field, payload)
|
||||
if gate in ("G03", "G09", "R-L"):
|
||||
versions = payload.get("versions")
|
||||
require(isinstance(versions, list) and
|
||||
len(set(versions)) >= 2 and
|
||||
all(isinstance(version, str) and re.fullmatch(r"[0-9a-f]{40}", version) is not None
|
||||
for version in versions),
|
||||
f"{prefix} JSON artifact requires mixed-version evidence")
|
||||
require(source_revision in versions, f"{prefix} JSON artifact versions omit tested source revision")
|
||||
expected_role = SCANNER_HEAL_RELEASE_MIXED_VERSION_ROLES[(gate, field)]
|
||||
require(payload.get("mixed_version_role") == expected_role,
|
||||
f"{prefix} JSON artifact mixed-version role must be {expected_role}")
|
||||
if gate in ("G04", "G07", "R-E", "R-L"):
|
||||
crash_points = payload.get("crash_points")
|
||||
require(isinstance(crash_points, list) and crash_points,
|
||||
f"{prefix} JSON artifact requires crash-boundary evidence")
|
||||
if (gate, field) in SCANNER_HEAL_RELEASE_MRF_DURABLE_REPLAY_FIELDS:
|
||||
evidence_integer(payload.get("replayed_records"), f"{prefix} JSON artifact replayed_records", 1, 2**63 - 1)
|
||||
require(payload.get("responsibility_anchor_retained") is True,
|
||||
f"{prefix} JSON artifact requires retained MRF responsibility anchors")
|
||||
require(payload.get("successor_snapshot_published") is True,
|
||||
f"{prefix} JSON artifact requires successor snapshot publication evidence")
|
||||
if gate == "P4" and field == "mrf_cleanup_gc_soak_evidence":
|
||||
release_bundle_exact_strings(
|
||||
payload.get("cleanup_gc_cases"),
|
||||
SCANNER_HEAL_RELEASE_MRF_CLEANUP_GC_SOAK_CASES,
|
||||
f"{prefix} JSON artifact cleanup_gc_cases",
|
||||
)
|
||||
require(payload.get("verified_idle_gc_observed") is True,
|
||||
f"{prefix} JSON artifact requires verified idle GC evidence")
|
||||
require(payload.get("pending_responsibilities_after_gc") == 0,
|
||||
f"{prefix} JSON artifact requires zero pending responsibilities after GC")
|
||||
require(payload.get("stale_journals_after_gc") == 0,
|
||||
f"{prefix} JSON artifact requires zero stale journals after GC")
|
||||
if gate == "G07":
|
||||
case_field = {
|
||||
"mrf_responsibility_oracle": "mrf_responsibility_cases",
|
||||
"commit_boundary_crash_matrix": "commit_crash_cases",
|
||||
}[field]
|
||||
cases = evidence_string_list(payload.get(case_field), f"{prefix} JSON artifact {case_field}")
|
||||
missing_cases = sorted(set(SCANNER_HEAL_RELEASE_G07_REQUIRED_CASES[field]) - set(cases))
|
||||
require(not missing_cases, f"{prefix} JSON artifact missing cases: {', '.join(missing_cases)}")
|
||||
if gate == "G08":
|
||||
case_field = {
|
||||
"mrf_capacity_evidence": "capacity_cases",
|
||||
"disk_full_matrix": "disk_full_cases",
|
||||
"replica_loss_matrix": "replica_loss_cases",
|
||||
}[field]
|
||||
cases = evidence_string_list(payload.get(case_field), f"{prefix} JSON artifact {case_field}")
|
||||
missing_cases = sorted(set(SCANNER_HEAL_RELEASE_G08_REQUIRED_CASES[field]) - set(cases))
|
||||
require(not missing_cases, f"{prefix} JSON artifact missing cases: {', '.join(missing_cases)}")
|
||||
|
||||
|
||||
def release_bundle_bool_true(value: object, name: str) -> None:
|
||||
@@ -2406,6 +2285,7 @@ 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():
|
||||
@@ -2413,7 +2293,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",):
|
||||
for relative in (".github/workflows/ci.yml", ".github/workflows/ci-docs-only.yml"):
|
||||
source = sources[relative]
|
||||
mutations = {
|
||||
"different action": source.replace("./.github/actions/quick-checks", "./.github/actions/other"),
|
||||
@@ -2668,6 +2548,17 @@ class SelfTests(unittest.TestCase):
|
||||
evidence.update({"completed_heal_objects": 1, "duplicate_task_count": 0})
|
||||
if gate == "P3" and field == "recovery_window_measurement":
|
||||
evidence.update({"pressure_recovery_window_seconds": 5, "lock_hold_p95_ms": 0})
|
||||
write_json(artifact, {
|
||||
"schema": 1,
|
||||
"evidence_type": "measured",
|
||||
"source_revision": source_revision,
|
||||
"run_id": run_id,
|
||||
"measurement_window_id": window_id,
|
||||
"gate": gate,
|
||||
"field": field,
|
||||
"fixture": True,
|
||||
})
|
||||
evidence["sha256"] = digest(artifact)
|
||||
if gate in ("G03", "G09", "R-L"):
|
||||
evidence["versions"] = ["a" * 40, source_revision]
|
||||
evidence["mixed_version_role"] = SCANNER_HEAL_RELEASE_MIXED_VERSION_ROLES[(gate, field)]
|
||||
@@ -2768,8 +2659,8 @@ class SelfTests(unittest.TestCase):
|
||||
evidence["saved_bytes"] = 2048
|
||||
artifacts = {}
|
||||
for artifact_kind in RELEASE_PROFILE_ARTIFACTS:
|
||||
profile_artifact = artifact_dir / f"{gate}-{field}-{artifact_kind}.json"
|
||||
write_json(profile_artifact, {
|
||||
artifact = artifact_dir / f"{gate}-{field}-{artifact_kind}.json"
|
||||
write_json(artifact, {
|
||||
"schema": 1,
|
||||
"evidence_type": "measured",
|
||||
"source_revision": source_revision,
|
||||
@@ -2778,10 +2669,11 @@ class SelfTests(unittest.TestCase):
|
||||
"gate": gate,
|
||||
"field": field,
|
||||
"artifact_kind": artifact_kind,
|
||||
"fixture": True,
|
||||
})
|
||||
artifacts[artifact_kind] = {
|
||||
"artifact": profile_artifact.relative_to(bundle_dir).as_posix(),
|
||||
"sha256": digest(profile_artifact),
|
||||
"artifact": artifact.relative_to(bundle_dir).as_posix(),
|
||||
"sha256": digest(artifact),
|
||||
"artifact_format": "json",
|
||||
}
|
||||
evidence["profile_artifacts"] = artifacts
|
||||
@@ -2811,22 +2703,6 @@ class SelfTests(unittest.TestCase):
|
||||
evidence["fault_modes"] = ["process-restart", "process-crash-restart"]
|
||||
evidence["recovery_p95_ms"] = 500.0
|
||||
evidence["recovery_p99_ms"] = 1000.0
|
||||
artifact_payload = {
|
||||
"schema": 1,
|
||||
"evidence_type": "measured",
|
||||
"source_revision": source_revision,
|
||||
"run_id": run_id,
|
||||
"measurement_window_id": window_id,
|
||||
"gate": gate,
|
||||
"field": field,
|
||||
}
|
||||
mrf_artifact_kind = SCANNER_HEAL_RELEASE_MRF_ARTIFACT_KINDS.get((gate, field))
|
||||
if mrf_artifact_kind is not None:
|
||||
artifact_payload["artifact_kind"] = mrf_artifact_kind
|
||||
for mirror_field in release_bundle_json_artifact_mirrored_fields(gate, field):
|
||||
artifact_payload[mirror_field] = evidence[mirror_field]
|
||||
write_json(artifact, artifact_payload)
|
||||
evidence["sha256"] = digest(artifact)
|
||||
fields[field] = evidence
|
||||
gates[gate] = {
|
||||
"status": "pass",
|
||||
@@ -3187,30 +3063,6 @@ class SelfTests(unittest.TestCase):
|
||||
("P1", "profile_evidence", "rss-samples"),
|
||||
"JSON artifact run_id mismatch",
|
||||
),
|
||||
(
|
||||
"fixture-marker",
|
||||
lambda payload: payload.update({"fixture": True}),
|
||||
("G08", "disk_full_matrix"),
|
||||
"JSON artifact is fixture",
|
||||
),
|
||||
(
|
||||
"mrf-artifact-kind",
|
||||
lambda payload: payload.update({"artifact_kind": "generic-json"}),
|
||||
("G08", "disk_full_matrix"),
|
||||
"JSON artifact kind must be mrf-disk-full-enospc-matrix",
|
||||
),
|
||||
(
|
||||
"g08-case-mirror",
|
||||
lambda payload: payload["disk_full_cases"].remove("manifest-write-enospc"),
|
||||
("G08", "disk_full_matrix"),
|
||||
"JSON artifact missing cases",
|
||||
),
|
||||
(
|
||||
"p4-gc-mirror",
|
||||
lambda payload: payload.update({"pending_responsibilities_after_gc": 1}),
|
||||
("P4", "mrf_cleanup_gc_soak_evidence"),
|
||||
"JSON artifact requires zero pending responsibilities",
|
||||
),
|
||||
):
|
||||
with self.subTest(fault=fault), tempfile.TemporaryDirectory() as tmp:
|
||||
root, bundle = self.scanner_heal_release_bundle_fixture(Path(tmp))
|
||||
@@ -3452,10 +3304,7 @@ class SelfTests(unittest.TestCase):
|
||||
self.assertIn("segment_activation_preflight", requirements["G11"]["evidence_fields"])
|
||||
self.assertIn("distributed_segment_invalidation_evidence", requirements["G14"]["evidence_fields"])
|
||||
self.assertIn("cold_segment_reuse_measurement", requirements["P2"]["evidence_fields"])
|
||||
self.assertEqual(
|
||||
tuple(requirements["P4"]["evidence_fields"]),
|
||||
SCANNER_HEAL_RELEASE_REQUIRED_EVIDENCE_FIELDS["P4"],
|
||||
)
|
||||
self.assertIn("mrf_cleanup_gc_soak_evidence", requirements["P4"]["evidence_fields"])
|
||||
|
||||
def test_scanner_heal_required_evidence_fields_cannot_be_removed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
#!/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())
|
||||
@@ -26,7 +26,7 @@
|
||||
6|crates/ecstore/src/config/com.rs
|
||||
14|crates/ecstore/src/config/storageclass.rs
|
||||
178|crates/ecstore/src/core/pools.rs
|
||||
6|crates/ecstore/src/data_movement/mod.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
|
||||
5|crates/ecstore/src/disk/local.rs
|
||||
|
||||
@@ -101,16 +101,6 @@ case_names() {
|
||||
esac
|
||||
}
|
||||
|
||||
validate_test_selection() {
|
||||
case "$TEST_SELECTION" in
|
||||
all|mixed-version|rollback)
|
||||
;;
|
||||
*)
|
||||
die "unknown test selection: $TEST_SELECTION"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
artifact_for() {
|
||||
case "$1" in
|
||||
mixed-version)
|
||||
@@ -178,7 +168,7 @@ ensure_default_asset_platform() {
|
||||
|
||||
verify_sha256() {
|
||||
local archive="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1 && [[ "$(sha256sum --help 2>&1)" == *"--check"* ]]; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s %s\n' "$SOURCE_SHA256" "$archive" | sha256sum --check --strict
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s %s\n' "$SOURCE_SHA256" "$archive" | shasum -a 256 --check
|
||||
@@ -242,7 +232,7 @@ resolve_source_binary() {
|
||||
local archive="$SOURCE_DIR/$SOURCE_ASSET"
|
||||
curl --fail --location --retry 3 --output "$archive" \
|
||||
"https://github.com/$SOURCE_REPOSITORY/releases/download/$SOURCE_VERSION/$SOURCE_ASSET"
|
||||
verify_sha256 "$archive" >&2
|
||||
verify_sha256 "$archive"
|
||||
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||
chmod +x "$binary"
|
||||
test -x "$binary"
|
||||
@@ -432,18 +422,6 @@ run_self_test() {
|
||||
fi
|
||||
|
||||
mkdir -p "$tmp/run/mixed-version-upgrade" "$tmp/run/bucket-config-rollback"
|
||||
mkdir -p "$tmp/source"
|
||||
local archive checksum checksum_output
|
||||
archive="$tmp/source/$SOURCE_ASSET"
|
||||
printf 'not a real archive\n' >"$archive"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
checksum="$(shasum -a 256 "$archive" | awk '{print $1}')"
|
||||
else
|
||||
checksum="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
fi
|
||||
checksum_output="$(SOURCE_SHA256="$checksum" verify_sha256 "$archive")"
|
||||
[[ "$checksum_output" == *"OK"* ]]
|
||||
|
||||
local current previous
|
||||
current="$(git rev-parse HEAD)"
|
||||
previous="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
@@ -540,7 +518,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
validate_test_selection
|
||||
CASES=()
|
||||
while IFS= read -r case_name; do
|
||||
CASES+=("$case_name")
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PYTHON_BIN="${RUSTFS_PYTHON_BIN:-python3}"
|
||||
MIN_FREE_KIB="${RUSTFS_W13_MIN_FREE_KIB:-4194304}"
|
||||
SOAK_SECONDS="${RUSTFS_W13_MRF_SOAK_SECONDS:-7200}"
|
||||
ENOSPC_TMPFS_SIZE="${RUSTFS_W13_ENOSPC_TMPFS_SIZE:-16m}"
|
||||
|
||||
RUN_DIR=""
|
||||
ENOSPC_ROOT="${RUSTFS_SCANNER_HEAL_W13_ENOSPC_ROOT:-}"
|
||||
TEST_SELECTION="all"
|
||||
PLAN_ONLY=0
|
||||
ALLOW_DIRTY=0
|
||||
ALLOW_SHORT_SOAK=0
|
||||
SKIP_BUILD=0
|
||||
VERBOSE=0
|
||||
ENOSPC_TMPFS_MOUNTED=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/run_scanner_heal_w13_mrf_evidence.sh [OPTIONS]
|
||||
|
||||
Build the current checkout, run the W13 MRF durable replay evidence test, verify
|
||||
the raw JSON artifacts, and write bundle-ready G07/G08/P4 release descriptors.
|
||||
|
||||
Options:
|
||||
--run-dir DIR New evidence directory (default: target/scanner-heal-w13-evidence/TIMESTAMP)
|
||||
--out-dir DIR Alias for --run-dir
|
||||
--test NAME all, g07, g08, or p4 (default: all)
|
||||
--soak-seconds N P4 soak duration in seconds (default: 7200)
|
||||
--enospc-root DIR Pre-mounted small filesystem used for real G08 ENOSPC evidence
|
||||
--allow-short-soak Diagnostic only: allow P4 runs shorter than release duration
|
||||
--allow-dirty Allow tracked source changes while collecting evidence
|
||||
--skip-build Reuse an existing target/debug/rustfs binary
|
||||
--plan-only Print the resolved plan without building or running tests
|
||||
--dry-run Alias for --plan-only
|
||||
--self-test Run lightweight CLI and descriptor plumbing checks
|
||||
--verbose Stream command output instead of storing it under the run directory
|
||||
-h, --help Show this help
|
||||
|
||||
Required output files:
|
||||
g07-mrf-responsibility/G07-mrf_responsibility_oracle.json
|
||||
g07-mrf-responsibility/G07-commit_boundary_crash_matrix.json
|
||||
g08-mrf-capacity/G08-mrf_capacity_evidence.json
|
||||
g08-mrf-capacity/G08-disk_full_matrix.json
|
||||
g08-mrf-capacity/G08-replica_loss_matrix.json
|
||||
p4-mrf-soak/P4-mrf_scale_measurement.json
|
||||
p4-mrf-soak/P4-mrf_replay_cost_measurement.json
|
||||
p4-mrf-soak/P4-retained_responsibility_evidence.json
|
||||
p4-mrf-soak/P4-mrf_cleanup_gc_soak_evidence.json
|
||||
|
||||
Environment overrides:
|
||||
RUSTFS_SCANNER_HEAL_W13_OUTPUT_ROOT
|
||||
RUSTFS_SCANNER_HEAL_W13_ENOSPC_ROOT
|
||||
RUSTFS_W13_MIN_FREE_KIB
|
||||
RUSTFS_W13_MRF_SOAK_SECONDS
|
||||
RUSTFS_W13_ENOSPC_TMPFS_SIZE
|
||||
|
||||
Short-soak runs are for runner diagnostics only. They validate raw artifacts but
|
||||
do not validate the P4 release bundle gate.
|
||||
USAGE
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_value() {
|
||||
local option="$1"
|
||||
local count="$2"
|
||||
if [[ "$count" -lt 2 ]]; then
|
||||
die "missing value for $option"
|
||||
fi
|
||||
}
|
||||
|
||||
case_names() {
|
||||
case "$TEST_SELECTION" in
|
||||
all)
|
||||
printf '%s\n' g07 g08 p4
|
||||
;;
|
||||
g07|g08|p4)
|
||||
printf '%s\n' "$TEST_SELECTION"
|
||||
;;
|
||||
*)
|
||||
die "unknown test selection: $TEST_SELECTION"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_test_selection() {
|
||||
case "$TEST_SELECTION" in
|
||||
all|g07|g08|p4)
|
||||
;;
|
||||
*)
|
||||
die "unknown test selection: $TEST_SELECTION"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
selection_includes() {
|
||||
local needle="$1"
|
||||
[[ "$TEST_SELECTION" == "all" || "$TEST_SELECTION" == "$needle" ]]
|
||||
}
|
||||
|
||||
normalize_path() {
|
||||
local path="$1"
|
||||
if [[ "$path" == /* ]]; then
|
||||
echo "$path"
|
||||
else
|
||||
echo "$ROOT/$path"
|
||||
fi
|
||||
}
|
||||
|
||||
cargo_target_dir() {
|
||||
if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then
|
||||
normalize_path "$CARGO_TARGET_DIR"
|
||||
else
|
||||
echo "$ROOT/target"
|
||||
fi
|
||||
}
|
||||
|
||||
write_rustfs_features_stamp() {
|
||||
local target_dir
|
||||
target_dir="$(cargo_target_dir)"
|
||||
mkdir -p "$target_dir/debug"
|
||||
: >"$target_dir/debug/rustfs.features"
|
||||
}
|
||||
|
||||
artifact_dir_for() {
|
||||
case "$1" in
|
||||
g07)
|
||||
echo "g07-mrf-responsibility"
|
||||
;;
|
||||
g08)
|
||||
echo "g08-mrf-capacity"
|
||||
;;
|
||||
p4)
|
||||
echo "p4-mrf-soak"
|
||||
;;
|
||||
*)
|
||||
die "unknown W13 case: $1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_empty_case_dir() {
|
||||
local dir="$1"
|
||||
if [[ -d "$dir" ]] && find "$dir" -mindepth 1 -print -quit | grep -q .; then
|
||||
die "evidence case directory is not empty: $dir"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_min_free_space() {
|
||||
local path="$1"
|
||||
local available
|
||||
mkdir -p "$path"
|
||||
available="$(df -Pk "$path" | awk 'NR == 2 { print $4 }')"
|
||||
if [[ -z "$available" ]]; then
|
||||
die "could not determine free space for $path"
|
||||
fi
|
||||
if (( available < MIN_FREE_KIB )); then
|
||||
die "insufficient free space for W13 evidence run at $path: need ${MIN_FREE_KIB} KiB, found ${available} KiB"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_enospc_root() {
|
||||
if [[ "$ENOSPC_TMPFS_MOUNTED" == 1 && -n "$ENOSPC_ROOT" ]]; then
|
||||
umount "$ENOSPC_ROOT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_enospc_root() {
|
||||
if ! selection_includes g08; then
|
||||
return
|
||||
fi
|
||||
if [[ -n "$ENOSPC_ROOT" ]]; then
|
||||
ENOSPC_ROOT="$(normalize_path "$ENOSPC_ROOT")"
|
||||
mkdir -p "$ENOSPC_ROOT"
|
||||
return
|
||||
fi
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
die "G08 disk-full evidence requires --enospc-root on non-Linux hosts"
|
||||
fi
|
||||
if [[ "$(id -u)" != "0" ]]; then
|
||||
die "G08 disk-full evidence requires --enospc-root or root privileges to mount a tmpfs"
|
||||
fi
|
||||
if ! command -v mount >/dev/null 2>&1 || ! command -v umount >/dev/null 2>&1; then
|
||||
die "G08 disk-full evidence requires mount and umount, or a pre-mounted --enospc-root"
|
||||
fi
|
||||
ENOSPC_ROOT="$RUN_DIR/enospc-root"
|
||||
mkdir -p "$ENOSPC_ROOT"
|
||||
mount -t tmpfs -o "size=$ENOSPC_TMPFS_SIZE" rustfs-w13-enospc "$ENOSPC_ROOT"
|
||||
ENOSPC_TMPFS_MOUNTED=1
|
||||
}
|
||||
|
||||
run_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
local log="$RUN_DIR/logs/$label.log"
|
||||
mkdir -p "$(dirname "$log")"
|
||||
if [[ "$VERBOSE" == 1 ]]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
if ! "$@" >"$log" 2>&1; then
|
||||
echo "$label failed; log: $log" >&2
|
||||
tail -80 "$log" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
echo "PASS: $label"
|
||||
}
|
||||
|
||||
validate_artifacts() {
|
||||
local source_revision="$1"
|
||||
"$PYTHON_BIN" - "$RUN_DIR" "$source_revision" "$TEST_SELECTION" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
run_dir = pathlib.Path(sys.argv[1])
|
||||
source_revision = sys.argv[2]
|
||||
selection = sys.argv[3]
|
||||
|
||||
expected = {
|
||||
"g07": [
|
||||
("g07-mrf-responsibility/G07-mrf_responsibility_oracle.json", "G07", "mrf_responsibility_oracle", "mrf-durable-responsibility-oracle"),
|
||||
("g07-mrf-responsibility/G07-commit_boundary_crash_matrix.json", "G07", "commit_boundary_crash_matrix", "mrf-commit-boundary-crash-matrix"),
|
||||
],
|
||||
"g08": [
|
||||
("g08-mrf-capacity/G08-mrf_capacity_evidence.json", "G08", "mrf_capacity_evidence", "mrf-capacity-boundary"),
|
||||
("g08-mrf-capacity/G08-disk_full_matrix.json", "G08", "disk_full_matrix", "mrf-disk-full-enospc-matrix"),
|
||||
("g08-mrf-capacity/G08-replica_loss_matrix.json", "G08", "replica_loss_matrix", "mrf-replica-loss-matrix"),
|
||||
],
|
||||
"p4": [
|
||||
("p4-mrf-soak/P4-mrf_scale_measurement.json", "P4", "mrf_scale_measurement", "mrf-scale-measurement"),
|
||||
("p4-mrf-soak/P4-mrf_replay_cost_measurement.json", "P4", "mrf_replay_cost_measurement", "mrf-replay-cost-measurement"),
|
||||
("p4-mrf-soak/P4-retained_responsibility_evidence.json", "P4", "retained_responsibility_evidence", "mrf-retained-responsibility-soak"),
|
||||
("p4-mrf-soak/P4-mrf_cleanup_gc_soak_evidence.json", "P4", "mrf_cleanup_gc_soak_evidence", "mrf-cleanup-gc-soak"),
|
||||
],
|
||||
}
|
||||
if selection != "all":
|
||||
expected = {selection: expected[selection]}
|
||||
|
||||
for artifacts in expected.values():
|
||||
for relative, gate, field, artifact_kind in artifacts:
|
||||
path = run_dir / relative
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"missing W13 evidence artifact: {relative}")
|
||||
evidence = json.loads(path.read_text())
|
||||
if evidence.get("schema") != 1:
|
||||
raise SystemExit(f"{relative}: expected schema 1")
|
||||
if evidence.get("evidence_type") != "measured":
|
||||
raise SystemExit(f"{relative}: expected measured evidence")
|
||||
if evidence.get("artifact_kind") != artifact_kind:
|
||||
raise SystemExit(f"{relative}: unexpected artifact kind")
|
||||
if evidence.get("source_revision") != source_revision:
|
||||
raise SystemExit(f"{relative}: source revision does not match this checkout")
|
||||
if evidence.get("gate") != gate or evidence.get("field") != field:
|
||||
raise SystemExit(f"{relative}: unexpected gate or field")
|
||||
if gate == "G07":
|
||||
crash_points = evidence.get("crash_points")
|
||||
if not isinstance(crash_points, list) or not crash_points:
|
||||
raise SystemExit(f"{relative}: missing crash points")
|
||||
if gate == "P4":
|
||||
duration = evidence.get("duration_seconds")
|
||||
if not isinstance(duration, int) or duration <= 0:
|
||||
raise SystemExit(f"{relative}: invalid P4 duration")
|
||||
if gate == "G08" and field == "disk_full_matrix":
|
||||
if evidence.get("journal_write_enospc_observed") is not True:
|
||||
raise SystemExit(f"{relative}: journal ENOSPC was not observed")
|
||||
if evidence.get("committed_checkpoint_enospc_observed") is not True:
|
||||
raise SystemExit(f"{relative}: committed checkpoint ENOSPC was not observed")
|
||||
if evidence.get("cleanup_delete_on_full_filesystem_observed") is not True:
|
||||
raise SystemExit(f"{relative}: cleanup delete on a full filesystem was not observed")
|
||||
filler_bytes = evidence.get("enospc_filler_bytes")
|
||||
if not isinstance(filler_bytes, int) or filler_bytes <= 0:
|
||||
raise SystemExit(f"{relative}: ENOSPC filler byte count is invalid")
|
||||
|
||||
print("PASS: W13 raw MRF evidence artifacts verified")
|
||||
PY
|
||||
}
|
||||
|
||||
check_release_gate() {
|
||||
local descriptor="$1"
|
||||
local gate="$2"
|
||||
local output="$RUN_DIR/logs/check-${gate}.json"
|
||||
"$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$descriptor" "$gate" >"$output"
|
||||
"$PYTHON_BIN" - "$output" "$gate" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
gate = sys.argv[2]
|
||||
status = json.loads(path.read_text())
|
||||
if status.get("decision") != "verified" or status.get("verified_gate") != gate:
|
||||
print(path.read_text(), file=sys.stderr)
|
||||
raise SystemExit(f"{gate} release bundle gate was not verified")
|
||||
print(path.read_text().strip())
|
||||
PY
|
||||
}
|
||||
|
||||
write_release_descriptor() {
|
||||
local source_revision="$1"
|
||||
"$PYTHON_BIN" - "$ROOT" "$RUN_DIR" "$source_revision" "$TEST_SELECTION" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
run_dir = pathlib.Path(sys.argv[2])
|
||||
source_revision = sys.argv[3]
|
||||
selection = sys.argv[4]
|
||||
descriptor = run_dir / "release-bundle-w13.json"
|
||||
registry = json.loads((root / ".config/scanner-heal-required-tests.json").read_text())
|
||||
requirements = {item["gate"]: item for item in registry["release_requirements"]}
|
||||
artifacts = {
|
||||
"G07": {
|
||||
"mrf_responsibility_oracle": run_dir / "g07-mrf-responsibility" / "G07-mrf_responsibility_oracle.json",
|
||||
"commit_boundary_crash_matrix": run_dir / "g07-mrf-responsibility" / "G07-commit_boundary_crash_matrix.json",
|
||||
},
|
||||
"G08": {
|
||||
"mrf_capacity_evidence": run_dir / "g08-mrf-capacity" / "G08-mrf_capacity_evidence.json",
|
||||
"disk_full_matrix": run_dir / "g08-mrf-capacity" / "G08-disk_full_matrix.json",
|
||||
"replica_loss_matrix": run_dir / "g08-mrf-capacity" / "G08-replica_loss_matrix.json",
|
||||
},
|
||||
"P4": {
|
||||
"mrf_scale_measurement": run_dir / "p4-mrf-soak" / "P4-mrf_scale_measurement.json",
|
||||
"mrf_replay_cost_measurement": run_dir / "p4-mrf-soak" / "P4-mrf_replay_cost_measurement.json",
|
||||
"retained_responsibility_evidence": run_dir / "p4-mrf-soak" / "P4-retained_responsibility_evidence.json",
|
||||
"mrf_cleanup_gc_soak_evidence": run_dir / "p4-mrf-soak" / "P4-mrf_cleanup_gc_soak_evidence.json",
|
||||
},
|
||||
}
|
||||
if selection == "g07":
|
||||
artifacts = {"G07": artifacts["G07"]}
|
||||
elif selection == "g08":
|
||||
artifacts = {"G08": artifacts["G08"]}
|
||||
elif selection == "p4":
|
||||
artifacts = {"P4": artifacts["P4"]}
|
||||
|
||||
mirrors = {
|
||||
("G07", "mrf_responsibility_oracle"): ("crash_points", "mrf_responsibility_cases", "replayed_records", "responsibility_anchor_retained", "successor_snapshot_published"),
|
||||
("G07", "commit_boundary_crash_matrix"): ("crash_points", "commit_crash_cases", "replayed_records", "responsibility_anchor_retained", "successor_snapshot_published"),
|
||||
("G08", "mrf_capacity_evidence"): ("capacity_cases",),
|
||||
("G08", "disk_full_matrix"): ("disk_full_cases",),
|
||||
("G08", "replica_loss_matrix"): ("replica_loss_cases",),
|
||||
("P4", "mrf_replay_cost_measurement"): ("duration_seconds", "replayed_records", "responsibility_anchor_retained", "successor_snapshot_published"),
|
||||
("P4", "retained_responsibility_evidence"): ("duration_seconds", "retained_responsibility_cases", "retention_window_seconds", "idle_cleanup_observed", "verified_proof_discharge_observed", "replayed_records", "responsibility_anchor_retained", "successor_snapshot_published"),
|
||||
("P4", "mrf_cleanup_gc_soak_evidence"): ("duration_seconds", "cleanup_gc_cases", "verified_idle_gc_observed", "pending_responsibilities_after_gc", "stale_journals_after_gc", "replayed_records", "responsibility_anchor_retained", "successor_snapshot_published"),
|
||||
}
|
||||
|
||||
def digest(path: pathlib.Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
def relative_to_descriptor(path: pathlib.Path) -> str:
|
||||
return path.resolve(strict=True).relative_to(descriptor.parent.resolve()).as_posix()
|
||||
|
||||
gates: dict[str, object] = {}
|
||||
for gate, gate_artifacts in artifacts.items():
|
||||
fields: dict[str, object] = {}
|
||||
for field, artifact in gate_artifacts.items():
|
||||
payload = json.loads(artifact.read_text())
|
||||
if payload.get("source_revision") != source_revision:
|
||||
raise SystemExit(f"{gate}.{field}: source revision does not match this checkout")
|
||||
evidence = {
|
||||
"artifact": relative_to_descriptor(artifact),
|
||||
"sha256": digest(artifact),
|
||||
"evidence_type": "measured",
|
||||
"source_revision": source_revision,
|
||||
"run_id": payload["run_id"],
|
||||
"measurement_window_id": payload["measurement_window_id"],
|
||||
"started_at": payload["started_at"],
|
||||
"finished_at": payload["finished_at"],
|
||||
"command": payload["command"],
|
||||
"artifact_format": "json",
|
||||
"summary": payload["summary"],
|
||||
}
|
||||
for mirror in mirrors.get((gate, field), ()):
|
||||
evidence[mirror] = payload[mirror]
|
||||
if gate == "P4" and field == "mrf_scale_measurement":
|
||||
evidence["duration_seconds"] = payload["duration_seconds"]
|
||||
fields[field] = evidence
|
||||
gates[gate] = {
|
||||
"status": "pass",
|
||||
"lane": requirements[gate]["lane"],
|
||||
"evidence_type": "measured",
|
||||
"evidence_fields": fields,
|
||||
}
|
||||
|
||||
descriptor.write_text(json.dumps({
|
||||
"schema": 1,
|
||||
"evidence": "measured",
|
||||
"source_revision": source_revision,
|
||||
"gates": gates,
|
||||
}, indent=2, sort_keys=True) + "\n")
|
||||
print(descriptor)
|
||||
PY
|
||||
}
|
||||
|
||||
run_self_test() {
|
||||
local tmp plan
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/rustfs-w13-evidence-self-test.XXXXXX")"
|
||||
trap "rm -rf '$tmp'" EXIT
|
||||
|
||||
plan="$("$0" --plan-only --run-dir "$tmp/run" --test all)"
|
||||
[[ "$plan" == *"tests=g07 g08 p4"* ]]
|
||||
[[ "$plan" == *"soak_seconds=7200"* ]]
|
||||
[[ "$plan" == *"run_dir=$tmp/run"* ]]
|
||||
[[ "$(CARGO_TARGET_DIR=relative-target "$0" --plan-only --run-dir "$tmp/run" --test g07)" == *"target_dir=$ROOT/relative-target"* ]]
|
||||
|
||||
if "$0" --plan-only --test not-a-case >/dev/null 2>&1; then
|
||||
echo "self-test failed: invalid test selection was accepted" >&2
|
||||
return 1
|
||||
fi
|
||||
if "$0" --plan-only --test p4 --soak-seconds 10 >/dev/null 2>&1; then
|
||||
echo "self-test failed: short P4 soak was accepted as release evidence" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$tmp/nonempty/g07-mrf-responsibility"
|
||||
: >"$tmp/nonempty/g07-mrf-responsibility/existing.json"
|
||||
if "$0" --dry-run --run-dir "$tmp/nonempty" >/dev/null 2>&1; then
|
||||
echo "self-test failed: non-empty evidence directory was accepted" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--run-dir|--out-dir)
|
||||
require_value "$1" "$#"
|
||||
RUN_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--test)
|
||||
require_value "$1" "$#"
|
||||
TEST_SELECTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--soak-seconds)
|
||||
require_value "$1" "$#"
|
||||
SOAK_SECONDS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--enospc-root)
|
||||
require_value "$1" "$#"
|
||||
ENOSPC_ROOT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--allow-short-soak)
|
||||
ALLOW_SHORT_SOAK=1
|
||||
shift
|
||||
;;
|
||||
--allow-dirty)
|
||||
ALLOW_DIRTY=1
|
||||
shift
|
||||
;;
|
||||
--skip-build)
|
||||
SKIP_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--plan-only|--dry-run)
|
||||
PLAN_ONLY=1
|
||||
shift
|
||||
;;
|
||||
--self-test)
|
||||
run_self_test
|
||||
exit $?
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
validate_test_selection
|
||||
[[ "$SOAK_SECONDS" =~ ^[0-9]+$ ]] || die "--soak-seconds must be a non-negative integer"
|
||||
CASES=()
|
||||
while IFS= read -r case_name; do
|
||||
CASES+=("$case_name")
|
||||
done < <(case_names)
|
||||
if [[ " ${CASES[*]} " == *" p4 "* && "$SOAK_SECONDS" -lt 7200 && "$ALLOW_SHORT_SOAK" != 1 ]]; then
|
||||
die "P4 release evidence requires at least 7200 soak seconds; pass --allow-short-soak only for diagnostics"
|
||||
fi
|
||||
if [[ -z "$RUN_DIR" ]]; then
|
||||
OUTPUT_ROOT="$(normalize_path "${RUSTFS_SCANNER_HEAL_W13_OUTPUT_ROOT:-$ROOT/target/scanner-heal-w13-evidence}")"
|
||||
RUN_DIR="$OUTPUT_ROOT/$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
else
|
||||
RUN_DIR="$(normalize_path "$RUN_DIR")"
|
||||
fi
|
||||
|
||||
for case_name in "${CASES[@]}"; do
|
||||
check_empty_case_dir "$RUN_DIR/$(artifact_dir_for "$case_name")"
|
||||
done
|
||||
|
||||
if [[ "$PLAN_ONLY" == 1 ]]; then
|
||||
echo "run_dir=$RUN_DIR"
|
||||
echo "out_dir=$RUN_DIR"
|
||||
echo "tests=${CASES[*]}"
|
||||
echo "soak_seconds=$SOAK_SECONDS"
|
||||
echo "min_free_kib=$MIN_FREE_KIB"
|
||||
target_dir="$(cargo_target_dir)"
|
||||
echo "target_dir=$target_dir"
|
||||
echo "current_binary=$target_dir/debug/rustfs"
|
||||
echo "test_filter=rustfs-heal heal::mrf_queue::tests::w13_mrf_release_evidence_outputs_bundle_artifacts"
|
||||
echo "required_artifacts:"
|
||||
if [[ " ${CASES[*]} " == *" g07 "* ]]; then
|
||||
echo " $RUN_DIR/g07-mrf-responsibility/G07-mrf_responsibility_oracle.json"
|
||||
echo " $RUN_DIR/g07-mrf-responsibility/G07-commit_boundary_crash_matrix.json"
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" g08 "* ]]; then
|
||||
echo " $RUN_DIR/g08-mrf-capacity/G08-mrf_capacity_evidence.json"
|
||||
echo " $RUN_DIR/g08-mrf-capacity/G08-disk_full_matrix.json"
|
||||
echo " $RUN_DIR/g08-mrf-capacity/G08-replica_loss_matrix.json"
|
||||
if [[ -n "$ENOSPC_ROOT" ]]; then
|
||||
echo "enospc_root=$(normalize_path "$ENOSPC_ROOT")"
|
||||
elif [[ "$(uname -s)" == "Linux" ]]; then
|
||||
echo "enospc_root=$RUN_DIR/enospc-root"
|
||||
echo "enospc_tmpfs_size=$ENOSPC_TMPFS_SIZE"
|
||||
else
|
||||
echo "enospc_root=required-for-non-linux"
|
||||
fi
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" p4 "* ]]; then
|
||||
echo " $RUN_DIR/p4-mrf-soak/P4-mrf_scale_measurement.json"
|
||||
echo " $RUN_DIR/p4-mrf-soak/P4-mrf_replay_cost_measurement.json"
|
||||
echo " $RUN_DIR/p4-mrf-soak/P4-retained_responsibility_evidence.json"
|
||||
echo " $RUN_DIR/p4-mrf-soak/P4-mrf_cleanup_gc_soak_evidence.json"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
if [[ "$ALLOW_DIRTY" != 1 && -n "$(git status --porcelain --untracked-files=no)" ]]; then
|
||||
echo "commit tracked source changes before creating release evidence, or pass --allow-dirty for local diagnostics" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$RUN_DIR" ]]; then
|
||||
die "evidence run directory already exists: $RUN_DIR"
|
||||
fi
|
||||
mkdir -p "$RUN_DIR/logs"
|
||||
trap cleanup_enospc_root EXIT
|
||||
if [[ -n "${TMPDIR:-}" ]]; then
|
||||
mkdir -p "$TMPDIR"
|
||||
ensure_min_free_space "$TMPDIR"
|
||||
fi
|
||||
ensure_min_free_space "$RUN_DIR"
|
||||
prepare_enospc_root
|
||||
|
||||
SOURCE_REVISION="$(git rev-parse HEAD)"
|
||||
printf '%s\n' "$SOURCE_REVISION" >"$RUN_DIR/source-revision.txt"
|
||||
|
||||
if [[ "$SKIP_BUILD" != 1 ]]; then
|
||||
run_logged build-current cargo build --locked -p rustfs --bin rustfs
|
||||
write_rustfs_features_stamp
|
||||
fi
|
||||
|
||||
selection_csv="$(IFS=,; echo "${CASES[*]}")"
|
||||
run_logged w13-mrf-evidence env \
|
||||
RUSTFS_SCANNER_HEAL_W13_EVIDENCE_DIR="$RUN_DIR" \
|
||||
RUSTFS_SCANNER_HEAL_W13_SOURCE_REVISION="$SOURCE_REVISION" \
|
||||
RUSTFS_SCANNER_HEAL_W13_SELECTION="$selection_csv" \
|
||||
RUSTFS_SCANNER_HEAL_W13_SOAK_SECONDS="$SOAK_SECONDS" \
|
||||
RUSTFS_SCANNER_HEAL_W13_ALLOW_SHORT_SOAK="$ALLOW_SHORT_SOAK" \
|
||||
RUSTFS_SCANNER_HEAL_W13_RUN_ID="w13-mrf-release-evidence-run" \
|
||||
RUSTFS_SCANNER_HEAL_W13_WINDOW_ID="w13-mrf-release-evidence-window" \
|
||||
RUSTFS_SCANNER_HEAL_W13_ENOSPC_ROOT="$ENOSPC_ROOT" \
|
||||
RUSTFS_SCANNER_HEAL_W13_ENOSPC_FILL_LIMIT_BYTES="${RUSTFS_SCANNER_HEAL_W13_ENOSPC_FILL_LIMIT_BYTES:-67108864}" \
|
||||
cargo test --locked -p rustfs-heal --lib heal::mrf_queue::tests::w13_mrf_release_evidence_outputs_bundle_artifacts \
|
||||
-- --ignored --exact --nocapture
|
||||
|
||||
validate_artifacts "$SOURCE_REVISION"
|
||||
DESCRIPTOR="$(write_release_descriptor "$SOURCE_REVISION")"
|
||||
if [[ " ${CASES[*]} " == *" g07 "* ]]; then
|
||||
check_release_gate "$DESCRIPTOR" G07
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" g08 "* ]]; then
|
||||
check_release_gate "$DESCRIPTOR" G08
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" p4 "* ]]; then
|
||||
if [[ "$ALLOW_SHORT_SOAK" == 1 && "$SOAK_SECONDS" -lt 7200 ]]; then
|
||||
echo "SKIP: P4 release bundle gate validation for diagnostic short soak"
|
||||
else
|
||||
check_release_gate "$DESCRIPTOR" P4
|
||||
fi
|
||||
fi
|
||||
echo "Scanner/Heal W13 MRF release descriptors verified: $DESCRIPTOR"
|
||||
echo "Scanner/Heal W13 MRF evidence verified: $RUN_DIR"
|
||||
@@ -1,620 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PYTHON_BIN="${RUSTFS_PYTHON_BIN:-python3}"
|
||||
MIN_FREE_KIB="${RUSTFS_W16_MIN_FREE_KIB:-4194304}"
|
||||
|
||||
RUN_DIR=""
|
||||
TEST_SELECTION="all"
|
||||
PLAN_ONLY=0
|
||||
ALLOW_DIRTY=0
|
||||
SKIP_BUILD=0
|
||||
VERBOSE=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/run_scanner_heal_w16_recovery_evidence.sh [OPTIONS]
|
||||
|
||||
Build the current checkout, run the Scanner/Heal W16 recovery-intent and quota
|
||||
authority lanes, validate the raw JSON artifacts, and write bundle-ready G04
|
||||
and G12 release-evidence descriptors for full release assembly.
|
||||
|
||||
Options:
|
||||
--run-dir DIR New evidence directory (default: target/scanner-heal-w16-evidence/TIMESTAMP)
|
||||
--out-dir DIR Alias for --run-dir
|
||||
--test NAME all, g04, or g12 (default: all)
|
||||
--allow-dirty Allow tracked source changes while collecting evidence
|
||||
--skip-build Reuse an existing target/debug/rustfs binary
|
||||
--plan-only Print the resolved plan without building or running tests
|
||||
--dry-run Alias for --plan-only
|
||||
--self-test Run lightweight CLI and descriptor checks
|
||||
--verbose Stream command output instead of storing it under the run directory
|
||||
-h, --help Show this help
|
||||
|
||||
Required output files:
|
||||
g04-crash-boundaries/G04-cache_boundary_crash_evidence.json
|
||||
g04-crash-boundaries/G04-root_floor_intent_crash_evidence.json
|
||||
g12-quota-authority/G12-reset_quota_path_evidence.json
|
||||
g12-quota-authority/G12-settlement_quota_path_evidence.json
|
||||
|
||||
Environment overrides:
|
||||
RUSTFS_SCANNER_HEAL_W16_OUTPUT_ROOT
|
||||
RUSTFS_W16_MIN_FREE_KIB
|
||||
USAGE
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_value() {
|
||||
local option="$1"
|
||||
local count="$2"
|
||||
if [[ "$count" -lt 2 ]]; then
|
||||
die "missing value for $option"
|
||||
fi
|
||||
}
|
||||
|
||||
case_names() {
|
||||
case "$TEST_SELECTION" in
|
||||
all)
|
||||
printf '%s\n' g04 g12
|
||||
;;
|
||||
g04|g12)
|
||||
printf '%s\n' "$TEST_SELECTION"
|
||||
;;
|
||||
*)
|
||||
die "unknown test selection: $TEST_SELECTION"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_test_selection() {
|
||||
case "$TEST_SELECTION" in
|
||||
all|g04|g12)
|
||||
;;
|
||||
*)
|
||||
die "unknown test selection: $TEST_SELECTION"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
normalize_path() {
|
||||
local path="$1"
|
||||
if [[ "$path" == /* ]]; then
|
||||
echo "$path"
|
||||
else
|
||||
echo "$ROOT/$path"
|
||||
fi
|
||||
}
|
||||
|
||||
cargo_target_dir() {
|
||||
if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then
|
||||
normalize_path "$CARGO_TARGET_DIR"
|
||||
else
|
||||
echo "$ROOT/target"
|
||||
fi
|
||||
}
|
||||
|
||||
write_rustfs_features_stamp() {
|
||||
local target_dir
|
||||
target_dir="$(cargo_target_dir)"
|
||||
mkdir -p "$target_dir/debug"
|
||||
: >"$target_dir/debug/rustfs.features"
|
||||
}
|
||||
|
||||
artifact_dir_for() {
|
||||
case "$1" in
|
||||
g04)
|
||||
echo "g04-crash-boundaries"
|
||||
;;
|
||||
g12)
|
||||
echo "g12-quota-authority"
|
||||
;;
|
||||
*)
|
||||
die "unknown W16 case: $1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_empty_case_dir() {
|
||||
local dir="$1"
|
||||
if [[ -d "$dir" ]] && find "$dir" -mindepth 1 -print -quit | grep -q .; then
|
||||
die "evidence case directory is not empty: $dir"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_min_free_space() {
|
||||
local path="$1"
|
||||
local available
|
||||
mkdir -p "$path"
|
||||
available="$(df -Pk "$path" | awk 'NR == 2 { print $4 }')"
|
||||
if [[ -z "$available" ]]; then
|
||||
die "could not determine free space for $path"
|
||||
fi
|
||||
if (( available < MIN_FREE_KIB )); then
|
||||
die "insufficient free space for W16 evidence run at $path: need ${MIN_FREE_KIB} KiB, found ${available} KiB"
|
||||
fi
|
||||
}
|
||||
|
||||
run_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
local log="$RUN_DIR/logs/$label.log"
|
||||
mkdir -p "$(dirname "$log")"
|
||||
if [[ "$VERBOSE" == 1 ]]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
if ! "$@" >"$log" 2>&1; then
|
||||
echo "$label failed; log: $log" >&2
|
||||
tail -80 "$log" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
echo "PASS: $label"
|
||||
}
|
||||
|
||||
utc_now() {
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
}
|
||||
|
||||
validate_artifacts() {
|
||||
local source_revision="$1"
|
||||
"$PYTHON_BIN" - "$RUN_DIR" "$source_revision" "$TEST_SELECTION" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
run_dir = pathlib.Path(sys.argv[1])
|
||||
source_revision = sys.argv[2]
|
||||
selection = sys.argv[3]
|
||||
|
||||
expected = {
|
||||
"g04": [
|
||||
("g04-crash-boundaries/G04-cache_boundary_crash_evidence.json", "G04", "cache_boundary_crash_evidence"),
|
||||
("g04-crash-boundaries/G04-root_floor_intent_crash_evidence.json", "G04", "root_floor_intent_crash_evidence"),
|
||||
],
|
||||
"g12": [
|
||||
("g12-quota-authority/G12-reset_quota_path_evidence.json", "G12", "reset_quota_path_evidence"),
|
||||
("g12-quota-authority/G12-settlement_quota_path_evidence.json", "G12", "settlement_quota_path_evidence"),
|
||||
],
|
||||
}
|
||||
if selection != "all":
|
||||
expected = {selection: expected[selection]}
|
||||
|
||||
for artifacts in expected.values():
|
||||
for relative, gate, field in artifacts:
|
||||
path = run_dir / relative
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"missing W16 evidence artifact: {relative}")
|
||||
evidence = json.loads(path.read_text())
|
||||
if evidence.get("schema") != 1:
|
||||
raise SystemExit(f"{relative}: expected schema 1")
|
||||
if evidence.get("evidence_type") != "measured":
|
||||
raise SystemExit(f"{relative}: expected measured evidence")
|
||||
if evidence.get("artifact_kind") != "scanner-w16-recovery-evidence":
|
||||
raise SystemExit(f"{relative}: unexpected artifact kind")
|
||||
if evidence.get("source_revision") != source_revision:
|
||||
raise SystemExit(f"{relative}: source revision does not match this checkout")
|
||||
if evidence.get("gate") != gate or evidence.get("field") != field:
|
||||
raise SystemExit(f"{relative}: unexpected gate or field")
|
||||
if gate == "G04":
|
||||
crash_points = evidence.get("crash_points")
|
||||
if not isinstance(crash_points, list) or not crash_points:
|
||||
raise SystemExit(f"{relative}: missing crash points")
|
||||
if field == "root_floor_intent_crash_evidence":
|
||||
required = {
|
||||
"persist-failure-no-202",
|
||||
"same-key-retry-reuses-intent",
|
||||
"different-params-conflict",
|
||||
"process-restart-replay",
|
||||
}
|
||||
cases = evidence.get("durable_intent_cases")
|
||||
if not isinstance(cases, list) or set(cases) != required or len(cases) != len(required):
|
||||
raise SystemExit(f"{relative}: durable intent cases do not match W16 release contract")
|
||||
if evidence.get("persist_failure_blocks_acceptance") is not True:
|
||||
raise SystemExit(f"{relative}: persist failure did not block acceptance")
|
||||
|
||||
print("PASS: W16 raw evidence artifacts verified")
|
||||
PY
|
||||
}
|
||||
|
||||
write_artifacts() {
|
||||
local source_revision="$1"
|
||||
local started_at="$2"
|
||||
local finished_at="$3"
|
||||
"$PYTHON_BIN" - "$RUN_DIR" "$source_revision" "$started_at" "$finished_at" "$TEST_SELECTION" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
run_dir = pathlib.Path(sys.argv[1])
|
||||
source_revision = sys.argv[2]
|
||||
started_at = sys.argv[3]
|
||||
finished_at = sys.argv[4]
|
||||
selection = sys.argv[5]
|
||||
|
||||
def write(path: pathlib.Path, payload: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
def base(gate: str, field: str, run_id: str, window: str, command: list[str], summary: str) -> dict[str, object]:
|
||||
return {
|
||||
"schema": 1,
|
||||
"evidence_type": "measured",
|
||||
"artifact_kind": "scanner-w16-recovery-evidence",
|
||||
"source_revision": source_revision,
|
||||
"run_id": run_id,
|
||||
"measurement_window_id": window,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"gate": gate,
|
||||
"field": field,
|
||||
"command": command,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
if selection in ("all", "g04"):
|
||||
window = "w16-g04-crash-boundary-window"
|
||||
scanner_cmd = [
|
||||
"cargo", "test", "--locked", "-p", "rustfs-scanner", "--lib",
|
||||
"scanner_recovery_intent", "-j", "4",
|
||||
]
|
||||
crash_cmd = [
|
||||
"cargo", "test", "--locked", "-p", "rustfs-scanner", "--lib",
|
||||
"scanner::tests::recovery_control::disabled_cleanup_recovers_after_child_process_crash_boundaries",
|
||||
"--", "--exact", "--nocapture",
|
||||
]
|
||||
cache = base(
|
||||
"G04",
|
||||
"cache_boundary_crash_evidence",
|
||||
"w16-g04-cache-boundary-run",
|
||||
window,
|
||||
crash_cmd,
|
||||
"Measured disabled-startup crash-boundary recovery across scanner cache primary read, primary write, and usage-fence boundaries.",
|
||||
)
|
||||
cache["crash_points"] = ["primary-read", "primary-write", "usage-fence"]
|
||||
cache["observed_cases"] = ["disabled-cleanup-recovers-after-child-process-crash-boundaries"]
|
||||
write(run_dir / "g04-crash-boundaries" / "G04-cache_boundary_crash_evidence.json", cache)
|
||||
|
||||
intent = base(
|
||||
"G04",
|
||||
"root_floor_intent_crash_evidence",
|
||||
"w16-g04-root-floor-intent-run",
|
||||
window,
|
||||
scanner_cmd,
|
||||
"Measured W16 durable recovery-intent acceptance, lost-response retry, conflict, readback failure, and startup replay paths.",
|
||||
)
|
||||
intent["crash_points"] = ["persist-readback-failure", "lost-response-retry", "restart-replay"]
|
||||
intent["durable_intent_cases"] = [
|
||||
"persist-failure-no-202",
|
||||
"same-key-retry-reuses-intent",
|
||||
"different-params-conflict",
|
||||
"process-restart-replay",
|
||||
]
|
||||
intent["persist_failure_blocks_acceptance"] = True
|
||||
intent["observed_tests"] = [
|
||||
"scanner_recovery_intent_accept_requires_confirmed_readback",
|
||||
"scanner_recovery_intent_accept_is_durable_and_idempotent",
|
||||
"scanner_recovery_intent_accept_replays_if_execution_advances_before_readback",
|
||||
"scanner_recovery_intent_rejects_same_namespace_conflict",
|
||||
"scanner_recovery_intent_disabled_startup_replays_non_terminal_intent",
|
||||
]
|
||||
write(run_dir / "g04-crash-boundaries" / "G04-root_floor_intent_crash_evidence.json", intent)
|
||||
|
||||
if selection in ("all", "g12"):
|
||||
scanner_quota_cmd = [
|
||||
"cargo", "test", "--locked", "-p", "rustfs-scanner", "--lib",
|
||||
"quota_reset_preservation", "-j", "4",
|
||||
]
|
||||
distributed_quota_cmd = [
|
||||
"cargo", "test", "--locked", "-p", "e2e_test",
|
||||
"distributed::replication_quota_test::four_node_four_drive_hard_quota_rejects_over_limit_put",
|
||||
"--", "--exact", "--nocapture",
|
||||
]
|
||||
reset = base(
|
||||
"G12",
|
||||
"reset_quota_path_evidence",
|
||||
"w16-g12-reset-quota-path-run",
|
||||
"w16-g12-reset-quota-path-window",
|
||||
scanner_quota_cmd,
|
||||
"Measured scanner usage reset preserves quota reservation ledgers across storage-owner reconstruction and rejects unsupported quota protocols after restart.",
|
||||
)
|
||||
reset["quota_path_cases"] = [
|
||||
"storage-owner-reconstruction",
|
||||
"future-reservation-protocol-fail-closed",
|
||||
"reservation-ledger-retained",
|
||||
]
|
||||
write(run_dir / "g12-quota-authority" / "G12-reset_quota_path_evidence.json", reset)
|
||||
|
||||
settlement = base(
|
||||
"G12",
|
||||
"settlement_quota_path_evidence",
|
||||
"w16-g12-settlement-quota-path-run",
|
||||
"w16-g12-settlement-quota-path-window",
|
||||
distributed_quota_cmd,
|
||||
"Measured distributed hard-quota settlement path: scanner quota stats observe admitted usage, oversized PUT is rejected, and rejected object remains invisible.",
|
||||
)
|
||||
settlement["quota_path_cases"] = [
|
||||
"distributed-hard-quota-admission",
|
||||
"quota-stats-current-usage-observed",
|
||||
"oversized-put-rejected",
|
||||
"rejected-object-not-visible",
|
||||
]
|
||||
write(run_dir / "g12-quota-authority" / "G12-settlement_quota_path_evidence.json", settlement)
|
||||
PY
|
||||
}
|
||||
|
||||
digest_file() {
|
||||
"$PYTHON_BIN" - "$1" <<'PY'
|
||||
import hashlib
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
hasher.update(chunk)
|
||||
print(hasher.hexdigest())
|
||||
PY
|
||||
}
|
||||
|
||||
write_release_descriptor() {
|
||||
local source_revision="$1"
|
||||
"$PYTHON_BIN" - "$ROOT" "$RUN_DIR" "$source_revision" "$TEST_SELECTION" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
run_dir = pathlib.Path(sys.argv[2])
|
||||
source_revision = sys.argv[3]
|
||||
selection = sys.argv[4]
|
||||
descriptor = run_dir / "release-bundle-w16.json"
|
||||
registry = json.loads((root / ".config/scanner-heal-required-tests.json").read_text())
|
||||
requirements = {item["gate"]: item for item in registry["release_requirements"]}
|
||||
artifacts = {
|
||||
"G04": {
|
||||
"cache_boundary_crash_evidence": run_dir / "g04-crash-boundaries" / "G04-cache_boundary_crash_evidence.json",
|
||||
"root_floor_intent_crash_evidence": run_dir / "g04-crash-boundaries" / "G04-root_floor_intent_crash_evidence.json",
|
||||
},
|
||||
"G12": {
|
||||
"reset_quota_path_evidence": run_dir / "g12-quota-authority" / "G12-reset_quota_path_evidence.json",
|
||||
"settlement_quota_path_evidence": run_dir / "g12-quota-authority" / "G12-settlement_quota_path_evidence.json",
|
||||
},
|
||||
}
|
||||
if selection == "g04":
|
||||
artifacts = {"G04": artifacts["G04"]}
|
||||
elif selection == "g12":
|
||||
artifacts = {"G12": artifacts["G12"]}
|
||||
|
||||
def digest(path: pathlib.Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
def relative_to_descriptor(path: pathlib.Path) -> str:
|
||||
return path.resolve(strict=True).relative_to(descriptor.parent.resolve()).as_posix()
|
||||
|
||||
gates: dict[str, object] = {}
|
||||
for gate, gate_artifacts in artifacts.items():
|
||||
fields: dict[str, object] = {}
|
||||
for field, artifact in gate_artifacts.items():
|
||||
payload = json.loads(artifact.read_text())
|
||||
if payload.get("source_revision") != source_revision:
|
||||
raise SystemExit(f"{gate}.{field}: source revision does not match this checkout")
|
||||
evidence = {
|
||||
"artifact": relative_to_descriptor(artifact),
|
||||
"sha256": digest(artifact),
|
||||
"evidence_type": "measured",
|
||||
"source_revision": source_revision,
|
||||
"run_id": payload["run_id"],
|
||||
"measurement_window_id": payload["measurement_window_id"],
|
||||
"started_at": payload["started_at"],
|
||||
"finished_at": payload["finished_at"],
|
||||
"command": payload["command"],
|
||||
"artifact_format": "json",
|
||||
"summary": payload["summary"],
|
||||
}
|
||||
for mirror in ("crash_points", "durable_intent_cases", "persist_failure_blocks_acceptance"):
|
||||
if mirror in payload:
|
||||
evidence[mirror] = payload[mirror]
|
||||
fields[field] = evidence
|
||||
gates[gate] = {
|
||||
"status": "pass",
|
||||
"lane": requirements[gate]["lane"],
|
||||
"evidence_type": "measured",
|
||||
"evidence_fields": fields,
|
||||
}
|
||||
|
||||
descriptor.write_text(json.dumps({
|
||||
"schema": 1,
|
||||
"evidence": "measured",
|
||||
"source_revision": source_revision,
|
||||
"gates": gates,
|
||||
}, indent=2, sort_keys=True) + "\n")
|
||||
print(descriptor)
|
||||
PY
|
||||
}
|
||||
|
||||
run_self_test() {
|
||||
local tmp current descriptor
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/rustfs-w16-evidence-self-test.XXXXXX")"
|
||||
trap "rm -rf '$tmp'" EXIT
|
||||
|
||||
local plan
|
||||
plan="$("$0" --plan-only --run-dir "$tmp/run" --test all)"
|
||||
[[ "$plan" == *"tests=g04 g12"* ]]
|
||||
[[ "$plan" == *"run_dir=$tmp/run"* ]]
|
||||
[[ "$(CARGO_TARGET_DIR=relative-target "$0" --plan-only --run-dir "$tmp/run")" == *"target_dir=$ROOT/relative-target"* ]]
|
||||
|
||||
if "$0" --plan-only --test not-a-case >/dev/null 2>&1; then
|
||||
echo "self-test failed: invalid test selection was accepted" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$tmp/nonempty/g04-crash-boundaries"
|
||||
: >"$tmp/nonempty/g04-crash-boundaries/existing.json"
|
||||
if "$0" --dry-run --run-dir "$tmp/nonempty" >/dev/null 2>&1; then
|
||||
echo "self-test failed: non-empty evidence directory was accepted" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
current="$(git rev-parse HEAD)"
|
||||
RUN_DIR="$tmp/run" TEST_SELECTION="all"
|
||||
mkdir -p "$RUN_DIR/logs"
|
||||
write_artifacts "$current" "$(utc_now)" "$(utc_now)"
|
||||
validate_artifacts "$current" >/dev/null
|
||||
descriptor="$(write_release_descriptor "$current")"
|
||||
"$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$descriptor" G04 >/dev/null
|
||||
"$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$descriptor" G12 >/dev/null
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--run-dir|--out-dir)
|
||||
require_value "$1" "$#"
|
||||
RUN_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--test)
|
||||
require_value "$1" "$#"
|
||||
TEST_SELECTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--allow-dirty)
|
||||
ALLOW_DIRTY=1
|
||||
shift
|
||||
;;
|
||||
--skip-build)
|
||||
SKIP_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--plan-only|--dry-run)
|
||||
PLAN_ONLY=1
|
||||
shift
|
||||
;;
|
||||
--self-test)
|
||||
run_self_test
|
||||
exit $?
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
validate_test_selection
|
||||
CASES=()
|
||||
while IFS= read -r case_name; do
|
||||
CASES+=("$case_name")
|
||||
done < <(case_names)
|
||||
if [[ -z "$RUN_DIR" ]]; then
|
||||
OUTPUT_ROOT="$(normalize_path "${RUSTFS_SCANNER_HEAL_W16_OUTPUT_ROOT:-$ROOT/target/scanner-heal-w16-evidence}")"
|
||||
RUN_DIR="$OUTPUT_ROOT/$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
else
|
||||
RUN_DIR="$(normalize_path "$RUN_DIR")"
|
||||
fi
|
||||
|
||||
for case_name in "${CASES[@]}"; do
|
||||
check_empty_case_dir "$RUN_DIR/$(artifact_dir_for "$case_name")"
|
||||
done
|
||||
|
||||
if [[ "$PLAN_ONLY" == 1 ]]; then
|
||||
echo "run_dir=$RUN_DIR"
|
||||
echo "out_dir=$RUN_DIR"
|
||||
echo "tests=${CASES[*]}"
|
||||
echo "min_free_kib=$MIN_FREE_KIB"
|
||||
target_dir="$(cargo_target_dir)"
|
||||
echo "target_dir=$target_dir"
|
||||
echo "current_binary=$target_dir/debug/rustfs"
|
||||
echo "test_filters:"
|
||||
if [[ " ${CASES[*]} " == *" g04 "* ]]; then
|
||||
echo " g04-crash-boundaries: rustfs-scanner scanner_recovery_intent"
|
||||
echo " g04-crash-boundaries: rustfs-scanner scanner::tests::recovery_control::disabled_cleanup_recovers_after_child_process_crash_boundaries"
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" g12 "* ]]; then
|
||||
echo " g12-quota-authority: rustfs-scanner quota_reset_preservation"
|
||||
echo " g12-quota-authority: e2e_test distributed::replication_quota_test::four_node_four_drive_hard_quota_rejects_over_limit_put"
|
||||
fi
|
||||
echo "required_artifacts:"
|
||||
if [[ " ${CASES[*]} " == *" g04 "* ]]; then
|
||||
echo " $RUN_DIR/g04-crash-boundaries/G04-cache_boundary_crash_evidence.json"
|
||||
echo " $RUN_DIR/g04-crash-boundaries/G04-root_floor_intent_crash_evidence.json"
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" g12 "* ]]; then
|
||||
echo " $RUN_DIR/g12-quota-authority/G12-reset_quota_path_evidence.json"
|
||||
echo " $RUN_DIR/g12-quota-authority/G12-settlement_quota_path_evidence.json"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
if [[ "$ALLOW_DIRTY" != 1 && -n "$(git status --porcelain --untracked-files=no)" ]]; then
|
||||
echo "commit tracked source changes before creating release evidence, or pass --allow-dirty for local diagnostics" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$RUN_DIR" ]]; then
|
||||
die "evidence run directory already exists: $RUN_DIR"
|
||||
fi
|
||||
mkdir -p "$RUN_DIR/logs"
|
||||
if [[ -n "${TMPDIR:-}" ]]; then
|
||||
mkdir -p "$TMPDIR"
|
||||
ensure_min_free_space "$TMPDIR"
|
||||
fi
|
||||
ensure_min_free_space "$RUN_DIR"
|
||||
|
||||
RUN_STARTED_AT="$(utc_now)"
|
||||
SOURCE_REVISION="$(git rev-parse HEAD)"
|
||||
printf '%s\n' "$SOURCE_REVISION" >"$RUN_DIR/source-revision.txt"
|
||||
|
||||
if [[ "$SKIP_BUILD" != 1 ]]; then
|
||||
run_logged build-current cargo build --locked -p rustfs --bin rustfs
|
||||
write_rustfs_features_stamp
|
||||
fi
|
||||
|
||||
if [[ " ${CASES[*]} " == *" g04 "* ]]; then
|
||||
run_logged g04-recovery-intents cargo test --locked -p rustfs-scanner --lib scanner_recovery_intent -j 4
|
||||
run_logged g04-crash-boundaries cargo test --locked -p rustfs-scanner --lib \
|
||||
scanner::tests::recovery_control::disabled_cleanup_recovers_after_child_process_crash_boundaries \
|
||||
-- --exact --nocapture
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" g12 "* ]]; then
|
||||
run_logged g12-reset-quota-path cargo test --locked -p rustfs-scanner --lib quota_reset_preservation -j 4
|
||||
run_logged g12-settlement-quota-path env \
|
||||
NO_PROXY="${NO_PROXY:-127.0.0.1,localhost}" \
|
||||
HTTP_PROXY= \
|
||||
HTTPS_PROXY= \
|
||||
cargo test --locked -p e2e_test \
|
||||
distributed::replication_quota_test::four_node_four_drive_hard_quota_rejects_over_limit_put \
|
||||
-- --exact --nocapture
|
||||
fi
|
||||
|
||||
RUN_FINISHED_AT="$(utc_now)"
|
||||
write_artifacts "$SOURCE_REVISION" "$RUN_STARTED_AT" "$RUN_FINISHED_AT"
|
||||
validate_artifacts "$SOURCE_REVISION"
|
||||
DESCRIPTOR="$(write_release_descriptor "$SOURCE_REVISION")"
|
||||
if [[ " ${CASES[*]} " == *" g04 "* ]]; then
|
||||
"$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$DESCRIPTOR" G04
|
||||
fi
|
||||
if [[ " ${CASES[*]} " == *" g12 "* ]]; then
|
||||
"$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release-bundle-gate "$DESCRIPTOR" G12
|
||||
fi
|
||||
echo "Scanner/Heal W16 release descriptors verified: $DESCRIPTOR"
|
||||
echo "Scanner/Heal W16 evidence verified: $RUN_DIR"
|
||||
@@ -239,8 +239,6 @@ DEPLOY_MODE="${DEPLOY_MODE:-build}"
|
||||
RUSTFS_BINARY="${RUSTFS_BINARY:-}"
|
||||
NO_CACHE="${NO_CACHE:-false}"
|
||||
S3TESTS_LOCAL_SSE_MASTER_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
|
||||
S3TESTS_ENABLE_LOCAL_KMS="${S3TESTS_ENABLE_LOCAL_KMS:-true}"
|
||||
S3_KMS_KEY_ID="${S3_KMS_KEY_ID:-rustfs-s3tests-default-key}"
|
||||
|
||||
# Additional directories (SCRIPT_DIR and PROJECT_ROOT defined earlier)
|
||||
ARTIFACTS_DIR="${PROJECT_ROOT}/artifacts/s3tests-${TEST_MODE}"
|
||||
@@ -254,9 +252,6 @@ else
|
||||
fi
|
||||
DATA_DIR="${DATA_BASE}/test-data/${CONTAINER_NAME}"
|
||||
RUSTFS_PID=""
|
||||
RUSTFS_KMS_ARGS=()
|
||||
S3TESTS_KMS_HOST_KEY_DIR="${S3TESTS_KMS_KEY_DIR:-${DATA_BASE}/kms-keys/${CONTAINER_NAME}}"
|
||||
S3TESTS_KMS_RUNTIME_KEY_DIR="${S3TESTS_KMS_HOST_KEY_DIR}"
|
||||
|
||||
if [ "${DEPLOY_MODE}" != "existing" ] && [ -z "${RUSTFS_SSE_S3_MASTER_KEY:-}" ]; then
|
||||
export RUSTFS_SSE_S3_MASTER_KEY="${S3TESTS_LOCAL_SSE_MASTER_KEY_DEFAULT}"
|
||||
@@ -287,9 +282,6 @@ Environment Variables:
|
||||
S3_ALT_ACCESS_KEY - Alt user access key (default: rustfsalt)
|
||||
S3_ALT_SECRET_KEY - Alt user secret key (default: rustfsalt)
|
||||
RUSTFS_SSE_S3_MASTER_KEY - Optional base64 32-byte key for local managed SSE fallback
|
||||
S3TESTS_ENABLE_LOCAL_KMS - Enable local KMS for SSE-KMS cases (default: true)
|
||||
S3_KMS_KEY_ID - s3-tests KMS key id (default: rustfs-s3tests-default-key)
|
||||
S3TESTS_KMS_KEY_DIR - Host key directory for local KMS (default: DATA_ROOT/kms-keys)
|
||||
RUSTFS_SCANNER_ENABLED - Enable background scanner for harness service (default: false)
|
||||
MAXFAIL - Stop after N failures, 0 = never stop (default: 1)
|
||||
XDIST - Enable parallel execution with N workers (default: 0)
|
||||
@@ -353,52 +345,6 @@ cleanup() {
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
prepare_s3tests_local_kms() {
|
||||
if [ "${S3TESTS_ENABLE_LOCAL_KMS}" != "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "${DEPLOY_MODE}" = "existing" ]; then
|
||||
log_warn "Skipping local KMS setup for DEPLOY_MODE=existing; set S3_KMS_KEY_ID only when the target service is KMS-enabled"
|
||||
return 0
|
||||
fi
|
||||
if [ "${DEPLOY_MODE}" = "docker" ] && [ -z "${S3TESTS_KMS_KEY_DIR:-}" ]; then
|
||||
S3TESTS_KMS_HOST_KEY_DIR="/tmp/${CONTAINER_NAME}/kms-keys"
|
||||
S3TESTS_KMS_RUNTIME_KEY_DIR="/data/kms-keys"
|
||||
fi
|
||||
|
||||
mkdir -p "${S3TESTS_KMS_HOST_KEY_DIR}"
|
||||
cat > "${S3TESTS_KMS_HOST_KEY_DIR}/${S3_KMS_KEY_ID}.key" <<EOF
|
||||
{
|
||||
"key_id": "${S3_KMS_KEY_ID}",
|
||||
"version": 1,
|
||||
"algorithm": "AES_256",
|
||||
"usage": "EncryptDecrypt",
|
||||
"status": "Active",
|
||||
"metadata": {},
|
||||
"created_at": "2026-01-01T00:00:00+00:00[UTC]",
|
||||
"rotated_at": null,
|
||||
"created_by": "s3-tests",
|
||||
"encrypted_key_material": "${S3TESTS_LOCAL_SSE_MASTER_KEY_DEFAULT}",
|
||||
"nonce": [],
|
||||
"at_rest_protection": "plaintext-dev-only"
|
||||
}
|
||||
EOF
|
||||
chmod 700 "${S3TESTS_KMS_HOST_KEY_DIR}"
|
||||
chmod 600 "${S3TESTS_KMS_HOST_KEY_DIR}/${S3_KMS_KEY_ID}.key"
|
||||
export RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS="true"
|
||||
export RUSTFS_KMS_ENABLE="true"
|
||||
export RUSTFS_KMS_BACKEND="local"
|
||||
export RUSTFS_KMS_KEY_DIR="${S3TESTS_KMS_RUNTIME_KEY_DIR}"
|
||||
export RUSTFS_KMS_DEFAULT_KEY_ID="${S3_KMS_KEY_ID}"
|
||||
RUSTFS_KMS_ARGS=(
|
||||
--kms-enable
|
||||
--kms-backend local
|
||||
--kms-key-dir "${S3TESTS_KMS_RUNTIME_KEY_DIR}"
|
||||
--kms-default-key-id "${S3_KMS_KEY_ID}"
|
||||
)
|
||||
log_info "Using local KMS key '${S3_KMS_KEY_ID}' for the s3-tests harness"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -429,8 +375,6 @@ if [ "${DEPLOY_MODE}" != "existing" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
prepare_s3tests_local_kms
|
||||
|
||||
# Start RustFS based on deployment mode
|
||||
if [ "${DEPLOY_MODE}" = "existing" ]; then
|
||||
log_info "Using existing RustFS service at ${S3_HOST}:${S3_PORT}"
|
||||
@@ -464,7 +408,6 @@ elif [ "${DEPLOY_MODE}" = "binary" ]; then
|
||||
--address "${S3_HOST}:${S3_PORT}" \
|
||||
--access-key "${S3_ACCESS_KEY}" \
|
||||
--secret-key "${S3_SECRET_KEY}" \
|
||||
"${RUSTFS_KMS_ARGS[@]}" \
|
||||
"${DATA_DIR}/rustfs0" "${DATA_DIR}/rustfs1" "${DATA_DIR}/rustfs2" "${DATA_DIR}/rustfs3" \
|
||||
> "${ARTIFACTS_DIR}/rustfs-${TEST_MODE}/rustfs.log" 2>&1 &
|
||||
|
||||
@@ -529,7 +472,6 @@ elif [ "${DEPLOY_MODE}" = "build" ]; then
|
||||
--address "${S3_HOST}:${S3_PORT}" \
|
||||
--access-key "${S3_ACCESS_KEY}" \
|
||||
--secret-key "${S3_SECRET_KEY}" \
|
||||
"${RUSTFS_KMS_ARGS[@]}" \
|
||||
"${DATA_DIR}/rustfs0" "${DATA_DIR}/rustfs1" "${DATA_DIR}/rustfs2" "${DATA_DIR}/rustfs3" \
|
||||
> "${ARTIFACTS_DIR}/rustfs-${TEST_MODE}/rustfs.log" 2>&1 &
|
||||
|
||||
@@ -564,11 +506,6 @@ elif [ "${DEPLOY_MODE}" = "docker" ]; then
|
||||
-e RUSTFS_ACCESS_KEY="${S3_ACCESS_KEY}" \
|
||||
-e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \
|
||||
-e RUSTFS_SSE_S3_MASTER_KEY="${RUSTFS_SSE_S3_MASTER_KEY}" \
|
||||
-e RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS="${RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS:-false}" \
|
||||
-e RUSTFS_KMS_ENABLE="${RUSTFS_KMS_ENABLE:-false}" \
|
||||
-e RUSTFS_KMS_BACKEND="${RUSTFS_KMS_BACKEND:-local}" \
|
||||
-e RUSTFS_KMS_KEY_DIR="${RUSTFS_KMS_KEY_DIR:-}" \
|
||||
-e RUSTFS_KMS_DEFAULT_KEY_ID="${RUSTFS_KMS_DEFAULT_KEY_ID:-}" \
|
||||
-e RUSTFS_SCANNER_ENABLED="${RUSTFS_SCANNER_ENABLED}" \
|
||||
-e RUSTFS_SCANNER_START_DELAY_SECS="${RUSTFS_SCANNER_START_DELAY_SECS}" \
|
||||
-e RUSTFS_SCANNER_CYCLE="${RUSTFS_SCANNER_CYCLE}" \
|
||||
@@ -824,11 +761,6 @@ envsubst < "${TEMPLATE_PATH}" > "${CONF_OUTPUT_PATH}" || {
|
||||
log_error "Failed to generate s3tests config"
|
||||
exit 1
|
||||
}
|
||||
if [ -n "${S3_KMS_KEY_ID:-}" ]; then
|
||||
tmp_conf="${CONF_OUTPUT_PATH}.tmp"
|
||||
sed "s|^#kms_keyid = .*$|kms_keyid = ${S3_KMS_KEY_ID}|" "${CONF_OUTPUT_PATH}" > "${tmp_conf}"
|
||||
mv "${tmp_conf}" "${CONF_OUTPUT_PATH}"
|
||||
fi
|
||||
|
||||
# Step 7: Provision s3-tests alt user
|
||||
# Note: Main user (rustfsadmin) is a system user and doesn't need to be created via API
|
||||
|
||||
@@ -25,7 +25,7 @@ fi
|
||||
rg -q -- "--sha256 must be a 64-character lowercase hex digest" "$TMP_DIR/bad-sha.err"
|
||||
|
||||
VALID_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
env -u CARGO_TARGET_DIR bash "$RUNNER" \
|
||||
bash "$RUNNER" \
|
||||
--dry-run \
|
||||
--out-dir "$TMP_DIR/evidence" \
|
||||
--source-dir "$TMP_DIR/source" \
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
RUNNER="${PROJECT_ROOT}/scripts/run_scanner_heal_w13_mrf_evidence.sh"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
bash -n "$RUNNER"
|
||||
|
||||
bash "$RUNNER" --help >"$TMP_DIR/help.out"
|
||||
rg -q "RUSTFS_SCANNER_HEAL_W13_OUTPUT_ROOT" "$TMP_DIR/help.out"
|
||||
rg -q "RUSTFS_SCANNER_HEAL_W13_ENOSPC_ROOT" "$TMP_DIR/help.out"
|
||||
rg -q "G07-mrf_responsibility_oracle.json" "$TMP_DIR/help.out"
|
||||
rg -q "G08-disk_full_matrix.json" "$TMP_DIR/help.out"
|
||||
rg -q "P4-mrf_cleanup_gc_soak_evidence.json" "$TMP_DIR/help.out"
|
||||
|
||||
env -u CARGO_TARGET_DIR bash "$RUNNER" \
|
||||
--dry-run \
|
||||
--out-dir "$TMP_DIR/evidence" >"$TMP_DIR/dry-run.out"
|
||||
|
||||
rg -q "tests=g07 g08 p4" "$TMP_DIR/dry-run.out"
|
||||
rg -q "test_filter=rustfs-heal heal::mrf_queue::tests::w13_mrf_release_evidence_outputs_bundle_artifacts" "$TMP_DIR/dry-run.out"
|
||||
rg -q "target_dir=$PROJECT_ROOT/target" "$TMP_DIR/dry-run.out"
|
||||
|
||||
RUSTFS_SCANNER_HEAL_W13_OUTPUT_ROOT="$TMP_DIR/root-out" \
|
||||
bash "$RUNNER" --dry-run --test g07 >"$TMP_DIR/dry-run-output-root.out"
|
||||
rg -q "run_dir=$TMP_DIR/root-out/" "$TMP_DIR/dry-run-output-root.out"
|
||||
|
||||
CARGO_TARGET_DIR="$TMP_DIR/shared-target" bash "$RUNNER" \
|
||||
--dry-run \
|
||||
--out-dir "$TMP_DIR/evidence-with-target" \
|
||||
--test g08 \
|
||||
--enospc-root "$TMP_DIR/enospc" >"$TMP_DIR/dry-run-target.out"
|
||||
rg -q "target_dir=$TMP_DIR/shared-target" "$TMP_DIR/dry-run-target.out"
|
||||
rg -q "current_binary=$TMP_DIR/shared-target/debug/rustfs" "$TMP_DIR/dry-run-target.out"
|
||||
rg -q "enospc_root=$TMP_DIR/enospc" "$TMP_DIR/dry-run-target.out"
|
||||
|
||||
mkdir -p "$TMP_DIR/nonempty/g07-mrf-responsibility"
|
||||
touch "$TMP_DIR/nonempty/g07-mrf-responsibility/existing.json"
|
||||
if bash "$RUNNER" --dry-run --out-dir "$TMP_DIR/nonempty" >"$TMP_DIR/nonempty.out" 2>"$TMP_DIR/nonempty.err"; then
|
||||
echo "W13 runner should reject non-empty evidence case directories" >&2
|
||||
exit 1
|
||||
fi
|
||||
rg -q "evidence case directory is not empty" "$TMP_DIR/nonempty.err"
|
||||
|
||||
if bash "$RUNNER" --plan-only --test p4 --soak-seconds 10 >/dev/null 2>&1; then
|
||||
echo "W13 runner should reject short P4 release soak without --allow-short-soak" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash "$RUNNER" --self-test
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
RUNNER="${PROJECT_ROOT}/scripts/run_scanner_heal_w16_recovery_evidence.sh"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
bash -n "$RUNNER"
|
||||
|
||||
bash "$RUNNER" --help >"$TMP_DIR/help.out"
|
||||
rg -q "RUSTFS_SCANNER_HEAL_W16_OUTPUT_ROOT" "$TMP_DIR/help.out"
|
||||
rg -q "G04-root_floor_intent_crash_evidence.json" "$TMP_DIR/help.out"
|
||||
rg -q "G12-settlement_quota_path_evidence.json" "$TMP_DIR/help.out"
|
||||
|
||||
env -u CARGO_TARGET_DIR bash "$RUNNER" \
|
||||
--dry-run \
|
||||
--out-dir "$TMP_DIR/evidence" >"$TMP_DIR/dry-run.out"
|
||||
|
||||
rg -q "tests=g04 g12" "$TMP_DIR/dry-run.out"
|
||||
rg -q "rustfs-scanner scanner_recovery_intent" "$TMP_DIR/dry-run.out"
|
||||
rg -q "e2e_test distributed::replication_quota_test::four_node_four_drive_hard_quota_rejects_over_limit_put" "$TMP_DIR/dry-run.out"
|
||||
rg -q "target_dir=$PROJECT_ROOT/target" "$TMP_DIR/dry-run.out"
|
||||
|
||||
RUSTFS_SCANNER_HEAL_W16_OUTPUT_ROOT="$TMP_DIR/root-out" \
|
||||
bash "$RUNNER" --dry-run >"$TMP_DIR/dry-run-output-root.out"
|
||||
rg -q "run_dir=$TMP_DIR/root-out/" "$TMP_DIR/dry-run-output-root.out"
|
||||
|
||||
CARGO_TARGET_DIR="$TMP_DIR/shared-target" bash "$RUNNER" \
|
||||
--dry-run \
|
||||
--out-dir "$TMP_DIR/evidence-with-target" >"$TMP_DIR/dry-run-target.out"
|
||||
rg -q "target_dir=$TMP_DIR/shared-target" "$TMP_DIR/dry-run-target.out"
|
||||
rg -q "current_binary=$TMP_DIR/shared-target/debug/rustfs" "$TMP_DIR/dry-run-target.out"
|
||||
|
||||
mkdir -p "$TMP_DIR/nonempty/g04-crash-boundaries"
|
||||
touch "$TMP_DIR/nonempty/g04-crash-boundaries/existing.json"
|
||||
if bash "$RUNNER" --dry-run --out-dir "$TMP_DIR/nonempty" >"$TMP_DIR/nonempty.out" 2>"$TMP_DIR/nonempty.err"; then
|
||||
echo "W16 runner should reject non-empty evidence case directories" >&2
|
||||
exit 1
|
||||
fi
|
||||
rg -q "evidence case directory is not empty" "$TMP_DIR/nonempty.err"
|
||||
|
||||
bash "$RUNNER" --self-test
|
||||
Reference in New Issue
Block a user