Compare commits

..

2 Commits

Author SHA1 Message Date
houseme ab7d3f9a7d feat(scanner): surface cold segment reuse oracle
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:37:17 +08:00
houseme c123ae2123 feat(scanner): carry segment activation preflight evidence
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:13:07 +08:00
72 changed files with 544 additions and 6780 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
sha256-darwin=364f2329a7b72eb9f1608dbe1a3af37af4095354014f3cbe23ca448492d89961
sha256-linux=60983f1ebe7068cf660d473c5f76c76a650410ccc99d71934ddca7fd67607987
-1
View File
@@ -89,7 +89,6 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
test-wiring-check: ## Check tests stay registered and selected by their intended runners
@echo "🧪 Checking test wiring..."
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
.PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
-3
View File
@@ -32,8 +32,6 @@ script-tests: ## Run shell script tests
./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh
./scripts/test_scanner_heal_w13_mrf_evidence.sh
./scripts/test_scanner_heal_w16_recovery_evidence.sh
./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh
@@ -41,7 +39,6 @@ script-tests: ## Run shell script tests
./scripts/test_python_bin.sh
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
-3
View File
@@ -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"
]
},
+4
View File
@@ -111,6 +111,10 @@ runs:
shell: bash
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
shell: bash
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
shell: bash
run: ./scripts/check_uring_lane_lib_only.sh
+79
View File
@@ -0,0 +1,79 @@
# Copyright 2026 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Reports the existing required checks for paths excluded by ci.yml.
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
# action to keep validation coverage aligned. Keep this paths list in sync with
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
name: Continuous Integration (docs only)
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main, release ]
paths:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
permissions:
contents: read
jobs:
quick-checks:
name: Quick Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
# Docs-only PRs skip the full code CI, but they are exactly where a
# planning-type document could be slipped in (git add -f bypasses
# .gitignore). Run the guard here so the required "Test and Lint" check
# stays meaningful for docs-only changes.
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Satisfy required check for docs-only changes
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
+74 -77
View File
@@ -37,6 +37,25 @@ on:
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ]
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
# reports the required "Test and Lint" check for PRs skipped here.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
merge_group:
types: [ checks_requested ]
schedule:
@@ -69,32 +88,6 @@ jobs:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 2
persist-credentials: false
- name: Select scope using the base revision's policy
id: scope
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
else
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -107,7 +100,7 @@ jobs:
- name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
# Fail early with compile-free checks for every pull request.
# Fail early with compile-free checks shared with docs-only CI.
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -123,9 +116,9 @@ jobs:
uses: ./.github/actions/quick-checks
test-and-lint:
name: Workspace Test and Lint
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
name: Test and Lint
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -296,6 +289,45 @@ jobs:
- name: Run rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh
# Record the reason before this job completes as FAILURE. A separate
# dependent job cancels sibling lanes only after GitHub has preserved this
# required check's failure verdict.
- name: Annotate early-stop reason
if: >-
failure() && github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
run: |
{
echo "## CI early-stop"
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
} >> "$GITHUB_STEP_SUMMARY"
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
# CANCELLED and hid the actionable failure in the PR checks UI.
cancel-after-test-and-lint-failure:
name: Cancel siblings after Test and Lint failure
if: >-
failure() && needs.test-and-lint.result == 'failure'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
needs: [ test-and-lint ]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Cancel remaining jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -fsS -X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
# drive the object layer through process-global singletons (the GLOBAL_ENV
# ECStore, the global tier-config manager, background-expiry workers) and bind
@@ -308,8 +340,8 @@ jobs:
# See rustfs/backlog#1148 (ilm-1) and #1155.
test-ilm-integration-serial:
name: ILM Integration (serial)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -376,8 +408,8 @@ jobs:
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -417,8 +449,8 @@ jobs:
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
@@ -475,8 +507,8 @@ jobs:
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
@@ -529,8 +561,8 @@ jobs:
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 30
env:
@@ -652,8 +684,8 @@ jobs:
# job had neither, so each closed/merged PR really ran the whole io_uring
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
# 30662728539) and kept the cancellation run in progress for minutes.
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
# a container, applies no seccomp filter that would block io_uring_setup — so
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
@@ -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
View File
@@ -9928,7 +9928,6 @@ dependencies = [
"async-trait",
"base64-simd",
"bytes",
"chrono",
"crc-fast",
"futures",
"hotpath",
-55
View File
@@ -593,20 +593,6 @@ pub struct DataUsageSnapshotIdentity {
pub scanner_epoch: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSegmentInvalidationProof {
#[serde(default)]
pub process_epoch: String,
#[serde(default)]
pub generation_start: u64,
#[serde(default)]
pub generation_end: u64,
#[serde(default)]
pub producer_identity_coverage_complete: bool,
#[serde(default)]
pub cold_zero_walk_oracle: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSnapshotSetState {
pub pool_index: u64,
@@ -621,8 +607,6 @@ pub struct DataUsageSnapshotSetState {
pub complete: bool,
#[serde(default)]
pub tombstone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
}
impl DataUsageInfo {
@@ -3089,7 +3073,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
assert!(observed_data_usage_is_newer(&partial, &authoritative));
}
@@ -3112,7 +3095,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
},
DataUsageSnapshotSetState {
pool_index: 1,
@@ -3122,7 +3104,6 @@ mod tests {
scan_plan_digest: Some([2; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
},
],
..Default::default()
@@ -3132,42 +3113,6 @@ mod tests {
assert!(partial.is_valid_partial_snapshot());
}
#[test]
fn set_state_segment_invalidation_proof_is_additive() {
#[derive(Deserialize)]
struct LegacySetState {
pool_index: u64,
set_index: u64,
complete: bool,
}
let proof = DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 3,
generation_end: 5,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
};
let state = DataUsageSnapshotSetState {
pool_index: 1,
set_index: 2,
scanner_cycle: Some(9),
scanner_epoch: Some(4),
scan_plan_digest: Some([7; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: Some(proof.clone()),
};
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
assert_eq!(legacy.pool_index, 1);
assert_eq!(legacy.set_index, 2);
assert!(legacy.complete);
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
}
#[test]
fn completeness_marker_requires_a_snapshot_timestamp() {
let untimestamped = DataUsageInfo {
@@ -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,
+1 -47
View File
@@ -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()
+5 -6
View File
@@ -15,7 +15,6 @@
//! Regression coverage for anonymous access on multipart control APIs.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use crate::kms::common::LocalKMSTestEnvironment;
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
@@ -1466,10 +1465,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut kms_env = LocalKMSTestEnvironment::new().await?;
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
kms_env.wait_for_kms_ready().await?;
let env = &kms_env.base_env;
let mut env = RustFSTestEnvironment::new().await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let bucket = "anon-post-default-sse-kms";
let object_key = "post-default-sse-kms-object.txt";
@@ -1485,7 +1484,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::AwsKms)
.kms_master_key_id(default_key_id)
.kms_master_key_id("test-key")
.build()
.expect("default encryption rule should build"),
)
@@ -1137,12 +1137,7 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
let (listed, _, _) = tokio::try_join!(
env.wait_local_listed(bucket, hit_key, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
)?;
assert!(listed);
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
+21 -492
View File
@@ -2606,24 +2606,6 @@ where
usize::try_from(size).unwrap_or_default()
}
fn is_decommission_set_local_usage_cache(bucket: &str, object: &str) -> bool {
if bucket != RUSTFS_META_BUCKET {
return false;
}
let Some(path) = object
.strip_prefix(BUCKET_META_PREFIX)
.and_then(|path| path.strip_prefix('/'))
else {
return false;
};
let name = match path.rsplit_once('/') {
Some((bucket, name)) if !bucket.is_empty() && !bucket.contains('/') && bucket != "." && bucket != ".." => name,
Some(_) => return false,
None => path,
};
name.strip_suffix(".bkp").unwrap_or(name) == DATA_USAGE_CACHE_NAME
}
fn with_decommission_entry_context<E: Display>(stage: &str, bucket: &str, object: &str, err: E) -> Error {
Error::other(format!("decommission entry {stage} failed for bucket {bucket} object {object}: {err}"))
}
@@ -3481,11 +3463,6 @@ impl PoolRebalanceActivationFence {
}
}
#[cfg(test)]
tokio::task_local! {
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
}
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
pool: Arc<S>,
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
@@ -3496,21 +3473,17 @@ where
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
>,
{
// Match entry admission: rebalance.bin -> pool.bin. An entry retains its
// run read fence while target mutations acquire the pool metadata fence;
// activation must not hold pool.bin while waiting for that entry to drain.
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
#[cfg(test)]
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
let rebalance_meta_guard = rebalance_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_rebalance_meta_lock_error)?;
// Activation lock order is always pool.bin -> rebalance.bin.
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
let pool_meta_guard = pool_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_pool_meta_lock_error)?;
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
let rebalance_meta_guard = rebalance_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_rebalance_meta_lock_error)?;
Ok(PoolRebalanceActivationFence {
pool_meta_guard,
@@ -13312,12 +13285,6 @@ impl ECStore {
);
return Ok(DecommissionEntryAttemptOutcome::Complete);
}
// Scanner caches describe their own erasure set and are rebuilt there.
// Copying one onto another set can overwrite unrelated cache contents or
// leave an unresolvable target-capacity intent after a conditional PUT.
if is_decommission_set_local_usage_cache(&bucket, &entry.name) {
return Ok(DecommissionEntryAttemptOutcome::Complete);
}
let durable_ilm_record = if bucket == RUSTFS_META_BUCKET {
classify_durable_ilm_record(&entry.name)
.map_err(|err| with_decommission_entry_context("durable_ilm_namespace", &bucket, &entry.name, err))?
@@ -13875,7 +13842,7 @@ impl ECStore {
let bucket = bucket.clone();
let read_result = set
let rd = match set
.get_object_reader(
bucket.as_str(),
&encode_dir_object(&version.name),
@@ -13883,11 +13850,8 @@ impl ECStore {
HeaderMap::new(),
&decommission_object_migration_read_opts(version_id.clone()),
)
.await;
#[cfg(test)]
let read_result =
decommission_test_wrap_result("object_read", &bucket, &version.name, version_attempt, read_result);
let rd = match read_result {
.await
{
Ok(rd) => rd,
Err(err) => {
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
@@ -13896,6 +13860,15 @@ impl ECStore {
break;
}
if !ignore {
//
if bucket == RUSTFS_META_BUCKET && version.name.contains(DATA_USAGE_CACHE_NAME) {
ignore = true;
error!("decommission_pool: ignore data usage cache {}", &version.name);
break;
}
}
failure = true;
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
error!(
@@ -16863,7 +16836,7 @@ impl ECStore {
return;
}
if is_decommission_set_local_usage_cache(&bucket_name, &entry.name) {
if bucket_name == RUSTFS_META_BUCKET && entry.name.contains(DATA_USAGE_CACHE_NAME) {
return;
}
@@ -17181,361 +17154,6 @@ mod tests {
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use serde::Serialize;
#[test]
fn decommission_set_local_usage_cache_classification_is_exact() {
for object in [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
] {
assert!(is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
assert!(!is_decommission_set_local_usage_cache("user-bucket", object), "{object}");
}
for object in [
"buckets/.usage.v2.json",
"buckets/.usage.v2.json.bkp",
"buckets/.usage-cache.bin.extra",
"buckets/.usage-cache.bin.bkp.extra",
"buckets/prefix.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp.bkp",
"buckets/photos/nested/.usage-cache.bin",
"buckets//.usage-cache.bin",
"buckets/../.usage-cache.bin",
"buckets/./.usage-cache.bin",
"buckets/.usage-cache.bin/child",
"config/.usage-cache.bin",
"buckets-other/.usage-cache.bin",
".usage-cache.bin",
] {
assert!(!is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
}
}
#[tokio::test]
#[serial_test::serial]
async fn decommission_keeps_set_local_usage_caches_out_of_target_capacity() {
use crate::object_api::PutObjReader;
use tokio::io::AsyncReadExt as _;
// Keep the scenario's large setup and migration futures off the test
// future so ordinary metadata I/O retains the default thread stack.
let (_temp_dirs, store, _other_store) =
Box::pin(crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None)).await;
let user_bucket = "decommission-usage-cache-control";
Box::pin(store.make_bucket(user_bucket, &MakeBucketOptions::default()))
.await
.expect("create the ordinary-object control bucket");
let incarnation = Box::pin(store.bucket_incarnation_id(user_bucket))
.await
.expect("control bucket incarnation");
let source_time = OffsetDateTime::now_utc();
let cache_objects = [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
];
let conflict_object = "buckets/.usage-cache.bin.conflict";
let source_read_failure_object = "buckets/.usage-cache.bin.read-error";
let source_body = b"source set cache";
let target_body = b"independent older target set cache";
for object in cache_objects.into_iter().chain([conflict_object]) {
for (pool_index, body, mod_time) in [
(0, source_body.as_slice(), source_time),
(1, target_body.as_slice(), source_time - Duration::seconds(1)),
] {
// Scanner cache persistence writes directly to its own set.
store.pools[pool_index]
.get_disks_by_key(object)
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(body.to_vec()),
&ObjectOptions {
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed distinct native set-local objects");
}
}
let controls = [
(RUSTFS_META_BUCKET, "buckets/.usage.v2.json"),
(RUSTFS_META_BUCKET, "buckets/photos/.usage-cache.bin.extra"),
(user_bucket, "ordinary-object"),
(user_bucket, "buckets/.usage-cache.bin"),
];
for (bucket, object) in controls {
store.pools[0]
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(b"ordinary object contents".to_vec()),
&ObjectOptions {
expected_bucket_incarnation_id: (bucket == user_bucket).then_some(incarnation),
..Default::default()
},
)
.await
.expect("seed a control that must migrate");
}
store.pools[0]
.put_object(
RUSTFS_META_BUCKET,
source_read_failure_object,
&mut PutObjReader::from_vec(source_body.to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed a similarly named object whose source read will fail");
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
]],
);
Box::pin(store.save_current_pool_meta_for_decommission_start(&[0], Vec::new()))
.await
.expect("activate the decommission capacity reservation");
for object in cache_objects {
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(object),
))
.await
.expect("set-local cache must not enter cross-pool migration");
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
let mut reader = store.pools[pool_index]
.get_disks_by_key(object)
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("each set must retain its own cache");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read retained cache");
assert_eq!(actual, expected, "pool {pool_index}, {object}");
}
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("decommission progress");
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 0));
assert_eq!((info.bytes_done, info.bytes_failed), (0, 0));
let reservation = info.capacity_reservation.as_ref().expect("capacity reservation");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(reservation.consumed_target_physical_bytes, 0);
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
}
let mut persisted = PoolMeta::default();
Box::pin(persisted.load_no_lock_from_replicas(store.pools.clone()))
.await
.expect("reload durable capacity intents after cache entries");
let reservation = persisted.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("durable reservation");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
let injected_reads = Arc::new(AtomicUsize::new(0));
let observed_reads = Arc::clone(&injected_reads);
let read_fault = DecommissionTestFaultGuard::install(Arc::new(move |stage, bucket, object, _, success| {
if stage == "object_read" && bucket == RUSTFS_META_BUCKET && object == source_read_failure_object && success {
observed_reads.fetch_add(1, Ordering::SeqCst);
return true;
}
false
}));
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: source_read_failure_object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(source_read_failure_object),
))
.await
.expect("entry must record the non-NotFound source read failure");
drop(read_fault);
assert_eq!(injected_reads.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
{
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("source read failure progress");
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 1));
assert_eq!(info.bytes_failed, source_body.len());
assert_eq!(
info.capacity_reservation
.as_ref()
.expect("reservation")
.pending_target_physical_bytes,
0
);
}
let mut retained = store.pools[0]
.get_object_reader(
RUSTFS_META_BUCKET,
source_read_failure_object,
None,
HeaderMap::new(),
&ObjectOptions::default(),
)
.await
.expect("source read failure must retain the source");
let mut retained_body = Vec::new();
retained
.stream
.read_to_end(&mut retained_body)
.await
.expect("read retained source");
assert_eq!(retained_body, source_body);
drop(retained);
let target_err = store.pools[1]
.get_object_info(RUSTFS_META_BUCKET, source_read_failure_object, &ObjectOptions::default())
.await
.expect_err("failed source read must not create a target object");
assert!(is_err_object_not_found(&target_err), "unexpected target state: {target_err:?}");
for (bucket, object) in controls {
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.to_string(),
store.pools[0].get_disks_by_key(object),
))
.await
.expect("ordinary and similarly named objects must migrate");
let mut reader = store.pools[1]
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("control must exist on the target");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read migrated control");
assert_eq!(actual, b"ordinary object contents", "{bucket}/{object}");
let err = store.pools[0]
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect_err("migrated control must be removed from the source");
assert!(is_err_object_not_found(&err), "{bucket}/{object}: {err:?}");
}
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: conflict_object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(conflict_object),
))
.await
.expect("entry must record a real conditional-copy failure");
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("final progress");
assert_eq!(info.items_decommissioned, controls.len());
assert_eq!(
info.items_decommission_failed, 2,
"similar names must not hide read or migration failures"
);
assert_eq!(info.bytes_failed, source_body.len() * 2);
drop(meta);
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
let mut reader = store.pools[pool_index]
.get_object_reader(RUSTFS_META_BUCKET, conflict_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("failed migration must preserve both objects");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read conflict object");
assert_eq!(actual, expected);
}
}
#[tokio::test]
#[serial_test::serial]
async fn decommission_final_sweep_excludes_only_set_local_usage_caches() {
use crate::object_api::PutObjReader;
let (_temp_dirs, store, _other_store) =
crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None).await;
for object in [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
] {
store.pools[0]
.get_disks_by_key(object)
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(b"set-local cache".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed each supported set-local cache path");
}
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
]],
);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("activate the final-sweep generation");
let generation = store.active_decommission_generation(0).await.expect("active generation");
store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect("the four set-local cache forms must not block the final sweep");
for object in [
"buckets/.usage-cache.bin.extra",
"buckets/photos/.usage-cache.bin.bkp.extra",
"buckets/.usage.v2.json",
] {
let source_set = store.pools[0].get_disks_by_key(object);
source_set
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(b"unmigrated ordinary metadata".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed ordinary metadata that must prevent completion");
let err = store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect_err("a remaining similar name or global usage snapshot must block completion");
assert!(err.to_string().contains("after decommissioning"), "unexpected final-sweep error: {err:?}");
assert!(err.to_string().contains(object), "the final sweep must identify {object}: {err:?}");
source_set
.delete_object(RUSTFS_META_BUCKET, object, ObjectOptions::default())
.await
.expect("remove only the ordinary-metadata control before the next sweep");
}
store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect("only the four set-local caches remain after removing the controls");
}
#[test]
fn pool_activation_fleet_proof_error_classifier_matches_only_retryable_proof_failures() {
assert!(is_pool_activation_fleet_proof_error(&Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED)));
@@ -20247,55 +19865,6 @@ mod tests {
assert!(!is_decommission_copy_cleanup_safe_error(&wrap(Error::SlowDown)));
}
#[test]
fn decommission_target_gate_retry_recognizes_multipart_part_errors() {
let wrap = |inner: Error| {
data_movement::data_movement_part_stage_error_for_test(
"decommission_object",
"put_object_part",
"bucket-a",
"object-a",
1,
inner,
)
};
let gate_busy_message =
format!("{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX}7{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX}");
let wrapped = wrap(decommission_capacity_blocked_error(&gate_busy_message));
assert!(is_decommission_capacity_target_gate_busy(&wrapped));
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), Some(7));
assert_eq!(
wrapped.to_string(),
format!(
"Io error: decommission_object: put_object_part failed for bucket-a/object-a part 1: {}",
decommission_capacity_blocked_error(&gate_busy_message)
)
);
for unrelated in [
Error::SlowDown,
Error::DiskFull,
decommission_capacity_blocked_error("target capacity is exhausted"),
Error::other(gate_busy_message),
] {
let wrapped = wrap(unrelated);
assert!(!is_decommission_capacity_target_gate_busy(&wrapped));
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), None);
}
for missing_target in [
Error::FileNotFound,
Error::ObjectNotFound("bucket-a".to_string(), "object-a".to_string()),
Error::VersionNotFound("bucket-a".to_string(), "object-a".to_string(), "version-a".to_string()),
] {
assert!(is_decommission_copy_cleanup_safe_error(&missing_target));
assert!(
!is_decommission_copy_cleanup_safe_error(&wrap(missing_target)),
"a missing target part must never authorize source cleanup"
);
}
assert!(is_decommission_target_capacity_error(&wrap(Error::DiskFull)));
}
#[test]
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
@@ -22525,7 +22094,7 @@ mod pools_tests {
.resources
.lock()
.expect("activation lock recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
);
let mut second_acquire = Box::pin(acquire_pool_rebalance_activation_locks(second.clone(), None));
@@ -22541,50 +22110,10 @@ mod pools_tests {
.resources
.lock()
.expect("activation lock recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
);
}
#[tokio::test]
async fn test_activation_cancellation_releases_rebalance_fence_while_pool_fence_is_contended() {
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
let pool = Arc::new(ActivationLockRecorder {
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
owner: "activation-cancellation",
resources: StdMutex::new(Vec::new()),
});
let pool_lock = pool
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("pool lock should be created");
let pool_reader = pool_lock
.get_read_lock(std::time::Duration::from_secs(5))
.await
.expect("ordinary mutation should hold the pool read fence");
pool.resources.lock().expect("recorder should not be poisoned").clear();
let mut activation = Box::pin(acquire_pool_rebalance_activation_locks(Arc::clone(&pool), None));
assert!(matches!(futures::poll!(&mut activation), Poll::Pending));
assert_eq!(
*pool.resources.lock().expect("recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()],
"activation must hold the run fence before waiting for the pool fence",
);
drop(activation);
let rebalance_lock = pool
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, REBAL_META_NAME)
.await
.expect("run lock should be created");
let run_writer = rebalance_lock
.get_write_lock(std::time::Duration::from_secs(5))
.await
.expect("cancelling activation must release its already-acquired run fence");
assert!(
!pool_reader.is_released(),
"cancelling activation must not release another caller's pool fence"
);
assert!(!run_writer.is_lock_lost());
}
#[test]
fn decommission_receipt_run_token_changes_with_persisted_start_time() {
let first = OffsetDateTime::from_unix_timestamp(1_000).expect("first run timestamp should be valid");
+4 -29
View File
@@ -1521,27 +1521,9 @@ fn data_movement_part_stage_error(
bucket: &str,
object: &str,
part_number: usize,
err: Error,
err: impl std::fmt::Display,
) -> Error {
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
return data_movement_context_error(rendered, err);
}
// A missing target part is not evidence that the source can be deleted.
// Keep other part errors opaque to the source-cleanup classifiers.
Error::other(rendered)
}
#[cfg(test)]
pub(crate) fn data_movement_part_stage_error_for_test(
op_label: &str,
stage: &str,
bucket: &str,
object: &str,
part_number: usize,
err: Error,
) -> Error {
data_movement_part_stage_error(op_label, stage, bucket, object, part_number, err)
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}"))
}
fn is_data_movement_part_read_error(err: &Error) -> bool {
@@ -2446,15 +2428,8 @@ mod tests {
let err =
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
let message = err.to_string();
assert_eq!(
message,
Error::other(format!(
"rebalance_object: put_object_part failed for bucket-a/object-a part 7: {}",
Error::SlowDown
))
.to_string()
);
assert!(data_movement_stage_source(&err).is_none());
assert!(message.contains("rebalance_object: put_object_part failed for bucket-a/object-a part 7"));
assert!(message.contains(Error::SlowDown.to_string().as_str()));
}
#[test]
-2
View File
@@ -3397,7 +3397,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
partial.buckets_usage.insert(
"bucket".to_string(),
@@ -3470,7 +3469,6 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
}],
..Default::default()
};
@@ -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,
);
}
}
}
}
+9 -248
View File
@@ -164,16 +164,6 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
max_keys
}
fn list_versions_scan_limit(max_keys: i32, has_version_marker: bool) -> i32 {
if max_keys <= 0 {
return 0;
}
// The marker object's versions may all be filtered out after gathering.
// Reserve its raw entry in addition to the next-page lookahead entry.
max_keys_plus_one(max_keys, true) + i32::from(has_version_marker)
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum GatherResultsState {
LimitReached,
@@ -2149,19 +2139,15 @@ fn build_list_versions_next_marker(
// here; advertise it as the literal `null` marker so a resumed listing
// parses it back to `VersionMarker::Null` instead of a nil UUID that
// `find_version_index` can never match (issue #6745).
let version_marker = if last.is_dir && last.mod_time.is_none() {
// A CommonPrefix has no version to resume; a version marker would
// make the next page include this same prefix again.
None
} else {
(
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)),
Some(
last.version_id
.filter(|v| !v.is_nil())
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string()),
)
};
(Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), version_marker)
),
)
} else if let Some(last_prefix) = prefixes.last() {
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
} else {
@@ -2880,20 +2866,6 @@ fn listing_entries_supplement_target(
return None;
}
if let Some(directory) = entries.0.iter().flatten().find(|entry| entry.is_dir()) {
let directory_copies = entries
.0
.iter()
.flatten()
.filter(|entry| entry.is_dir() && entry.name == directory.name)
.count();
// A committed child may have some of its directory copies only on
// fallback disks, just like object metadata in a partial primary sample.
if directory_copies < resolver.dir_quorum {
return Some(directory.name.clone());
}
}
for (idx, entry) in entries.0.iter().enumerate() {
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
continue;
@@ -4046,7 +4018,8 @@ impl ECStore {
None
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
// Always request max_keys + 1 to detect if there are more results
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -5352,7 +5325,7 @@ impl Sets {
None
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -6061,7 +6034,7 @@ impl SetDisks {
let has_version_marker = version_marker.is_some();
let version_marker = version_marker.map(parse_version_marker).transpose()?;
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -6275,7 +6248,7 @@ impl SetDisks {
None
};
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -7468,153 +7441,6 @@ mod test {
assert!(cancel.is_cancelled());
}
#[test]
fn list_versions_pagination_scan_limit_boundaries() {
for has_version_marker in [false, true] {
assert_eq!(super::list_versions_scan_limit(-1, has_version_marker), 0);
assert_eq!(super::list_versions_scan_limit(0, has_version_marker), 0);
let marker_slot = i32::from(has_version_marker);
assert_eq!(super::list_versions_scan_limit(1, has_version_marker), 2 + marker_slot);
assert_eq!(super::list_versions_scan_limit(MAX_OBJECT_LIST, has_version_marker), 1001 + marker_slot);
assert_eq!(super::list_versions_scan_limit(i32::MAX, has_version_marker), 1001 + marker_slot);
}
}
#[tokio::test]
async fn list_versions_pagination_does_not_require_an_empty_final_page() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "version-pagination-bucket";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("pagination bucket should be created");
let mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
for kind in ["objects", "deletes", "null", "mixed", "delimiter"] {
let count = if kind == "mixed" { 5 } else { 10 };
let mut expected = Vec::new();
for index in 0..count {
let name = if kind == "delimiter" && index % 2 == 1 {
format!("{kind}/testobject-{index:02}/child")
} else {
format!("{kind}/testobject-{index:02}")
};
let entry = match kind {
"deletes" => test_delete_marker_meta_entry(&name, mod_time),
"null" => test_object_meta_entry(&name),
"mixed" => test_object_with_delete_marker_meta_entry(&name, mod_time, mod_time + time::Duration::SECOND),
_ => test_object_meta_entry_with_erasure_versions(&name, &[(mod_time, "etag", 2, 2)]),
};
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(&name);
tokio::fs::create_dir_all(&object_dir)
.await
.expect("pagination object directory should be created");
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), &entry.metadata)
.await
.expect("pagination metadata should be written");
}
if kind == "delimiter" && index % 2 == 1 {
expected.push((name.trim_end_matches("child").to_owned(), None, false));
} else {
let versions = entry.file_info_versions(bucket).expect("fixture versions should decode");
expected.extend(
versions
.versions
.iter()
.map(|version| (name.clone(), version.version_id, version.deleted)),
);
}
}
let prefix = format!("{kind}/");
let delimiter = (kind == "delimiter").then(|| "/".to_owned());
// Exercise each public/internal entry point with the reported page size.
// The store entry point also covers exact and one-over limit boundaries.
for (layer, max_keys) in [(0, 0), (0, 1), (0, 5), (0, 9), (0, 10), (0, 11), (1, 5), (2, 5), (3, 5)] {
if layer == 3 && delimiter.is_some() {
continue;
}
let mut marker = None;
let mut version_marker = None;
let expected_pages = if max_keys == 0 {
1
} else {
10usize.div_ceil(usize::try_from(max_keys).expect("positive page size"))
};
let mut actual = Vec::new();
for page in 0..expected_pages {
let result = match layer {
0 => {
store
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
1 => {
store.pools[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
2 => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
_ => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(
bucket,
&prefix,
marker,
version_marker,
max_keys,
)
.await
}
}
.expect("version page should list successfully");
let page_size = usize::try_from(max_keys).expect("nonnegative page size");
assert_eq!(result.objects.len() + result.prefixes.len(), (10 - page * page_size).min(page_size));
let has_more = page + 1 < expected_pages;
assert_eq!(result.is_truncated, has_more, "{kind}, layer {layer}, max_keys {max_keys}, page {page}");
assert_eq!(
result.next_marker.is_some(),
has_more,
"key marker must exist only when another page exists"
);
if !has_more {
assert!(
result.next_version_idmarker.is_none(),
"the final page must not advertise a version marker"
);
}
actual.extend(
result
.objects
.into_iter()
.map(|object| (object.name, object.version_id, object.delete_marker)),
);
actual.extend(result.prefixes.into_iter().map(|prefix| (prefix, None, false)));
marker = result.next_marker;
version_marker = result.next_version_idmarker;
}
// Objects and CommonPrefixes are serialized separately; compare their
// identities without relying on their relative position in the response.
actual.sort();
let mut expected = if max_keys == 0 { Vec::new() } else { expected.clone() };
expected.sort();
assert_eq!(actual, expected, "{kind}, layer {layer}, max_keys {max_keys}");
}
}
}
#[test]
fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
let version_marker = Some(VersionMarker::Null);
@@ -9622,71 +9448,6 @@ mod test {
assert!(supplemented.is_latest_delete_marker());
}
#[tokio::test]
async fn latest_listing_supplement_checks_fallback_disks_for_common_prefix_quorum() {
let mut fallback_disks = Vec::new();
let mut fallback_tempdirs = Vec::new();
for index in 0..4 {
let tempdir = tempfile::tempdir().expect("fallback tempdir should be created");
let endpoint = Endpoint::try_from(tempdir.path().to_str().expect("fallback path should be utf8"))
.expect("fallback endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("fallback disk should be created");
disk.make_volume("bucket").await.expect("fallback bucket should be created");
for copies in [3, 4] {
if index < copies {
let object = format!("quux-{copies}/thud");
let entry = test_object_meta_entry(&object);
disk.write_all("bucket", &format!("{object}/{STORAGE_FORMAT_FILE}"), bytes::Bytes::from(entry.metadata))
.await
.expect("fallback child metadata should be written");
}
}
fallback_disks.push(disk);
fallback_tempdirs.push(tempdir);
}
let supplement = ListingSupplement::new(
ListingSupplementOptions {
bucket: "bucket".to_owned(),
path: String::new(),
recursive: false,
incl_deleted: false,
skip_hidden_prefix_check: false,
filter_prefix: None,
forward_to: None,
per_disk_limit: 100,
skip_total_timeout: true,
walkdir_timeout: None,
walkdir_stall_timeout: None,
},
Arc::new(fallback_disks),
FallbackClaimTracker::default(),
);
// A 16-drive EC:4 set asks 12 primary disks. A committed write may
// exist on eight primary disks and all four remaining fallback disks.
let resolver = list_metadata_resolution_params("bucket".to_owned(), 4, 12, false, 0);
for fallback_copies in [3, 4] {
let prefix = format!("quux-{fallback_copies}/");
let mut primary = vec![Some(test_dir_meta_entry(&prefix)); 8];
primary.extend([None, None, None, None]);
let entry =
resolve_listing_entries_with_supplement(MetaCacheEntries(primary), resolver.clone(), true, supplement.clone())
.await;
assert_eq!(
entry.map(|entry| entry.name),
(fallback_copies == 4).then_some(prefix),
"the common prefix needs all twelve copies, including fallback disks"
);
}
}
#[test]
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
-1
View File
@@ -104,7 +104,6 @@ walkdir = { workspace = true }
http = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
chrono = { workspace = true }
[lib]
doctest = false
+16 -129
View File
@@ -841,10 +841,6 @@ pub struct HealManager {
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
/// Set IDs whose durable replacement metadata is corrupt or conflicting.
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
/// Durable handoff of interrupted administrator root traversals.
root_recovery: Arc<root_recovery::RootHealRecovery>,
/// Keep forceStart's cancellation side effects inside the shutdown fence.
force_start_shutdown: Mutex<()>,
/// Storage layer interface
storage: Arc<dyn HealStorageAPI>,
/// Cancel token
@@ -880,7 +876,6 @@ struct HealQueueContext<'a> {
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
root_recovery: &'a Arc<root_recovery::RootHealRecovery>,
config: &'a Arc<RwLock<HealConfig>>,
statistics: &'a Arc<RwLock<HealStatistics>>,
storage: &'a Arc<dyn HealStorageAPI>,
@@ -1382,8 +1377,6 @@ impl HealManager {
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
root_recovery: Arc::new(root_recovery::RootHealRecovery::default()),
force_start_shutdown: Mutex::new(()),
storage,
cancel_token: CancellationToken::new(),
statistics: Arc::new(RwLock::new(HealStatistics::new())),
@@ -1419,23 +1412,6 @@ impl HealManager {
"Heal manager starting"
);
// Restore graceful-shutdown root responsibilities before automatic
// repair can admit overlapping work.
if let Err(error) = self.replay_root_heals().await {
// A missing owner or invalid root record must not block existing
// replacement recovery. Keep its file for a later restart after
// the owner is readable or the record has been repaired.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "root_recovery_deferred",
error = %error,
"Root heal restart recovery deferred"
);
}
// start scheduler
self.start_scheduler().await?;
@@ -1473,7 +1449,6 @@ impl HealManager {
/// Stop HealManager
pub async fn stop(&self) -> Result<()> {
let _force_start_guard = self.force_start_shutdown.lock().await;
info!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
@@ -1483,39 +1458,11 @@ impl HealManager {
"Heal manager stopping"
);
// Keep scheduler, cancellation, and retry ownership stable until every
// unfinished root traversal has a durable successor. A failed write
// must leave the manager running and the shutdown marker unclean.
let mut active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for task in active_heals.values() {
if root_recovery::is_root_heal(&task.heal_type, task.source) {
if task.get_status().await == HealTaskStatus::Completed {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
} else {
let mut request = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request,
Err(Error::TaskTimeout) => {
let mut request = task.retry_request();
request.options.timeout = Some(Duration::ZERO);
request
}
Err(error) => return Err(error),
};
request.retry_attempts = task.retry_attempts;
self.root_recovery.persist(&request).await?;
}
}
}
for request in queue.requests().chain(retrying.values().map(|retrying| &retrying.request)) {
self.root_recovery.persist(request).await?;
}
// cancel all tasks
self.cancel_token.cancel();
drop(retrying);
drop(queue);
// cancel active workers after the durable handoff
// wait for all tasks to complete
let mut active_heals = self.active_heals.lock().await;
for task in active_heals.values() {
if let Err(e) = task.cancel().await {
warn!(
@@ -1642,17 +1589,6 @@ impl HealManager {
let admission_start = Instant::now();
let source = request.source;
let force_start = request.force_start;
// A forceStart must not retire an old durable owner if shutdown will
// reject its replacement. Hold the same gate through final admission.
let _force_start_guard = if source == HealRequestSource::Admin && force_start {
let guard = self.force_start_shutdown.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
Some(guard)
} else {
None
};
// HS-06 forceStart semantics (admin only): MinIO stops the old task
// first and then starts the new one. Cancel any active admin task
// overlapping this request's path before entering admission, so the
@@ -1660,9 +1596,7 @@ impl HealManager {
if request.source == HealRequestSource::Admin && request.force_start {
let overlapping: Vec<String> = {
let active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
let mut ids = active_heals
active_heals
.iter()
.filter(|(task_id, task)| {
task.source == HealRequestSource::Admin
@@ -1670,17 +1604,7 @@ impl HealManager {
&& *task_id != &request.id
})
.map(|(task_id, _)| task_id.clone())
.collect::<Vec<_>>();
ids.extend(
queue
.requests()
.chain(retrying.values().map(|retrying| &retrying.request))
.filter(|pending| {
root_recovery::is_root_heal(&pending.heal_type, pending.source) && pending.id != request.id
})
.map(|pending| pending.id.clone()),
);
ids
.collect()
};
for task_id in overlapping {
match self.cancel_task(&task_id).await {
@@ -1694,14 +1618,17 @@ impl HealManager {
result = "force_start_cancelled_overlap",
"Admin forceStart cancelled an overlapping heal task"
),
Err(err) => return Err(err),
}
}
// A failed or timed-out replay may have only its durable owner
// left. Root responsibility overlaps every administrator path.
for pending in self.root_recovery.pending().await? {
if pending.id != request.id {
self.cancel_task(&pending.id).await?;
Err(err) => warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
cancelled_task_id = %task_id,
error = %err,
result = "force_start_cancel_failed",
"Admin forceStart failed to cancel an overlapping heal task"
),
}
}
}
@@ -1714,9 +1641,6 @@ impl HealManager {
// active -> retrying transitions can slip between duplicate checks.
let lock_phase_start = Instant::now();
let active_heals = self.active_heals.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
#[cfg(test)]
pause_duplicate_admission_after_active_lock(&request.id).await;
let mut queue = self.heal_queue.lock().await;
@@ -2221,7 +2145,6 @@ impl HealManager {
{
let mut active_heals = self.active_heals.lock().await;
if let Some(task) = active_heals.get(&canonical_task_id) {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
@@ -2245,11 +2168,6 @@ impl HealManager {
{
let mut retrying_heals = self.retrying_heals.lock().await;
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
self.root_recovery
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
retrying.cancel_token.cancel();
drop(retrying_heals);
@@ -2270,11 +2188,6 @@ impl HealManager {
}
let mut queue = self.heal_queue.lock().await;
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
if queue.remove_request_id(&canonical_task_id).is_some() {
publish_heal_queue_length(&queue);
info!(
@@ -2292,10 +2205,6 @@ impl HealManager {
return Ok(());
}
drop(queue);
if self.root_recovery.cancel_pending(&canonical_task_id).await? {
return Ok(());
}
Err(Error::TaskNotFound {
task_id: task_id.to_string(),
})
@@ -2314,7 +2223,6 @@ impl HealManager {
for task_id in &task_ids {
if let Some(task) = active_heals.get(task_id) {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
@@ -2343,11 +2251,6 @@ impl HealManager {
.collect::<Vec<_>>();
for task_id in &task_ids {
if let Some(retrying) = retrying_heals.get(task_id) {
self.root_recovery
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(task_id) {
retrying.cancel_token.cancel();
cancelled += 1;
@@ -2370,14 +2273,6 @@ impl HealManager {
}
let mut queue = self.heal_queue.lock().await;
for request in queue
.requests()
.filter(|request| heal_type_matches_path(&request.heal_type, heal_path))
{
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
if !queued_cancelled.is_empty() {
publish_heal_queue_length(&queue);
@@ -2389,13 +2284,6 @@ impl HealManager {
self.remove_mrf_repair_notice_targets_for_task(&request.id);
}
if heal_type_matches_path(&HealType::Cluster, heal_path) {
for pending in self.root_recovery.pending().await? {
if self.root_recovery.cancel_pending(&pending.id).await? {
cancelled += 1;
}
}
}
if cancelled == 0 {
return Err(Error::TaskNotFound {
task_id: heal_path.to_string(),
@@ -2507,7 +2395,6 @@ impl std::fmt::Debug for HealManager {
mod auto_scan;
mod queue;
mod root_recovery;
mod scheduler;
mod unclean_shutdown;
@@ -1,342 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Graceful-shutdown handoff for administrator root heals. This namespace is
//! separate from erasure-set checkpoints and replacement generations, which
//! cannot represent a cluster traversal. One coordinator disk owns each
//! record; never create a fallback copy after an uncertain write or deletion.
use super::*;
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskAPI, EcstoreDiskBytes};
use crate::heal::{DiskStore, RUSTFS_META_BUCKET};
use serde::{Deserialize, Serialize};
// The metadata bucket already exists and its parent is durable. Creating a
// nested journal directory here would also require syncing every ancestor.
const ROOT_RECOVERY_PREFIX: &str = "root-heal-";
const ROOT_RECOVERY_SCHEMA: u32 = 1;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RootHealIntent {
schema: u32,
task_id: String,
#[serde(deserialize_with = "decode_options")]
options: HealOptions,
priority: HealPriority,
retry_attempts: u32,
created_at: SystemTime,
}
impl RootHealIntent {
fn from_request(request: &HealRequest) -> Self {
Self {
schema: ROOT_RECOVERY_SCHEMA,
task_id: request.id.clone(),
options: request.options.clone(),
priority: request.priority,
retry_attempts: request.retry_attempts,
created_at: request.created_at,
}
}
fn into_request(self) -> HealRequest {
let mut request = HealRequest::new(HealType::Cluster, self.options, self.priority);
request.id = self.task_id;
request.source = HealRequestSource::Admin;
request.retry_attempts = self.retry_attempts;
request.created_at = self.created_at;
request
}
}
#[derive(Default)]
pub(super) struct RootHealRecovery {
mutation: Mutex<()>,
#[cfg(test)]
disks: Option<Vec<DiskStore>>,
}
pub(super) fn is_root_heal(heal_type: &HealType, source: HealRequestSource) -> bool {
source == HealRequestSource::Admin && matches!(heal_type, HealType::Cluster)
}
fn decode_options<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<HealOptions, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
let object = value
.as_object()
.ok_or_else(|| serde::de::Error::custom("root heal options must be an object"))?;
const FIELDS: &[&str] = &[
"scan_mode",
"remove_corrupted",
"recreate_missing",
"update_parity",
"recursive",
"dry_run",
"no_lock",
"timeout",
"pool_index",
"set_index",
];
if object.keys().any(|key| !FIELDS.contains(&key.as_str())) {
return Err(serde::de::Error::custom("unknown root heal recovery option"));
}
let options: HealOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
if options.no_lock {
return Err(serde::de::Error::custom("administrator root heal cannot skip namespace locking"));
}
Ok(options)
}
fn intent_path(task_id: &str) -> Result<String> {
let parsed = uuid::Uuid::parse_str(task_id).map_err(|_| Error::Other("Invalid root heal recovery task id".to_string()))?;
if parsed.to_string() != task_id {
return Err(Error::Other("Noncanonical root heal recovery task id".to_string()));
}
Ok(format!("{ROOT_RECOVERY_PREFIX}{task_id}.json"))
}
fn decode_intent(task_id: &str, bytes: &[u8]) -> Result<RootHealIntent> {
let _ = intent_path(task_id)?;
let intent: RootHealIntent = serde_json::from_slice(bytes)
.map_err(|error| Error::Other(format!("Invalid root heal recovery record {task_id}: {error}")))?;
if intent.schema != ROOT_RECOVERY_SCHEMA || intent.task_id != task_id {
return Err(Error::Other(format!("Unsupported or mismatched root heal recovery record {task_id}")));
}
Ok(intent)
}
impl RootHealRecovery {
#[cfg(test)]
pub(super) fn with_disks(disks: Vec<DiskStore>) -> Self {
Self {
mutation: Mutex::new(()),
disks: Some(disks),
}
}
async fn disks(&self) -> Result<Vec<DiskStore>> {
#[cfg(test)]
if let Some(disks) = &self.disks {
return Ok(disks.clone());
}
let map = local_disk_map_read().await;
if map.values().any(Option::is_none) {
return Err(Error::Other("Root heal recovery owner may be on an unavailable local disk".to_string()));
}
let mut disks = map.values().flatten().cloned().collect::<Vec<_>>();
disks.sort_by_key(|disk| EcstoreDiskAPI::endpoint(disk.as_ref()).to_string());
Ok(disks)
}
async fn find(disks: &[DiskStore], task_id: &str) -> Result<Option<(DiskStore, EcstoreDiskBytes)>> {
let path = intent_path(task_id)?;
let mut found = None;
for disk in disks {
// read_all reports FileNotFound even when the whole metadata
// volume is absent; that is an unknown owner, not empty state.
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
match EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, &path).await {
Ok(bytes) => {
decode_intent(task_id, &bytes)?;
if found.is_some() {
return Err(Error::Other(format!("Multiple root heal recovery owners for {task_id}")));
}
found = Some((disk.clone(), bytes));
}
Err(DiskError::FileNotFound) => {}
Err(error) => return Err(Error::Disk(error)),
}
}
Ok(found)
}
pub(super) async fn persist(&self, request: &HealRequest) -> Result<()> {
if !is_root_heal(&request.heal_type, request.source) {
return Ok(());
}
let _guard = self.mutation.lock().await;
let disks = self.disks().await?;
let existing = Self::find(&disks, &request.id).await?;
let (disk, expected) = match existing {
Some((disk, bytes)) => (disk, Some(bytes)),
None => {
let disk = disks
.first()
.cloned()
.ok_or_else(|| Error::Other("No local disk available for root heal shutdown recovery".to_string()))?;
(disk, None)
}
};
if request.options.no_lock {
return Err(Error::Other("Administrator root heal cannot skip namespace locking".to_string()));
}
let bytes = serde_json::to_vec(&RootHealIntent::from_request(request))
.map_err(|error| Error::Other(format!("Serialize root heal recovery record: {error}")))?;
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(&request.id)?,
expected,
Some(bytes.into()),
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(()),
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
}
}
pub(super) async fn remove(&self, task_id: &str, heal_type: &HealType, source: HealRequestSource) -> Result<bool> {
if !is_root_heal(heal_type, source) {
return Ok(false);
}
let _guard = self.mutation.lock().await;
let Some((disk, bytes)) = Self::find(&self.disks().await?, task_id).await? else {
return Ok(false);
};
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(task_id)?,
Some(bytes),
None,
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(true),
_ => Err(Error::Other(format!("Root heal recovery record changed while retiring {task_id}"))),
}
}
pub(super) async fn checkpoint_failed_execution(&self, task: &HealTask) -> Result<()> {
if !is_root_heal(&task.heal_type, task.source) {
return Ok(());
}
let remaining = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request.options.timeout,
Err(Error::TaskTimeout) => Some(Duration::ZERO),
Err(error) => return Err(error),
};
let _guard = self.mutation.lock().await;
let Some((disk, expected)) = Self::find(&self.disks().await?, &task.id).await? else {
// A first execution that failed has no restart handoff to update.
return Ok(());
};
let mut intent = decode_intent(&task.id, &expected)?;
let mut expected_options = intent.options.clone();
expected_options.timeout = task.options.timeout;
if intent.created_at != task.created_at || intent.priority != task.priority || expected_options != task.options {
return Err(Error::Other(format!("Root heal recovery owner changed for {}", task.id)));
}
// A terminal timeout leaves no runtime owner for stop() to snapshot.
// Checkpoint its consumed budget before publishing terminal status;
// never refund time if an earlier checkpoint is already stricter.
intent.options.timeout = match (intent.options.timeout, remaining) {
(Some(previous), Some(remaining)) => Some(previous.min(remaining)),
(previous, remaining) => previous.or(remaining),
};
intent.retry_attempts = intent.retry_attempts.max(task.retry_attempts);
let bytes = serde_json::to_vec(&intent)
.map_err(|error| Error::Other(format!("Serialize root heal recovery checkpoint: {error}")))?;
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(&task.id)?,
Some(expected),
Some(bytes.into()),
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(()),
_ => Err(Error::Other(format!("Root heal recovery record changed while checkpointing {}", task.id))),
}
}
pub(super) async fn cancel_pending(&self, task_id: &str) -> Result<bool> {
if intent_path(task_id).is_err() {
return Ok(false);
}
self.remove(task_id, &HealType::Cluster, HealRequestSource::Admin).await
}
pub(super) async fn pending(&self) -> Result<Vec<HealRequest>> {
let _guard = self.mutation.lock().await;
let disks = self.disks().await?;
let mut ids = HashSet::new();
for disk in &disks {
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
let entries = match EcstoreDiskAPI::list_dir(disk.as_ref(), "", RUSTFS_META_BUCKET, "", -1).await {
Ok(entries) => entries,
Err(DiskError::FileNotFound) => continue,
Err(error) => return Err(Error::Disk(error)),
};
for entry in entries {
let Some(task_id) = entry
.strip_prefix(ROOT_RECOVERY_PREFIX)
.and_then(|entry| entry.strip_suffix(".json"))
else {
continue;
};
let _ = intent_path(task_id)?;
ids.insert(task_id.to_string());
}
}
let mut requests = Vec::new();
for task_id in ids {
if let Some((_, bytes)) = Self::find(&disks, &task_id).await? {
requests.push(decode_intent(&task_id, &bytes)?.into_request());
}
}
requests.sort_by(|left, right| left.created_at.cmp(&right.created_at).then_with(|| left.id.cmp(&right.id)));
Ok(requests)
}
}
impl HealManager {
pub(super) async fn replay_root_heals(&self) -> Result<()> {
// Decode every record before admitting anything. These are already
// accepted responsibilities, so restore distinct IDs even when their
// paths overlap or the configured admission capacity has changed.
let requests = self.root_recovery.pending().await?;
let active = self.active_heals.lock().await;
let mut queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for mut request in requests {
request.force_start = true;
let existing = active
.get(&request.id)
.map(|task| request_matches_task(&request, task))
.or_else(|| {
queue
.requests()
.find(|queued| queued.id == request.id)
.map(|queued| request_matches_request(&request, queued))
})
.or_else(|| {
retrying
.get(&request.id)
.map(|retrying| request_matches_request(&request, &retrying.request))
});
match existing {
Some(true) => continue,
Some(false) => return Err(Error::Other(format!("Conflicting root heal recovery task {}", request.id))),
None => {}
}
queue.push(request);
}
publish_heal_queue_length(&queue);
Ok(())
}
}
-40
View File
@@ -26,7 +26,6 @@ impl HealManager {
let retrying_heals = self.retrying_heals.clone();
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
let root_recovery = self.root_recovery.clone();
let cancel_token = self.cancel_token.clone();
let statistics = self.statistics.clone();
let storage = self.storage.clone();
@@ -60,7 +59,6 @@ impl HealManager {
retrying_heals: &retrying_heals,
mrf_repair_notice_targets: &mrf_repair_notice_targets,
replacement_recovery_anchors: &replacement_recovery_anchors,
root_recovery: &root_recovery,
config: &config,
statistics: &statistics,
storage: &storage,
@@ -80,7 +78,6 @@ impl HealManager {
retrying_heals: &retrying_heals,
mrf_repair_notice_targets: &mrf_repair_notice_targets,
replacement_recovery_anchors: &replacement_recovery_anchors,
root_recovery: &root_recovery,
config: &config,
statistics: &statistics,
storage: &storage,
@@ -109,7 +106,6 @@ impl HealManager {
retrying_heals,
mrf_repair_notice_targets,
replacement_recovery_anchors,
root_recovery,
config,
statistics,
storage,
@@ -121,9 +117,6 @@ impl HealManager {
let config = config.read().await;
let mainline_pressure = Self::mainline_throttle_active(&config, workload_provider);
let mut active_heals_guard = active_heals.lock().await;
if cancel_token.is_cancelled() {
return;
}
publish_active_heal_count(&active_heals_guard);
// Check if new heal tasks can be started
@@ -213,7 +206,6 @@ impl HealManager {
let replacement_recovery_anchors_clone = replacement_recovery_anchors.clone();
let statistics_clone = statistics.clone();
let notify_clone = notify.clone();
let root_recovery_clone = root_recovery.clone();
let manager_cancel_token = cancel_token.clone();
let task_type_label_for_spawn = task_type_label.clone();
let task_set_label_for_spawn = task_set_label.clone();
@@ -302,38 +294,6 @@ impl HealManager {
tests::pause_completed_retention_before_publish(&task_id, &completed_status).await;
let mut active_heals_guard = active_heals_clone.lock().await;
let owns_completion = active_heals_guard.contains_key(&task_id);
if owns_completion
&& result.is_ok()
&& let Err(error) = root_recovery_clone.remove(&task_id, &task.heal_type, task.source).await
{
// Keep the durable responsibility if retirement fails.
// Replaying a completed traversal is idempotent.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_SCHEDULER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
state = "root_recovery_retirement_failed",
error = %error,
"Failed to retire root heal recovery record"
);
}
if owns_completion
&& result.is_err()
&& let Err(error) = root_recovery_clone.checkpoint_failed_execution(&task).await
{
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_SCHEDULER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
state = "root_recovery_checkpoint_failed",
error = %error,
"Failed to checkpoint root heal recovery execution budget"
);
}
let cancelled_completion = if owns_completion {
false
} else {
-2
View File
@@ -26,7 +26,6 @@ use rustfs_madmin::heal_commands::HealResultItem;
use std::sync::Mutex as StdMutex;
use tempfile::TempDir;
mod root_recovery;
mod running_mainline;
use super::super::{DiskOption, DiskStore, Endpoint, new_disk, storage_api::status::BucketInfo};
@@ -95,7 +94,6 @@ async fn process_manager_queue_once(manager: &HealManager) {
retrying_heals: &manager.retrying_heals,
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
replacement_recovery_anchors: &manager.replacement_recovery_anchors,
root_recovery: &manager.root_recovery,
config: &manager.config,
statistics: &manager.statistics,
storage: &manager.storage,
@@ -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");
}
}
}
-902
View File
@@ -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();
-5
View File
@@ -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() {
+1 -5
View File
@@ -61,14 +61,10 @@ pub fn create_ahm_services_cancel_token() -> CancellationToken {
}
/// Shutdown all heal services gracefully
pub async fn shutdown_ahm_services() -> Result<()> {
if let Some(manager) = get_heal_manager() {
manager.stop().await?;
}
pub fn shutdown_ahm_services() {
if let Some(cancel_token) = GLOBAL_AHM_SERVICES_CANCEL_TOKEN.get() {
cancel_token.cancel();
}
Ok(())
}
struct HealRuntime {
-432
View File
@@ -1,432 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use bytes::Bytes;
use datafusion::object_store::{Error, Result};
use futures::{Stream, StreamExt, stream::BoxStream};
use transform_stream::AsyncTryStream;
use crate::SelectError;
/// Arrow accepts byte-sized CSV controls. Unicode quotes need streaming normalization.
pub fn csv_input_requires_normalization(quote: Option<&str>, escape: Option<&str>) -> bool {
quote.is_some_and(|quote| quote.len() > 1) || escape.is_some_and(|escape| escape.len() > 1)
}
/// CSV syntax independent of request headers, serialization formats, or S3 DTOs.
#[derive(Default)]
pub(crate) struct CsvSyntax<'a> {
pub quote: Option<&'a str>,
pub escape: Option<&'a str>,
pub field: Option<&'a str>,
pub record: Option<&'a str>,
pub comment: Option<u8>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
FieldStart,
Unquoted,
Quoted,
AfterQuote,
Escaped,
Comment,
}
/// Emits ordinary CSV with every field quoted. This avoids reserving a sentinel
/// byte that might also appear in a UTF-8 field. Only a partial control token is
/// retained between chunks; neither records nor objects are buffered.
struct CsvInputNormalizer {
quote: Vec<u8>,
escape: Vec<u8>,
field: Vec<u8>,
record: Vec<u8>,
comment: Option<u8>,
default_records: bool,
state: State,
record_start: bool,
carry: Vec<u8>,
token_size: usize,
}
impl CsvInputNormalizer {
fn new(csv: &CsvSyntax<'_>) -> Self {
let quote = csv
.quote
.filter(|value| !value.is_empty())
.unwrap_or("\"")
.as_bytes()
.to_vec();
let escape = csv
.escape
.filter(|value| !value.is_empty())
.unwrap_or("\"")
.as_bytes()
.to_vec();
let field = csv.field.filter(|value| !value.is_empty()).unwrap_or(",").as_bytes().to_vec();
let record = csv
.record
.filter(|value| !value.is_empty())
.unwrap_or("\n")
.as_bytes()
.to_vec();
let token_size = quote.len().max(escape.len()).max(field.len()).max(record.len()).max(2);
Self {
quote,
escape,
field,
record,
comment: csv.comment,
default_records: csv.record.is_none(),
state: State::FieldStart,
record_start: true,
carry: Vec::new(),
token_size,
}
}
fn record_len(&self, bytes: &[u8]) -> usize {
if self.default_records && bytes.starts_with(b"\r\n") {
2
} else if self.default_records && bytes.starts_with(b"\r") {
1
} else if bytes.starts_with(&self.record) {
self.record.len()
} else {
0
}
}
fn push_value(output: &mut Vec<u8>, bytes: &[u8]) {
for byte in bytes {
if *byte == b'"' {
output.push(b'"');
}
output.push(*byte);
}
}
fn convert(&mut self, chunk: &[u8], last: bool) -> std::result::Result<Vec<u8>, SelectError> {
let mut bytes = std::mem::take(&mut self.carry);
bytes.extend_from_slice(chunk);
let end = if last {
bytes.len()
} else {
bytes.len().saturating_sub(self.token_size - 1)
};
let mut output = Vec::with_capacity(bytes.len());
let mut pos = 0;
while pos < end {
let rest = &bytes[pos..];
let record_len = self.record_len(rest);
let field = rest.starts_with(&self.field) && self.field.len() > record_len;
match self.state {
State::Comment => {
if record_len > 0 {
self.state = State::FieldStart;
pos += record_len;
} else {
pos += 1;
}
}
State::Escaped => {
if record_len > 0 {
return Err(SelectError::CsvParsingError);
}
Self::push_value(&mut output, &rest[..1]);
self.state = State::Quoted;
pos += 1;
}
State::Quoted if rest.starts_with(&self.quote) => {
self.state = State::AfterQuote;
pos += self.quote.len();
}
State::Quoted if rest.starts_with(&self.escape) => {
self.state = State::Escaped;
pos += self.escape.len();
}
State::Quoted => {
if record_len > 0 {
return Err(SelectError::CsvParsingError);
}
Self::push_value(&mut output, &rest[..1]);
pos += 1;
}
State::AfterQuote if rest.starts_with(&self.quote) => {
Self::push_value(&mut output, &self.quote);
self.state = State::Quoted;
pos += self.quote.len();
}
State::FieldStart if self.record_start && self.comment == Some(rest[0]) => {
self.state = State::Comment;
pos += 1;
}
State::FieldStart if rest.starts_with(&self.quote) => {
output.push(b'"');
self.state = State::Quoted;
self.record_start = false;
pos += self.quote.len();
}
_ if field || record_len > 0 => {
if self.state == State::FieldStart {
if field || !self.record_start {
output.extend_from_slice(b"\"\"");
}
} else {
output.push(b'"');
}
output.push(if field { b',' } else { b'\n' });
self.state = State::FieldStart;
self.record_start = !field;
pos += if field { self.field.len() } else { record_len };
}
_ => {
if self.state == State::FieldStart {
output.push(b'"');
}
self.state = State::Unquoted;
self.record_start = false;
Self::push_value(&mut output, &rest[..1]);
pos += 1;
}
}
}
self.carry.extend_from_slice(&bytes[pos..]);
if last {
match self.state {
State::Quoted | State::Escaped => return Err(SelectError::CsvParsingError),
State::Unquoted | State::AfterQuote => output.push(b'"'),
State::FieldStart if !self.record_start => output.extend_from_slice(b"\"\""),
State::FieldStart | State::Comment => {}
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn normalize_chunks(csv: &CsvSyntax<'_>, input: &[u8], chunk_size: usize) -> Vec<u8> {
let mut normalizer = CsvInputNormalizer::new(csv);
let mut output = Vec::new();
for chunk in input.chunks(chunk_size) {
output.extend(normalizer.convert(chunk, false).expect("normalize complete CSV input"));
assert!(normalizer.carry.len() < normalizer.token_size, "only a partial token may be retained");
}
output.extend(normalizer.convert(&[], true).expect("finish complete CSV input"));
output
}
#[test]
fn unicode_csv_quotes_preserve_values_at_every_chunk_boundary() {
let cases = [
("ع", "\"", "عcol1ع,عcol2ع,عcol3ع\n", "\"col1\",\"col2\",\"col3\"\n"),
("ع", "\"", "\"left\",tail\n", "\"\"\"left\"\"\",\"tail\"\n"),
("ع", "\"", "عA,Bع,plain\n", "\"A,B\",\"plain\"\n"),
("ع", "\"", "عAععBع,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\"", "عA\"عBع,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\\", "عA\\\"Bع,tail\n", "\"A\"\"B\",\"tail\"\n"),
("\"", "", "\"A界\"B\",\"C\"\n", "\"A\"\"B\",\"C\"\n"),
("🦀", "🦀", "🦀A🦀🦀B🦀,C\n", "\"A🦀B\",\"C\"\n"),
("ع", "\"", "a\0b,عc\0\n", "\"a\0b\",\"c\0d\"\n"),
("ع", "\"", "AعB,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\"", "عaعsuffix,tail\n", "\"asuffix\",\"tail\"\n"),
("ع", "\"", ",\n", "\"\",\"\"\n"),
("ع", "\"", "a,", "\"a\",\"\""),
("ع", "\"", "عع", "\"\""),
("ع", "\"", "\n", "\n"),
("ع", "\"", "", ""),
];
for (quote, escape, input, expected) in cases {
let csv = CsvSyntax {
quote: Some(quote),
escape: Some(escape),
record: Some("\n"),
..Default::default()
};
for chunk_size in 1..=input.len().max(1) {
assert_eq!(
normalize_chunks(&csv, input.as_bytes(), chunk_size),
expected.as_bytes(),
"input={input:?}, chunk_size={chunk_size}"
);
}
}
}
#[test]
fn unicode_csv_quotes_keep_custom_delimiters_and_comments_out_of_values() {
let csv = CsvSyntax {
quote: Some("ع"),
escape: Some("\\"),
field: Some(""),
record: Some("^Y"),
comment: Some(b'#'),
};
let input = "#skipع界^Yعa界bع界\"literal\"^Yعline\nbreakع界end^Y";
let expected = "\"a界b\",\"\"\"literal\"\"\"\n\"line\nbreak\",\"end\"\n";
for chunk_size in 1..=input.len() {
assert_eq!(normalize_chunks(&csv, input.as_bytes(), chunk_size), expected.as_bytes());
}
}
#[test]
fn unicode_csv_quotes_reject_unterminated_fields_and_quoted_record_delimiters() {
for input in ["عunfinished", "عescape\\", "عline\nbreakع\n", "عline\\\nbreakع\n"] {
let csv = CsvSyntax {
quote: Some("ع"),
escape: Some("\\"),
..Default::default()
};
let mut normalizer = CsvInputNormalizer::new(&csv);
assert_eq!(normalizer.convert(input.as_bytes(), true), Err(SelectError::CsvParsingError));
}
}
#[test]
fn unicode_csv_quotes_preserve_omitted_syntax_defaults() {
assert!(!csv_input_requires_normalization(None, None));
assert!(!csv_input_requires_normalization(Some("\""), Some("\\")));
assert!(csv_input_requires_normalization(Some("ع"), None));
assert!(csv_input_requires_normalization(None, Some("")));
let quote_only = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
assert_eq!(
normalize_chunks(&quote_only, "عA\"عBع,tail\r\n".as_bytes(), 1),
"\"AعB\",\"tail\"\n".as_bytes()
);
let escape_only = CsvSyntax {
escape: Some(""),
..Default::default()
};
assert_eq!(
normalize_chunks(&escape_only, "\"A界\"B\",tail\r\n".as_bytes(), 1),
b"\"A\"\"B\",\"tail\"\n"
);
}
#[test]
fn unicode_csv_quotes_stream_large_fields_without_retaining_records() {
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut normalizer = CsvInputNormalizer::new(&csv);
let chunk = vec![b'x'; 64 * 1024];
let mut output_len = normalizer.convert("ع".as_bytes(), false).expect("opening quote").len();
for _ in 0..64 {
let output = normalizer.convert(&chunk, false).expect("stream field chunk");
assert!(output.len() >= chunk.len() - 3, "field data must be emitted before its closing quote");
assert!(normalizer.carry.len() < 4);
output_len += output.len();
}
output_len += normalizer.convert("ع\n".as_bytes(), true).expect("close field").len();
assert_eq!(output_len, chunk.len() * 64 + 3);
}
}
pub(crate) fn normalize_csv_stream<S>(stream: S, csv: &CsvSyntax<'_>) -> BoxStream<'static, Result<Bytes>>
where
S: Stream<Item = Result<Bytes>> + Send + 'static,
{
let mut normalizer = CsvInputNormalizer::new(csv);
AsyncTryStream::<Bytes, Error, _>::new(|mut y| async move {
futures::pin_mut!(stream);
while let Some(chunk) = stream.next().await {
let converted = normalizer.convert(&chunk?, false).map_err(|source| Error::Generic {
store: "EcObjectStore",
source: Box::new(source),
})?;
if !converted.is_empty() {
y.yield_ok(Bytes::from(converted)).await;
}
}
let converted = normalizer.convert(&[], true).map_err(|source| Error::Generic {
store: "EcObjectStore",
source: Box::new(source),
})?;
if !converted.is_empty() {
y.yield_ok(Bytes::from(converted)).await;
}
Ok(())
})
.boxed()
}
#[cfg(test)]
mod stream_tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
struct DropProbe(Arc<AtomicBool>);
impl Drop for DropProbe {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[tokio::test]
async fn unicode_csv_quotes_drop_the_source_without_reading_ahead() {
let polls = Arc::new(AtomicUsize::new(0));
let dropped = Arc::new(AtomicBool::new(false));
let source =
futures::stream::unfold((DropProbe(Arc::clone(&dropped)), Arc::clone(&polls)), |(guard, polls)| async move {
polls.fetch_add(1, Ordering::SeqCst);
Some((Ok(Bytes::from_static("عvalueع\n".as_bytes())), (guard, polls)))
});
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut stream = normalize_csv_stream(source, &csv);
assert!(!stream.next().await.expect("first output").expect("valid CSV").is_empty());
assert_eq!(polls.load(Ordering::SeqCst), 1);
drop(stream);
assert!(dropped.load(Ordering::SeqCst), "cancellation must release the source reader");
assert_eq!(polls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn unicode_csv_quotes_preserve_source_errors_after_partial_output() {
let source = futures::stream::iter([
Ok(Bytes::from_static("عvalueع\n".as_bytes())),
Err(Error::Generic {
store: "fixture",
source: std::io::Error::other("source read failed").into(),
}),
]);
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut stream = normalize_csv_stream(source, &csv);
assert!(!stream.next().await.expect("partial output").expect("valid prefix").is_empty());
let error = stream
.next()
.await
.expect("source failure must remain visible")
.expect_err("must not return a successful tail");
assert!(error.to_string().contains("source read failed"));
assert!(stream.next().await.is_none());
}
}
-2
View File
@@ -23,14 +23,12 @@ use datafusion::{
use std::{error::Error as StdError, fmt::Display};
use thiserror::Error;
mod csv_input;
mod input_stream;
mod metrics;
pub mod object_store;
pub mod query;
pub mod server;
mod storage_api;
pub use csv_input::csv_input_requires_normalization;
pub use metrics::{SelectInputMetrics, SelectInputMetricsSnapshot};
pub use storage_api::SelectObjectSnapshot;
+7 -109
View File
@@ -64,7 +64,6 @@ use tokio::{io::AsyncReadExt, sync::OnceCell};
use tokio_util::io::ReaderStream;
use transform_stream::AsyncTryStream;
use crate::csv_input::{CsvSyntax, csv_input_requires_normalization, normalize_csv_stream};
use crate::storage_api::object_store::HTTPRangeSpec;
mod json_document;
@@ -346,29 +345,6 @@ impl EcObjectStore {
(self.need_convert || (delimiter.len() == 2 && delimiter != NORMALIZED_RECORD_DELIMITER)).then_some(delimiter)
}
fn convert_csv_stream<S>(&self, stream: S) -> BoxStream<'static, Result<Bytes>>
where
S: Stream<Item = Result<Bytes>> + Send + 'static,
{
if let Some(csv) = self.input.request.input_serialization.csv.as_ref()
&& csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
{
let syntax = CsvSyntax {
quote: csv.quote_character.as_deref(),
escape: csv.quote_escape_character.as_deref(),
field: csv.field_delimiter.as_deref(),
record: csv.record_delimiter.as_deref(),
comment: csv.comments.as_ref().and_then(|comment| comment.as_bytes().first().copied()),
};
return normalize_csv_stream(stream, &syntax);
}
convert_csv_delimiter_stream(
stream,
self.record_delimiter_for_conversion(),
self.need_convert.then(|| self.delimiter.clone()),
)
}
fn csv_has_header(&self) -> bool {
self.input
.request
@@ -844,6 +820,7 @@ impl ObjectStore for EcObjectStore {
});
}
let record_delimiter = self.record_delimiter_for_conversion();
let needs_scan_context = options.range.is_none() && has_effective_request_range;
let scan_context = if needs_scan_context {
if let Some(scan_range) = self.scan_range(original_size)? {
@@ -906,7 +883,8 @@ impl ObjectStore for EcObjectStore {
max_processed_bytes,
query_guard,
)?;
let stream = self.convert_csv_stream(stream);
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
GetResultPayload::Stream(stream)
}
} else if options.range.is_some() {
@@ -959,7 +937,8 @@ impl ObjectStore for EcObjectStore {
} else {
stream
};
let stream = self.convert_csv_stream(stream);
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
GetResultPayload::Stream(stream)
} else {
let stream_size = usize::try_from(original_size).map_err(|err| o_Error::Generic {
@@ -969,7 +948,8 @@ impl ObjectStore for EcObjectStore {
let stream = bytes_stream(ReaderStream::with_capacity(reader.stream, SELECT_DEFAULT_READ_BUFFER_SIZE), stream_size);
if meter_input {
let stream = meter_uncompressed_input_stream(stream, Arc::clone(&self.input_metrics));
let stream = self.convert_csv_stream(stream);
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
GetResultPayload::Stream(stream)
} else {
GetResultPayload::Stream(stream.boxed())
@@ -2886,88 +2866,6 @@ mod test {
assert_eq!(input_metrics.snapshot().bytes_processed, 2);
}
#[tokio::test]
async fn unicode_csv_quotes_preserve_raw_offsets_and_metrics() {
const BUCKET: &str = "s3select-unicode-csv-stream";
const HEADER: &str = "عnameع,عkindع\n";
const SKIP: &str = "عskipع,عzeroع\n";
const ROW: &str = "عA,Bع,عAععBع\n";
let data = format!("{HEADER}{SKIP}{ROW}");
let env = crate::storage_api::select_test_ecstore_env().await;
env.make_bucket(BUCKET, false).await;
for (object, compression, range_offset) in [
("plain.csv", None, None),
("range.csv", None, Some(0)),
("range-mid-character.csv", None, Some(1)),
("gzip.csv", Some(CompressionFormat::Gzip), None),
("bzip.csv", Some(CompressionFormat::Bzip2), None),
] {
let bytes = match compression {
Some(format) => encode_compressed_fixture(format, data.as_bytes()).await,
None => data.as_bytes().to_vec(),
};
let raw_size = bytes.len();
let mut reader = SelectPutObjReader::from_vec(bytes);
env.ecstore
.put_object(BUCKET, object, &mut reader, &Default::default())
.await
.expect("write Unicode CSV fixture");
let mut input = (*csv_input(BUCKET, object)).clone();
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
csv.quote_character = Some("ع".to_owned());
csv.quote_escape_character = Some("\\".to_owned());
csv.record_delimiter = Some("\n".to_owned());
input.request.input_serialization.compression_type = compression.map(|format| {
CompressionType::from_static(match format {
CompressionFormat::Gzip => CompressionType::GZIP,
CompressionFormat::Bzip2 => CompressionType::BZIP2,
})
});
let start = HEADER.len() + SKIP.len();
if let Some(range_offset) = range_offset {
let offset = i64::try_from(start + range_offset).expect("fixture offset");
input.request.scan_range = Some(ScanRange {
start: Some(offset),
end: Some(offset),
});
}
let metrics = Arc::new(SelectInputMetrics::default());
let store = EcObjectStore::build_with_snapshot(
Arc::new(input),
Arc::new(GreedyMemoryPool::new(1024 * 1024)),
None,
Arc::clone(&metrics),
prepare_test_snapshot(BUCKET, object).await,
JsonSource::default(),
)
.expect("snapshot store");
let result = store
.get_opts(&Path::from(object), GetOptions::default())
.await
.expect("open Unicode CSV stream");
let GetResultPayload::Stream(stream) = result.payload else { panic!("CSV must remain streaming") };
let output = stream.try_collect::<Vec<_>>().await.expect("normalize CSV stream").concat();
let expected = match range_offset {
Some(0) => "\"name\",\"kind\"\n\"A,B\",\"AعB\"\n",
Some(_) => "\"name\",\"kind\"\n",
None => "\"name\",\"kind\"\n\"skip\",\"zero\"\n\"A,B\",\"AعB\"\n",
};
assert_eq!(output, expected.as_bytes(), "object={object}");
let measured = metrics.snapshot();
if let Some(range_offset) = range_offset {
// The range reader includes one byte of delimiter context and a
// separate header read; offsets always refer to the original CSV.
let processed = u64::try_from(ROW.len() + 1 - range_offset + HEADER.len()).expect("raw range length");
assert_eq!(measured.bytes_scanned, processed);
assert_eq!(measured.bytes_processed, processed);
} else {
assert_eq!(measured.bytes_scanned, u64::try_from(raw_size).expect("raw length"));
assert_eq!(measured.bytes_processed, u64::try_from(data.len()).expect("decoded length"));
}
}
}
#[tokio::test]
async fn compressed_object_uses_one_full_stream_and_rejects_internal_ranges() {
const BUCKET: &str = "s3select-compressed-object";
+1 -25
View File
@@ -456,12 +456,7 @@ impl SessionCtxFactory {
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
let metered_input_requires_single_file_scan =
input_metrics.is_some() && context.input.request.input_serialization.parquet.is_none();
let normalized_csv_requires_single_file_scan =
context.input.request.input_serialization.csv.as_ref().is_some_and(|csv| {
crate::csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
});
let config = if normalized_csv_requires_single_file_scan
|| custom_two_byte_record_delimiter
let config = if custom_two_byte_record_delimiter
|| scan_range_requires_single_file_scan
|| json_document_requires_single_file_scan
|| compressed_input_requires_single_file_scan
@@ -911,25 +906,6 @@ mod tests {
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
}
#[tokio::test]
async fn unicode_csv_quotes_disable_file_scan_repartition() {
let mut context = test_context();
Arc::get_mut(&mut context.input)
.expect("unique context")
.request
.input_serialization
.csv
.as_mut()
.expect("CSV input")
.quote_character = Some("ع".to_owned());
let session = SessionCtxFactory::new(true)
.with_target_partitions(4)
.create_session_ctx(&context)
.await
.expect("Unicode CSV session");
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
}
#[tokio::test]
async fn two_byte_csv_record_delimiter_disables_file_scan_repartition() {
let mut context = test_context();
+23 -139
View File
@@ -53,7 +53,7 @@ use rustfs_s3select_api::{
},
},
};
use s3s::dto::{FileHeaderInfo, JSONType, SelectObjectContentInput};
use s3s::dto::{CompressionType, FileHeaderInfo, JSONType, SelectObjectContentInput};
use std::sync::LazyLock;
use tokio::{
sync::Semaphore,
@@ -430,6 +430,13 @@ impl SimpleQueryDispatcher {
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
let table_path = ListingTableUrl::parse(path)?;
let compressed_input = self
.input
.request
.input_serialization
.compression_type
.as_ref()
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
let mut need_rename_volume_name = false;
@@ -478,27 +485,28 @@ impl SimpleQueryDispatcher {
if let Some(quote) = csv.quote_character.as_ref() {
file_format = file_format.with_quote(quote.as_bytes().first().copied().unwrap_or_default());
}
if rustfs_s3select_api::csv_input_requires_normalization(
csv.quote_character.as_deref(),
csv.quote_escape_character.as_deref(),
) {
file_format = file_format
.with_quote(b'"')
.with_escape(None)
.with_delimiter(b',')
.with_terminator(Some(b'\n'))
.with_comment(None)
.with_newlines_in_values(true);
}
(
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
ListingOptions::new(Arc::new(file_format)).with_file_extension(if compressed_input {
EXACT_OBJECT_FILE_EXTENSION
} else {
".csv"
}),
need_rename_volume_name,
need_ignore_volume_name,
)
} else if self.input.request.input_serialization.json.is_some() {
let file_format = JsonFormat::default();
let file_extension = if compressed_input {
EXACT_OBJECT_FILE_EXTENSION.to_string()
} else {
std::path::Path::new(&self.input.key)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| format!(".{extension}"))
.unwrap_or_else(|| ".json".to_string())
};
(
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
ListingOptions::new(Arc::new(file_format)).with_file_extension(file_extension),
false,
false,
)
@@ -1523,130 +1531,6 @@ mod tests {
assert_eq!(error.select_error(), SelectError::InvalidDataSource);
}
#[tokio::test]
async fn unicode_csv_quotes_reach_arrow_without_changing_field_values() {
let cases = [
("ع", "\"", ",", "\n", "عcol1ع,عcol2ع,عcol3ع\n", vec![vec!["col1", "col2", "col3"]]),
(
"ع",
"\\",
",",
"\n",
"\"literal\",عA\\\"Bع,عAععBع\n",
vec![vec!["\"literal\"", "A\"B", "AعB"]],
),
("\"", "", ",", "\n", "\"A界\"B\",🦀\n", vec![vec!["A\"B", "🦀"]]),
("ع", "\\", "", "^Y", "عa界bع界عline\nbreakع^Y", vec![vec!["a界b", "line\nbreak"]]),
];
let env = snapshot_test_env().await;
for (index, (quote, escape, field, record, data, expected)) in cases.into_iter().enumerate() {
let mut input = test_input();
input.bucket = format!("select-unicode-quotes-{index}");
input.key = "records".to_owned();
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::NONE));
csv.quote_character = Some(quote.to_owned());
csv.quote_escape_character = Some(escape.to_owned());
csv.field_delimiter = Some(field.to_owned());
csv.record_delimiter = Some(record.to_owned());
env.make_bucket(&input.bucket, false).await;
env.put_object_bytes(&input.bucket, &input.key, data.as_bytes().to_vec())
.await;
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
let input = Arc::new(input);
let dispatcher = production_dispatcher(Arc::clone(&input));
let query = Query::new_with_snapshot(QueryContext { input }, "SELECT * FROM S3Object".to_owned(), snapshot);
let output = dispatcher.execute_query(&query).await.expect("execute Unicode CSV query");
let mut stream = output.into_record_batch_stream().expect("record stream");
let mut rows = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.expect("Arrow must receive valid UTF-8 fields");
for row in 0..batch.num_rows() {
rows.push(
batch
.columns()
.iter()
.map(|column| {
column
.as_any()
.downcast_ref::<StringArray>()
.expect("CSV string column")
.value(row)
.to_owned()
})
.collect::<Vec<_>>(),
);
}
}
assert_eq!(rows, expected, "fixture={index}");
}
}
#[tokio::test]
async fn select_uses_input_serialization_independently_of_object_extension() {
for (key, json) in [
("records", false),
("records.bin", false),
("records", true),
("records.csv", true),
] {
let mut input = test_input();
input.key = key.to_owned();
let data = if json {
input.request.input_serialization.csv = None;
input.request.input_serialization.json = Some(s3s::dto::JSONInput {
type_: Some(JSONType::from_static(JSONType::LINES)),
});
b"{\"value\":\"selected\"}\n".as_slice()
} else {
b"value\nselected\n".as_slice()
};
let input = Arc::new(input);
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
let dispatcher = test_dispatcher_for_input(
Arc::clone(&input),
Arc::new(Semaphore::new(1)),
Duration::from_secs(30),
Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))),
);
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_owned());
let machine = dispatcher.build_query_state_machine(query).await.expect("build query state");
let store_url = ObjectStoreUrl::parse("s3://test-bucket").expect("test store URL");
let store = machine
.session
.inner()
.runtime_env()
.object_store(&store_url)
.expect("test store");
store.put(&Path::from(key), data.into()).await.expect("write selected object");
store
.put(&Path::from(format!("{key}.other")), b"unrelated\nwrong\n".as_slice().into())
.await
.expect("write neighboring object");
let plan = dispatcher
.build_logical_plan(Arc::clone(&machine))
.await
.expect("infer schema without an extension filter")
.expect("select plan");
let output = dispatcher
.execute_logical_plan(plan, machine)
.await
.expect("execute selected object");
let mut stream = output.into_record_batch_stream().expect("record stream");
let mut values = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.expect("selected batch");
let column = batch
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::StringArray>()
.expect("string column");
values.extend(column.iter().map(|value| value.expect("selected value").to_owned()));
}
assert_eq!(values, ["selected"], "key={key}, json={json}");
}
}
#[tokio::test]
async fn csv_query_uses_custom_record_delimiter_across_file_partitions() {
const ROW_COUNT: usize = 200_000;
+4 -13
View File
@@ -28,10 +28,10 @@ use metrics::{counter, describe_counter, describe_histogram, histogram};
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
pub use rustfs_data_usage::{
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSegmentInvalidationProof, DataUsageSnapshotSetState,
LEGACY_DATA_USAGE_OBJECT_NAME, PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary,
SizeReconciliationEntry, SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER,
UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSnapshotSetState, LEGACY_DATA_USAGE_OBJECT_NAME,
PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary, SizeReconciliationEntry,
SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP,
UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
};
use rustfs_heal_contracts::heal_channel::HealScanMode;
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
@@ -657,11 +657,6 @@ pub struct DataUsageCacheInfo {
/// structural plan remains reusable across ordinary bucket writes.
#[serde(default)]
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
/// Process-epoch and generation window that produced a complete set cache
/// with all known segment invalidation producers wired. This proof is
/// additive compatibility metadata; absence keeps segment reuse disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
/// Durable bucket incarnations captured for a complete set aggregate.
/// Missing or nil entries are legacy/unproven and cannot authorize
/// skipping an unselected bucket in a later scoped set scan.
@@ -691,7 +686,6 @@ impl Serialize for DataUsageCacheInfo {
+ usize::from(self.lkg_leader_epoch.is_some())
+ usize::from(self.lkg_scan_plan_digest.is_some())
+ usize::from(self.scan_execution_digest.is_some())
+ usize::from(self.segment_invalidation_proof.is_some())
+ usize::from(!self.scan_bucket_incarnations.is_empty());
let mut state = serializer.serialize_map(Some(field_count))?;
state.serialize_entry("name", &self.name)?;
@@ -752,9 +746,6 @@ impl Serialize for DataUsageCacheInfo {
if let Some(scan_execution_digest) = self.scan_execution_digest {
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
}
if let Some(proof) = &self.segment_invalidation_proof {
state.serialize_entry("segment_invalidation_proof", proof)?;
}
if !self.scan_bucket_incarnations.is_empty() {
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
}
@@ -1095,7 +1095,6 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
assert!(!decoded.snapshot_complete);
assert!(decoded.scan_plan_digest.is_none());
assert!(decoded.scan_execution_digest.is_none());
assert!(decoded.segment_invalidation_proof.is_none());
assert_eq!(decoded.cache_key_format, 0);
}
@@ -1184,13 +1183,6 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
segment_invalidation_proof: Some(DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 7,
generation_end: 9,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
}),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
@@ -1220,16 +1212,6 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
assert!(current.info.snapshot_complete);
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
assert_eq!(
current.info.segment_invalidation_proof,
Some(DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 7,
generation_end: 9,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
})
);
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
+10 -160
View File
@@ -39,7 +39,7 @@ use s3s::dto::{
BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration,
};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
@@ -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)]
+1 -6
View File
@@ -464,7 +464,6 @@ pub(super) fn completed_usage_candidate(
scan_plan_digest: Some(result.info.scan_plan_digest?.0),
complete: true,
tombstone: false,
segment_invalidation_proof: result.info.segment_invalidation_proof.clone(),
})
})
.collect::<Option<Vec<_>>>()?;
@@ -643,14 +642,13 @@ pub(super) fn observational_data_usage_info(
let current_snapshot = current.is_some();
let selected = current.or(lkg);
if let Some(selected) = selected {
let (cycle, epoch, digest, last_update, complete, segment_invalidation_proof) = if current_snapshot {
let (cycle, epoch, digest, last_update, complete) = if current_snapshot {
(
Some(selected.info.next_cycle),
Some(selected.info.leader_epoch),
selected.info.scan_plan_digest.map(|digest| digest.0),
selected.info.last_update,
true,
selected.info.segment_invalidation_proof.clone(),
)
} else {
(
@@ -659,7 +657,6 @@ pub(super) fn observational_data_usage_info(
selected.info.lkg_scan_plan_digest.map(|digest| digest.0),
selected.info.lkg_last_update,
false,
None,
)
};
set_states.push(DataUsageSnapshotSetState {
@@ -670,7 +667,6 @@ pub(super) fn observational_data_usage_info(
scan_plan_digest: digest,
complete,
tombstone: false,
segment_invalidation_proof,
});
usable.push((selected, last_update));
} else {
@@ -682,7 +678,6 @@ pub(super) fn observational_data_usage_info(
scan_plan_digest: Some(expected_plan_digest.0),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
});
}
}
+29 -97
View File
@@ -16,16 +16,17 @@ use super::*;
pub(super) static DIRTY_USAGE_BUCKET_GENERATION: AtomicU64 = AtomicU64::new(0);
pub(super) static DIRTY_USAGE_BUCKETS: LazyLock<StdMutex<DirtyUsageBuckets>> = LazyLock::new(|| StdMutex::new(HashMap::new()));
// Lock order when dirty usage state is updated is `DIRTY_USAGE_BUCKETS`,
// `DIRTY_USAGE_BUCKET_SCOPES`, then `DIRTY_USAGE_PRODUCER_IDENTITIES`. All
// guards are held only for synchronous map updates, so no scanner task can
// observe a bucket generation without its matching scope and producer evidence.
// Lock order when both dirty maps are needed is `DIRTY_USAGE_BUCKETS` followed
// by `DIRTY_USAGE_BUCKET_SCOPES`. Both are held only for synchronous map
// updates, so no scanner task can observe a bucket generation without its
// matching scope.
pub(super) static DIRTY_USAGE_BUCKET_SCOPES: LazyLock<StdMutex<DirtyUsageBucketScopes>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
// Non-authoritative process-local producer coverage. Any future segment reuse
// activation must bind this to the exact generation window and durable proof.
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<StdMutex<DirtyUsageProducerIdentities>> =
LazyLock::new(|| StdMutex::new(BTreeMap::new()));
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<
StdMutex<BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>>,
> = LazyLock::new(|| StdMutex::new(BTreeSet::new()));
pub(super) static DIRTY_USAGE_BUCKET_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
pub(super) static SCANNER_ACTIVITY_EPOCH: LazyLock<String> = LazyLock::new(|| format!("{:032x}", rand::random::<u128>()));
pub(super) static SCANNER_MAINTENANCE_GENERATION: AtomicU64 = AtomicU64::new(0);
@@ -58,39 +59,6 @@ pub(super) type DirtyUsageBucketScopes = HashMap<String, DirtyUsageBucketScope>;
const MAX_DIRTY_USAGE_TOP_LEVEL_ENTRIES_PER_BUCKET: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct DirtyUsageProducerIdentityState {
first_generation: u64,
last_generation: u64,
}
pub(super) type DirtyUsageProducerIdentities =
BTreeMap<crate::segment_invalidation::SegmentInvalidationProducerIdentity, DirtyUsageProducerIdentityState>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct DirtyUsageProducerEvidence {
pub(super) producer_identity_coverage_complete: bool,
pub(super) durable_producer_identity: bool,
pub(super) restart_gap_absent: bool,
pub(super) generation_window_bound: bool,
pub(super) generation_start: u64,
pub(super) generation_end: u64,
}
impl DirtyUsageProducerEvidence {
pub(super) fn segment_invalidation_proof(self) -> Option<crate::DataUsageSegmentInvalidationProof> {
(self.generation_window_bound && self.producer_identity_coverage_complete).then(|| {
crate::DataUsageSegmentInvalidationProof {
process_epoch: scanner_activity_epoch().to_string(),
generation_start: self.generation_start,
generation_end: self.generation_end,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: false,
}
})
}
}
/// A point-in-time view of the local dirty bucket generations.
///
/// `complete == false` is an all-or-nothing overflow signal: `buckets` is
@@ -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()
-10
View File
@@ -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 {
+3 -16
View File
@@ -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,
],
+9 -231
View File
@@ -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
View File
@@ -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:
-12
View File
@@ -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.
-1
View File
@@ -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));
+2 -34
View File
@@ -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();
+2 -17
View File
@@ -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",
+2 -12
View File
@@ -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 {
+1 -4
View File
@@ -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
-4
View File
@@ -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) |
+83
View File
@@ -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
View File
@@ -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:
-369
View File
@@ -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())
+1 -1
View File
@@ -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"
-68
View File
@@ -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