Compare commits

..

6 Commits

Author SHA1 Message Date
唐小鸭 8553853761 Merge branch 'fix/replication-target-version-ledger' into fix/replication-check-ledger-probe 2026-09-07 16:45:49 +08:00
唐小鸭 3b62044485 Merge remote-tracking branch 'origin/main' into fix/replication-target-version-ledger 2026-09-07 16:38:04 +08:00
唐小鸭 91fccdcac2 Merge branch 'fix/replication-target-version-ledger' into fix/replication-check-ledger-probe 2026-09-07 16:37:38 +08:00
唐小鸭 6448fa54c4 fix(scanner): drop the unused Digest import
Same one-line change as rustfs/rustfs#7366 (main is red with it under -D warnings); carried here so the stacked PRs' merge commits compile until that fix lands.
2026-09-07 16:37:34 +08:00
唐小鸭 ee73203791 fix(admin): probe replication-check mutations by the assigned version id
On a target that mints its own version ids the DeleteMarker and
VersionDelete phases of ?replication-check were skipped: they addressed
the source id, which such a target never had. The replication worker now
addresses the id the target assigned (the target-version ledger), and
the probe already holds that id from its own PUT, so run both phases
against it. VersionFidelity keeps failing with the mismatch code and the
target stays FAILED; the phases report whether ledger-addressed purges
work against this endpoint (rustfs/backlog#2340).
2026-09-07 16:08:55 +08:00
唐小鸭 46a387dffe fix(replication): resolve drifted replicas via a target version ledger
A replication target that mints its own version ids (Wasabi, AWS S3)
never answers to the source uuid, so every version-addressed mutation
after the initial PUT failed forever: permanent version deletes answered
NoSuchVersion every heal cycle, and tag / retention / legal-hold updates
re-PUT the object, minting one more target version per update
(rustfs/backlog#2340).

Record the id the target assigned as a per-target ledger on the source
version (replication-target-version-<arn>, written through the existing
status writeback) and resolve every later mutation through it: version
deletes DELETE the ledger id, metadata updates go through the
metadata-only Object Lock and tagging APIs. Replicas written before the
ledger existed are located by exact key and ETag, minus the candidates
other generations of the key already claim through their own ledgers; an
ambiguous remainder is refused with a backoff instead of guessed, since a
wrong pick would destroy a live generation. A fresh write never consults
content identity. NoSuchVersion on a version-addressed DELETE counts as
purged.

The fake target gains the Wasabi shape (404 NoSuchVersion on an unknown
id, per-version Object Lock APIs) and the matrix covers the three
mutation classes plus the same-bytes generation case.
2026-09-07 15:40:54 +08:00
249 changed files with 2471 additions and 30482 deletions
@@ -1,33 +0,0 @@
# Adversarial Review Shape
Use when root `AGENTS.md` triggers adversarial validation or for a substantial PR
review. Paths below are repository-relative. The root finding standard and
completion rule apply; selecting a lens does not require finding a defect.
Risk and review shape:
- **Exempt:** documentation, comments, formatting, or typos with no runtime,
build, test, or agent-execution effect.
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
changes. Run correctness and simplicity lenses.
- **Standard:** localized behavior changes. Run one integrated final-diff pass
covering correctness, simplicity, and test coverage; add only domain lenses
matched by the diff.
- **High risk / substantial PR review:** high risk includes locking,
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
explicit adversarial request, a high-risk change, or a substantial PR review;
then read only its matching role references. A routine standard pass does not
load the playbook unless the reviewer needs a RustFS-specific probe.
-65
View File
@@ -1,65 +0,0 @@
# Implementation Rules
Applies when changing code or running artifact-heavy work. Paths below
are repository-relative. Read only the relevant sections during read-only review.
## Worktree and Disk Hygiene
- Start implementation from the latest `origin/main` and confirm the requested
change is not already present.
- An existing clean, isolated task worktree is sufficient. Create another
worktree only when the current checkout is shared, dirty with unrelated work,
or belongs to another task.
- Never commit from a shared checkout.
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
- Do not include agent, tool, contributor, account, or organization names in
branch names.
- Push to the user-requested remote or the repository's configured push remote.
Do not hard-code or infer a remote from an account name.
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
Re-check before a broad gate when space is tight.
- Remove only task-owned temporary/build artifacts. Never delete another task's
worktree or uncommitted data.
- At handoff, mention disk or cleanup details only when they affected execution
or artifacts/worktrees remain intentionally.
## Change Style
- Preserve existing control flow unless changing it is required for correctness.
- Prefer a direct local edit over new files, wrappers, managers, or speculative
abstractions.
- Add a helper only when it removes current duplication, names a real domain
boundary, or isolates a non-trivial invariant.
- Remove an in-scope path superseded by the change. If compatibility requires it,
adapt at the boundary to one canonical core and use the repository's
`RUSTFS_COMPAT_TODO` policy.
- Comments explain non-obvious invariants or reasons. Do not narrate code or
record change history.
- Mention unrelated problems when useful; do not fix them in a narrow task.
## Reuse and Boundary Rules
- Before adding helpers, constants, fixtures, or wrappers, search the touched
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
direct dependencies.
- Reuse requires matching semantics: normalization, error types, deadlines,
durability, and compatibility must fit the call site. A narrowly named local
helper is better than forced reuse with different semantics.
- Validate untrusted input at its trust boundary, then trust the validated type.
Values crossing disk, RPC, persistence, or version boundaries remain
untrusted at every consumer.
- Re-check boundary values immediately before destructive actions such as
delete, overwrite, or quorum decisions.
- Every new branch needs a concrete triggering input/state. For decoded or peer
data, corruption and mixed-version input are valid triggers.
- Required values must return a typed error when absent or corrupt; do not use a
default that converts corruption into a plausible result.
- Attach error context once where it is actionable. Do not erase typed errors
below aggregation or quorum layers.
## Naming
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
functions/variables, and `PascalCase` types. Do not rename unrelated existing
violations.
-51
View File
@@ -1,51 +0,0 @@
# Git and Pull Request Rules
Applies to commits, pushes, PRs, and issue/discussion actions. Paths below are
repository-relative. User authorization and root `AGENTS.md` still govern scope.
## Final PR Preflight
Before creating or updating a PR, reuse completed review and verification:
- Verify the actual base (normally `origin/main`) and the complete task diff,
including file names and whitespace. Exclude secrets, logs, generated
artifacts, and unrelated edits. Retain an existing PR's base unless requested.
- Confirm the final diff passed the root verification tier; fix task-owned
failures and run missing scoped checks. Report unresolved required checks or
authority without expanding the task. Do not start another general review.
- Keep the English Conventional Commit title at most 72 characters. Use the
template headings, actual checks, material risks, and rollback notes.
- Immediately before writing to GitHub, confirm the head and task diff are
unchanged. Rerun only checks invalidated by edits or relevant state changes.
## Pull Request Lifecycle
- Creating or updating a PR includes one immediate snapshot of checks,
mergeability, reviews, and unresolved threads.
- Unless the user explicitly requests monitoring, a release workflow requires
it, or an automation already owns it, hand off after the PR is open with the
current state and next event to watch. Do not delay ordinary handoff with
fixed quiet-period sleeps.
- For requested monitoring, use event-driven or bounded waits. Report only state
changes, actionable failures, or a meaningful prolonged delay.
- Investigate failures/comments before changing code. Fix task-attributable
issues, rerun affected verification, push, reply or resolve the thread, then
resume the requested monitor.
- Never merge without required reviewer approval or explicit authority.
- After an observed merge, verify the commit reached the base, then clean the
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
deletion was explicitly authorized.
## Git and PR Baseline
- Follow Conventional Commits; keep the subject at most 72 characters.
- Source comments, commits, PR titles, and PR bodies are in English.
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
needed and include commands actually run.
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
- PR/issue/discussion content must not contain the literal sequence `\n` or
hard-wrapped prose paragraphs.
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
content.
- Resolve review threads after the underlying issue is fixed. If declining a
suggestion, reply with a short evidence-based reason.
+8 -11
View File
@@ -1,11 +1,11 @@
--- ---
name: adversarial-validation name: adversarial-validation
description: Review RustFS diffs or designs for explicit adversarial requests, high-risk changes under the repository review policy, or substantial PR reviews. Skip ordinary questions, diagnosis, planning, status, routine low-risk implementation, and prose with no execution effect. description: Review a final RustFS diff adversarially when the user requests adversarial review, the root AGENTS.md classifies the change as high risk, or a substantial PR is being reviewed. Do not use for ordinary questions, diagnosis, planning, status, documentation-only work, or routine low-risk implementation.
--- ---
# RustFS Adversarial Validation # RustFS Adversarial Validation
Use the [repository risk tiers and review shape](../../references/adversarial-validation.md). This skill Use the risk tier and review shape defined in the root `AGENTS.md`. This skill
routes a review to RustFS-specific probes without loading unrelated domains. routes a review to RustFS-specific probes without loading unrelated domains.
## Select Lenses ## Select Lenses
@@ -31,18 +31,15 @@ adversarial review.
## Review Protocol ## Review Protocol
1. Freeze the exact final diff/head (or the design under review) and list the 1. Freeze the exact final diff/head and list the selected lenses.
selected lenses. 2. Run the review shape required by root `AGENTS.md`.
2. Run the review shape required by the repository risk tier.
3. For each selected lens, either report a concrete finding or a null verdict 3. For each selected lens, either report a concrete finding or a null verdict
naming the attacks performed. naming the attacks performed.
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers, 4. A finding needs `file:line`, a triggering input/state/interleaving, the wrong
existing coverage, and invariants before accepting it; an adversarial role outcome, and a focused fix or missing regression check.
does not have to produce a defect. 5. Fix or rebut every finding with code-path, test, or invariant evidence.
5. Fix or rebut supported findings with code-path, test, or invariant evidence.
6. After a non-trivial edit, rerun only lenses affected by that edit against the 6. After a non-trivial edit, rerun only lenses affected by that edit against the
new exact diff. new exact diff.
Do not turn a null verdict into a long checklist. Record concise evidence that Do not turn a null verdict into a long checklist. Record concise evidence that
the relevant failure classes were attacked, then stop under the root completion the relevant failure classes were attacked.
rule. Keep the required per-lens verdicts for high-risk PRs.
@@ -3,10 +3,9 @@
- For every behavior claim, name the focused test/check that fails if the - For every behavior claim, name the focused test/check that fails if the
changed hunk is reverted. If none is practical, require the reason and changed hunk is reverted. If none is practical, require the reason and
residual risk. residual risk.
- Confirm tests exercise the real production path and distinguish the intended - Confirm tests exercise the real production path and assert returned values,
behavior from the named regression. A success, `is_err()`, or no-panic check exact bytes, stored state, or the specific error variant—not only success,
can be sufficient when that is the actual contract; require exact values, `is_err()`, or no panic.
bytes, state, or error variants when those distinctions matter to the change.
- For new flags/modes, verify each branch and ask which test fails if the branch - For new flags/modes, verify each branch and ask which test fails if the branch
is inverted. is inverted.
- For new error propagation, inject the failure and assert the caller observes - For new error propagation, inject the failure and assert the caller observes
+4 -6
View File
@@ -1,13 +1,12 @@
--- ---
name: arch-checks name: arch-checks
description: Diagnose failures from check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_unsafe_code_allowances.sh, check_logging_guardrails.sh, check_doc_paths.sh, or check_no_planning_docs.sh. Use when one of these guards fails, not for every architecture question or documentation edit. description: Resolve failures from the repository's architecture guard scripts — check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_unsafe_code_allowances.sh, check_logging_guardrails.sh, check_doc_paths.sh. Use when make pre-commit / pre-pr or CI fails on one of these checks.
--- ---
# Architecture Guard Checks # Architecture Guard Checks
Read only the section for the failing guard. Use `.config/make/` and the current All five run in `make pre-commit` / `make pre-pr` and in CI. Fix the cause;
workflow to verify its wiring; not every guard is part of every gate. Fix the never weaken a check to get green.
cause and rerun the failed guard; never weaken a check to get green.
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src` ## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
@@ -55,8 +54,7 @@ Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
Markdown file under `docs/` (architecture, operations, testing, index) must not Markdown file under `docs/` (architecture, operations, testing, index) must not
reference repo file paths that no longer exist. If your refactor moved code, reference repo file paths that no longer exist. If your refactor moved code,
update the docs that point at it — the error message lists `doc -> stale-path` update the docs that point at it — the error message lists `doc -> stale-path`
pairs. In durable docs, cite paths plus symbol names rather than line numbers pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
(see `docs/architecture/README.md`). Review findings still need `file:line`.
## `check_no_planning_docs.sh` ## `check_no_planning_docs.sh`
@@ -8,11 +8,19 @@ description: Review a commit, PR, or merged patch when the user requests ordinar
Use this skill for an ordinary requested review. If the root policy or user calls Use this skill for an ordinary requested review. If the root policy or user calls
for adversarial validation, use `adversarial-validation` instead of running both. for adversarial validation, use `adversarial-validation` instead of running both.
## Quick Start
1. Read the scope: commit, PR, patch, or file list.
2. Map each changed area by risk and user impact.
3. Inspect each risky change in context.
4. Report findings first, ordered by severity.
5. Close with residual risks and verification recommendations.
## Core Workflow ## Core Workflow
### 1) Scope and assumptions ### 1) Scope and assumptions
- Derive the change source, target branch, and relevant runtime/version from the - Confirm change source (diff, commit, PR, files), target branch, language/runtime, and version.
supplied diff and metadata. Ask only when missing context could change the verdict. - If context is missing, state assumptions before deeper analysis.
- Focus only on requested scope; avoid reviewing unrelated files. - Focus only on requested scope; avoid reviewing unrelated files.
### 2) Risk map ### 2) Risk map
@@ -32,20 +40,43 @@ for adversarial validation, use `adversarial-validation` instead of running both
- unchecked assumptions and null/empty/error-path handling - unchecked assumptions and null/empty/error-path handling
- stale tests, fixtures, and configs - stale tests, fixtures, and configs
- hidden coupling to shared helpers/constants/features - hidden coupling to shared helpers/constants/features
- Apply root `AGENTS.md`'s finding standard: try to disprove a candidate before - If a point is uncertain, mark it as an open question instead of guessing.
reporting it. Mention an unresolved question only when it could materially
change the verdict; do not fill the report with speculative possibilities.
#### Rust-specific checks #### Rust-specific checks (apply to all Rust changes)
For changed Rust behavior, use the matching sections of [rust-code-quality](../rust-code-quality/SKILL.md). Reuse checks already performed by the selected review workflow. Comment-only or formatting-only Rust diffs do not require the full Rust checklist. Carry its P0P3 ratings over unchanged and use this skill's output format. Run the full checklist in [rust-code-quality](../rust-code-quality/SKILL.md) — the canonical Rust review checklist for the unwrap/casting/cloning/locking/recursion/error-type/serde/test rules and the reuse-and-necessity checks (duplicated helpers, defensive branches without a nameable trigger, redundant error wrapping). Do not restate those rules here; carry its P0P3 ratings over unchanged and use this skill's output format.
### 4) Findings-first output ### 4) Findings-first output
- Order supported findings by P0P3 severity; preserve the Rust ratings above. - Order findings by severity:
Include `path:line`, the failure and impact, a focused fix, and its validation. - P0: critical failure, security breach, or data loss risk
- If no supported issues remain, state `No findings` with the reviewed scope and - P1: high-impact regression
any material verification limitation. Do not append optional improvements to - P2: medium risk correctness gap
make a clean review look productive. - P3: low risk/quality debt
- For each finding include:
- Severity
- `path:line` reference
- concise issue statement
- impact and likely failure mode
- specific fix or mitigation
- validation step to confirm
- If no issues exist, explicitly state `No findings` and why.
Close after the required review. Recommend additional verification only for an ### 5) Close
identified unresolved risk or required gate; reuse evidence for unchanged code. - Report assumptions and unknowns.
- Suggest targeted checks (tests, canary checks, logs/metrics, migration validation).
## Output Template
1. Findings
2. No findings (if applicable)
3. Assumptions / Unknowns
4. Recommended verification steps
## Finding Template
- `[P1] Missing timeout for downstream call`
- Location: `path/to/file.rs:123`
- Issue: ...
- Impact: ...
- Fix suggestion: ...
- Validation: ...
+32 -28
View File
@@ -1,6 +1,6 @@
--- ---
name: issue-triage name: issue-triage
description: Assess whether a GitHub issue is fixed, needs implementation, or can be closed by checking related work and current code. Use for issue completion/triage requests. Status questions are read-only; comment, close, or change labels only when the conversation authorizes that action. description: Triage a GitHub issue — determine if it is already fixed, needs implementation, or should be closed. Searches related commits and PRs, verifies implementation status, and posts a triage comment or closes the issue. Use when the user provides an issue URL and asks whether it can be closed or needs work.
--- ---
# Issue Triage # Issue Triage
@@ -20,8 +20,6 @@ Read the issue body to understand what was requested. Extract:
- Any linked PRs or commits mentioned in the body or comments. - Any linked PRs or commits mentioned in the body or comments.
- Any checklist items or sub-issues. - Any checklist items or sub-issues.
Resolve the issue repository and implementation repository separately (for example, `rustfs/backlog` tracks work in `rustfs/rustfs`). Pass the implementation repository explicitly to PR queries; the current checkout may belong to another repository.
### 2. Search for related work ### 2. Search for related work
Search git history for commits referencing the issue: Search git history for commits referencing the issue:
@@ -31,29 +29,26 @@ git log --oneline --all --grep="<N>" | head -30
Search for related PRs: Search for related PRs:
```bash ```bash
gh pr list --repo <implementation-repo> --search "<issue-url>" --state all --json number,title,state,mergedAt gh pr list --search "fixes #<N> OR closes #<N> OR #<N>" --state all --json number,title,state,mergedAt
``` ```
Also search qualified issue references and subject keywords; for same-repository
issues, include `#<N>`. Follow explicit links even without a text match. A search
page with no match does not prove the work is absent.
If the issue mentions specific PRs, check their status: If the issue mentions specific PRs, check their status:
```bash ```bash
gh pr view <PR_N> --repo <implementation-repo> --json state,mergedAt,title,mergeCommit,baseRefName gh pr view <PR_N> --json state,mergedAt,title
``` ```
### 3. Verify implementation ### 3. Verify implementation
Fetch the implementation repository's current base branch. For each merged candidate, verify its merge commit is present and inspect the current code for the claimed behavior; a commit message match alone is not proof: For each linked or related PR that is merged, verify the fix is actually present on the current main branch:
```bash ```bash
git fetch <implementation-remote> <base-branch> git log --oneline main | grep -i "<keyword>"
git merge-base --is-ancestor <merge-commit> <implementation-remote>/<base-branch> # or
git log --oneline main --grep="<PR_N>"
``` ```
If the issue describes a specific defect, inspect the fetched base's code rather than assuming the current checkout contains it: If the issue describes a specific defect, check the relevant code to confirm the fix is in place:
```bash ```bash
git show <implementation-remote>/<base-branch>:crates/<relevant>/src/<file>.rs grep -n "<pattern>" crates/<relevant>/src/<file>.rs
``` ```
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too: For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
@@ -63,15 +58,13 @@ gh issue view <SUB_N> --repo <owner/repo> --json state
### 4. Determine verdict ### 4. Determine verdict
- **All items fixed and merged**: Recommend closing; name the verified PRs and behavior. - **All items fixed and merged**: Close with a summary comment listing what was fixed and which PRs.
- **Some items fixed, some remaining**: Keep open; report each remaining item. - **Some items fixed, some remaining**: Comment with status of each item. Do not close.
- **Not yet implemented**: Keep open; report what remains. - **Not yet implemented**: Comment with a summary of what remains. Do not close.
- **Superseded or no longer relevant**: Recommend closing with evidence. - **Superseded or no longer relevant**: Close with explanation.
### 5. Take action ### 5. Take action
For a status-only request, return the assessment without GitHub writes. If commenting, closing, or label edits are authorized, perform only those actions; do not ask again for authority already given. Prepare the final assessment before asking for any missing authority. Write `rustfs/backlog` issue content in Chinese.
Close with comment: Close with comment:
```bash ```bash
gh issue close <N> --repo <owner/repo> --comment "<body>" gh issue close <N> --repo <owner/repo> --comment "<body>"
@@ -82,9 +75,9 @@ Comment without closing:
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
``` ```
Update labels only when label changes are authorized, using existing repository labels; never add tool-specific labels: Update issue labels if needed:
```bash ```bash
gh issue edit <N> --repo <owner/repo> --add-label "<existing-label>" gh issue edit <N> --repo <owner/repo> --add-label "completed" --remove-label "needs-triage"
``` ```
Always use `--body-file` for multiline content, never inline `--body`. Always use `--body-file` for multiline content, never inline `--body`.
@@ -92,16 +85,27 @@ Always use `--body-file` for multiline content, never inline `--body`.
### 6. Handle multi-issue batches ### 6. Handle multi-issue batches
When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"): When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"):
1. List the full requested scope with pagination (for example `gh api --paginate 'repos/<repo>/issues?state=open&per_page=100'`, excluding entries with `pull_request`). Add an author filter only when the user requested one; the default page/limit is not evidence that all issues were checked. 1. List the issues: `gh issue list --repo <repo> --author <user> --state open --json number,title,updatedAt`
2. For each issue, run steps 1-5 above. 2. For each issue, run steps 1-5 above.
3. Report a summary table of all triaged issues with verdicts. 3. Report a summary table of all triaged issues with verdicts.
## Report ## Output format
Identify the issue and current state, verified implementation/PR evidence, ### Issue Triage: #<N> — <title>
remaining items, verdict, and action actually taken. Use a table for batches;
a single issue does not require a heading for each field. Follow step 4's **State**: OPEN / CLOSED
verdicts without repeating the assessment in another template. **Linked PRs**: <list with merge status>
#### Assessment
<what was requested vs what is implemented>
#### Verdict
- Close — all items resolved by <PR list>
- Keep open — <remaining items>
- Not started — <what needs to be done>
#### Action taken
- Closed with comment / Commented / No action
## Notes ## Notes
@@ -1,6 +1,6 @@
--- ---
name: plugin-contract-guard name: plugin-contract-guard
description: Guard changes to target-plugin manifests, extension schemas, admin catalog/instance contracts, secret redaction, and external-plugin install policy. Use when a diff changes those contracts in crates/targets, crates/extension-schema, or admin plugin/extension handlers; path membership alone, comments, and unrelated runtime internals do not trigger it. description: Invariants and change procedure for the target-plugin / extension system — plugin manifests, admin plugin/extension catalog and instance APIs, secret redaction, external-plugin install policy. Use when editing crates/targets (manifest, plugin, control_plane, catalog, runtime), crates/extension-schema, or rustfs/src/admin plugin_contract.rs / plugins_*.rs / extensions.rs / target_descriptor.rs.
--- ---
# Plugin & Extension Contract Guard # Plugin & Extension Contract Guard
@@ -0,0 +1,46 @@
---
name: pr-creation-checker
description: Perform the final RustFS PR preflight and draft compliant English title/body metadata immediately before creating or updating a PR. Do not use during implementation or as a second general code review.
---
# PR Creation Checker
Use this skill only at the PR boundary. Reuse completed diff review and
verification evidence; do not reread the repository or rerun equivalent checks.
## Preflight
1. Confirm the branch is based on current `origin/main` and contains only the
intended task diff.
2. Inspect `git diff --stat`, `git diff --check`, and changed file names for
secrets, logs, generated artifacts, or unrelated edits.
3. Confirm the checks selected by root `AGENTS.md` passed on the final diff.
Do not replace focused behavioral tests with a generic gate or rerun checks
already covered by an unchanged umbrella run.
4. Read `.github/pull_request_template.md`. Consult `Makefile`, `.config/make/`,
or CI only when the required command/current gate is uncertain.
5. Return `BLOCKED` for an unclean scope, missing required evidence, failed
required checks, or non-compliant metadata.
## Metadata
- Title: English Conventional Commit, at most 72 characters, with no tool
prefix.
- Body: English, exact template headings, `N/A` where needed, concise rationale,
actual verification commands, and material risks/rollback notes.
- Use repository-relative paths; never include local absolute paths.
- Keep prose paragraphs on one logical line and never include the literal
sequence `\n`.
- Use a temporary body file with `gh pr create --body-file` or
`gh pr edit --body-file`; never pass multiline Markdown inline.
## Output
- Status: `READY` or `BLOCKED`.
- Title.
- Complete PR body.
- Verification commands and results.
- Risks or `N/A`.
Immediately before the GitHub write, repeat only the five preflight checks above
against the final head.
@@ -0,0 +1,4 @@
interface:
display_name: "PR Creation Checker"
short_description: "Draft RustFS-ready PRs with checks, template, and blockers."
default_prompt: "Use $pr-creation-checker for final PR preflight and compliant English title/body metadata."
+84 -30
View File
@@ -1,93 +1,147 @@
--- ---
name: pr-review name: pr-review
description: Review a GitHub PR from a URL or number using its actual base/head and risk-appropriate code review. Use when the user asks for a PR review, not a status lookup or PR wording edit. Publish a review only when authorized; delegation and monitoring follow the requested scope and root AGENTS.md. description: Review a GitHub PR end-to-end from a URL or number — fetch metadata, inspect the diff, run multi-role adversarial review, check CI status, and post the review comment. Use when the user provides a PR link and asks to review it.
--- ---
# PR Review # PR Review
Use this skill for PR context and review delivery. An ordinary review request is read-only unless the conversation also authorizes posting or fixes. Reuse that authorization without asking again; prepare the review before requesting any missing publication approval. Use this skill when the user provides a GitHub PR URL or number and asks to review it. This covers the full review lifecycle: data gathering, code review, CI verification, and posting the result.
## Prerequisites ## Prerequisites
- Follow root `AGENTS.md`; classify risk with the [review policy](../../references/adversarial-validation.md) and consult relevant [change-style and boundary rules](../../references/implementation.md). - Read `AGENTS.md` for the repository's adversarial validation policy and change-style rules.
- Select `code-change-verification` for ordinary review or `adversarial-validation` for explicitly adversarial, substantial, or high-risk review; do not run both on the same diff. - The `adversarial-validation` skill handles the review role playbooks; this skill orchestrates the workflow around it.
## Workflow ## Workflow
### 1. Gather PR context ### 1. Gather PR context
```bash ```bash
gh pr view <N> --repo <owner/repo> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName,baseRefOid,headRefOid gh pr view <N> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName
gh pr diff <N> --repo <owner/repo> --name-only gh pr diff <N> --name-only
``` ```
Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too: Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too:
```bash ```bash
gh issue view <ISSUE> --repo <issue-owner/repo> --json title,body,state gh issue view <ISSUE> --json title,body,state
``` ```
### 2. Fetch the diff and classify the change ### 2. Fetch the diff and classify the change
```bash ```bash
git fetch <repo-remote> <baseRefName> refs/pull/<N>/head git fetch origin pull/<N>/head:pr-<N>
git diff <baseRefOid>...<headRefOid> --stat git diff main...pr-<N> --stat
``` ```
Resolve `<repo-remote>` to the PR repository; do not assume the current checkout's `origin` or `main` matches. Record the exact base/head used. If either moved during fetching, refresh the snapshot before reviewing. Classify using the repository review policy; instruction changes that affect agent execution are mechanical, not exempt. Classify the change by risk tier (per AGENTS.md):
- **Exempt**: docs/comments/instruction-only, formatting, typos.
- **Mechanical**: renames, file moves, test-only or tooling changes.
- **Standard** (default): any behavior change.
- **High risk**: locking, erasure coding, quorum/heal, replication, multipart, RPC, lifecycle/tiering, metadata formats, persistence/fsync, IAM/KMS/auth, on-disk/on-wire formats, S3 API-visible behavior.
### 3. Review the changed behavior ### 3. Cluster changed files and delegate review
Group files by functional area to trace callers and invariants. Use the root risk tier's review shape and only matching lenses. File count does not authorize delegation. When delegation is explicitly authorized, high-risk/substantial reviews use exactly two independent reviewers with the applicable lenses split between them; otherwise use two fresh sequential passes. Reviewers do not spawn further agents. Group the changed files into logical clusters (by crate or functional area). For each cluster, spawn a subagent with a focused review prompt that includes:
- The cluster's changed files and their diffs.
- The applicable adversarial role probes (from the `adversarial-validation` skill).
- The repository's AGENTS.md rules relevant to that domain.
Findings need a concrete failure scenario with `file:line`; a null verdict briefly names the relevant probes. Reuse existing evidence and choose local checks from the final diff under the root verification policy. For standard-tier changes: correctness adversary + simplicity adversary + test-coverage skeptic, plus every role whose domain the diff touches.
For high-risk changes: run all seven roles.
Each subagent must produce findings (concrete failure scenario with file:line) or a null report ("attacked X, Y, Z — no break found").
### 4. Check CI status ### 4. Check CI status
```bash ```bash
gh pr checks <N> --repo <owner/repo> gh pr checks <N>
``` ```
Investigate a failed check when it bears on a finding or the user requested CI diagnosis/merge readiness: If any checks fail, investigate:
```bash ```bash
gh run view --repo <owner/repo> --log-failed --job=<JOB_ID> gh run view --log-failed --job=<JOB_ID>
``` ```
Use current evidence to distinguish pre-existing, flaky, and PR-caused failures. Do not classify them by guesswork or turn a code-only review into unrelated CI repair. Determine whether failures are pre-existing (on main), flaky, or caused by the PR.
### 5. Synthesize findings ### 5. Synthesize findings
Report the PR, reviewed base/head, and risk tier, then summarize the assessment. Combine all subagent findings into a structured review:
Use the selected review's P0P3 ratings and root finding standard: supported - **Summary**: one-paragraph overview of the change and overall assessment.
findings with `file:line`, failure scenario, and fix, or `No findings`. - **Findings**: each finding with severity (critical/major/minor/nit), file:line, concrete failure scenario, and suggested fix.
State the observed check status, including pending or unavailable checks, and - **CI status**: pass/fail with notes on any failures.
the verdict (`APPROVE`, `REQUEST_CHANGES`, or `COMMENT`). Do not infer a pass - **Verdict**: APPROVE, REQUEST_CHANGES, or COMMENT.
from missing checks or add style nits to populate a clean review.
### 6. Post the review ### 6. Post the review
Only when posting is authorized, write the review body to a temp file and post via CLI. Refresh the PR head first; if it changed, review the delta and update the verdict before posting: Write the review body to a temp file and post via CLI:
```bash ```bash
# Request changes # Request changes
gh pr review <N> --repo <owner/repo> --request-changes --body-file /tmp/pr_review.md gh pr review <N> --request-changes --body-file /tmp/pr_review.md
# Approve # Approve
gh pr review <N> --repo <owner/repo> --approve --body-file /tmp/pr_review.md gh pr review <N> --approve --body-file /tmp/pr_review.md
# Comment only (no verdict) # Comment only (no verdict)
gh pr review <N> --repo <owner/repo> --comment --body-file /tmp/pr_review.md gh pr review <N> --comment --body-file /tmp/pr_review.md
``` ```
For authorized inline comments, use [the submission example](references/posting.md). For inline comments on specific lines, use the GitHub API:
```bash
cat > /tmp/pr_review.json <<'EOF'
{
"body": "review body",
"event": "REQUEST_CHANGES",
"comments": [
{
"path": "crates/foo/src/bar.rs",
"line": 42,
"body": "finding description"
}
]
}
EOF
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
```
Always use `--body-file` or `--input`, never inline multiline `--body`. Always use `--body-file` or `--input`, never inline multiline `--body`.
### 7. Handle follow-up ### 7. Handle follow-up
Follow the [PR lifecycle](../../references/pull-requests.md) and any explicit monitoring request. For follow-up, fetch the new head and compare the recorded reviewed SHA with the new SHA; revisit affected callers and findings. Never use an unfetched `origin/pull/<N>/head` ref as evidence. Update the posted review or resolve addressed threads only within existing authorization. If the review requests changes:
- Monitor for new commits: `gh pr view <N> --json commits`
- Re-review changed files only: `git diff pr-<N>..origin/pull/<N>/head`
- Update the review when findings are addressed.
If CI was failing due to pre-existing main breakage:
- Comment on the PR noting the failure is pre-existing.
- Suggest updating the branch: `gh pr update-branch <N>`
## Output format
### PR Review: #<N> — <title>
**Author**: <author>
**Risk tier**: exempt | mechanical | standard | high-risk
**Changed files**: <count> across <cluster count> clusters
#### Summary
<one-paragraph overview>
#### Findings
| Severity | Location | Finding |
|----------|----------|---------|
| critical | file:line | concrete failure scenario |
#### CI Status
- All checks pass / Failing: <details>
#### Verdict
APPROVE / REQUEST_CHANGES / COMMENT
## Notes ## Notes
- The user may ask for review in Chinese; respond in the same language but keep the review body in English per AGENTS.md rules. - The user may ask for review in Chinese; respond in the same language but keep the review body in English per AGENTS.md rules.
- When the user asks for "多角色对抗 review", run the full adversarial validation protocol — this skill's step 3 covers that. - When the user asks for "多角色对抗 review", run the full adversarial validation protocol — this skill's step 3 covers that.
- If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes. - If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes.
- For very large PRs, batch the review by functional area while keeping the same bounded review shape. - For very large PRs (>50 files), cluster aggressively and delegate in parallel to keep review time reasonable.
@@ -1,22 +0,0 @@
# Inline PR Review Submission
Read only when an inline review is authorized. Recheck the PR head before posting and bind the review to the reviewed commit.
For inline comments on specific lines, use the GitHub API:
```bash
cat > /tmp/pr_review.json <<'EOF'
{
"commit_id": "<reviewed-head-sha>",
"body": "review body",
"event": "REQUEST_CHANGES",
"comments": [
{
"path": "crates/foo/src/bar.rs",
"line": 42,
"body": "finding description"
}
]
}
EOF
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
```
+23 -16
View File
@@ -1,6 +1,6 @@
--- ---
name: rust-code-quality name: rust-code-quality
description: Run a focused Rust quality review when the user requests one or a selected review workflow needs Rust-specific checks for changed behavior. Do not auto-load for every implementation edit, comment-only or formatting-only Rust diff, or repeat an already completed review. description: Run a focused Rust quality review when the user requests one, when reviewing a Rust PR/commit, or when another selected review workflow delegates Rust-specific checks. Do not auto-load for every implementation edit.
--- ---
# Rust Code Quality Gate # Rust Code Quality Gate
@@ -8,18 +8,12 @@ description: Run a focused Rust quality review when the user requests one or a s
Use this skill for a dedicated Rust review to cover rules that `cargo clippy` Use this skill for a dedicated Rust review to cover rules that `cargo clippy`
does not catch. does not catch.
Search matches and checklist items are candidates, not findings. Apply the root
finding standard; distinguish a demonstrated bug, an explicit rule violation,
and an optional preference. P2/P3 suggestions do not need to be invented or
included in an otherwise clean correctness review.
## Quick Start ## Quick Start
1. Identify changed `.rs` files. 1. Identify changed `.rs` files.
2. Run the matching candidate searches on changed files. 2. Run automated checks on changed files.
3. Apply the manual checklist sections whose behavior the diff touches. 3. Run manual review checklist on the diff.
4. Report or rebut every finding with evidence; P0/P1 findings block approval. 4. Resolve or rebut every finding with evidence; P0/P1 findings cannot be deferred.
Fix them when implementation is authorized; a read-only review reports them.
## Automated Checks ## Automated Checks
@@ -41,7 +35,7 @@ rg -n 'Result<.*String>' <changed-files>
rg -n 'Box<dyn.*Error' <changed-files> rg -n 'Box<dyn.*Error' <changed-files>
# 5. println/eprintln in production # 5. println/eprintln in production
rg -n 'println!|eprintln!' <changed-files> rg -n 'println!\|eprintln!' <changed-files>
# 6. Ordering::Relaxed usage (verify each is intentional) # 6. Ordering::Relaxed usage (verify each is intentional)
rg -n 'Ordering::Relaxed' <changed-files> rg -n 'Ordering::Relaxed' <changed-files>
@@ -86,7 +80,7 @@ For the Rust diff under review, verify:
- [ ] Test volume and line count are never treated as production-code growth - [ ] Test volume and line count are never treated as production-code growth
### Serde ### Serde
- [ ] Structs from untrusted input reject unknown fields where the compatibility contract permits; otherwise validate security-critical fields explicitly and test the supported input shape - [ ] Structs from untrusted input have `#[serde(deny_unknown_fields)]`
- [ ] `#[serde(default)]` not used on security-critical fields without validation - [ ] `#[serde(default)]` not used on security-critical fields without validation
### Code Hygiene ### Code Hygiene
@@ -110,7 +104,20 @@ For the Rust diff under review, verify:
## Output Template ## Output Template
Use the calling review's output format. For a standalone review, report supported ```
findings with severity, location, impact, fix, and validation, or `No findings`. ## Rust Code Quality Report
Include only material unverified checks. Candidate counts are not a quality
metric and do not need a separate scan report. ### Automated Scan
- unwrap/expect candidates inspected: N
- numeric-cast candidates inspected: N
- error-type candidates inspected: N
- output-macro candidates inspected: N
### Findings
- [P1] `path:line` — description
- Fix: ...
- Validation: ...
### Verdict
PASS / BLOCKED (list blocking findings)
```
+104 -13
View File
@@ -4,14 +4,9 @@ description: "Run the end-to-end RustFS console gate, version bump, preview vali
--- ---
# RustFS Release Publish (preview-validated pipeline) # RustFS Release Publish (preview-validated pipeline)
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (invoked here with the authorized commit/push/PR scope) with a mandatory preview-tag validation loop before the final tag is published. This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
The binary reports its build tag (`build::TAG` via shadow_rs; `SHORT_VERSION` in Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. That Release is temporary: `build.yml` deletes it automatically once the final tag's Release is published, so the Releases page ends up carrying deliverables only while the `-preview.N` tags stay behind as the traceability record. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
`rustfs/src/config/cli.rs`), and `build.yml` derives asset names and preview
classification from that tag. Cargo.toml supplies only the no-tag fallback.
Preview and final tags must therefore share the validated source commit;
their tag-dependent version and asset names differ. The channel and cleanup
constraints are defined once under Preview tag naming and Hard rules below.
Pipeline shape: Pipeline shape:
@@ -34,9 +29,9 @@ On validation failure: fix lands on main via normal PR (version files are alread
- Final target version, for example `1.0.0-beta.10`. - Final target version, for example `1.0.0-beta.10`.
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` after `git fetch --tags`). - Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` after `git fetch --tags`).
If the target version is missing or ambiguous, collect the current release/tag baseline and ask before version edits or publication. Continue independent read-only preflight while the answer is pending (see the semver gate below). If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
## Semver gate — resolve the target before version edits or publication ## Semver gate — confirm the target version before touching anything
Versions follow [SemVer 2.0.0](https://semver.org/). Precedence reminder: Versions follow [SemVer 2.0.0](https://semver.org/). Precedence reminder:
@@ -48,7 +43,7 @@ Numeric prerelease identifiers compare numerically (`beta.9 < beta.10`), not lex
Rules: Rules:
- A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences. - A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose via AskUserQuestion with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
- After a stable `X.Y.Z` exists, the next version must state which component bumps: patch `X.Y.(Z+1)` for fixes only, minor `X.(Y+1).0` for backward-compatible features, major `(X+1).0.0` for breaking changes. If the user names a bump type but not a number, compute it from the latest stable tag and echo the exact resulting version back for confirmation. - After a stable `X.Y.Z` exists, the next version must state which component bumps: patch `X.Y.(Z+1)` for fixes only, minor `X.(Y+1).0` for backward-compatible features, major `(X+1).0.0` for breaking changes. If the user names a bump type but not a number, compute it from the latest stable tag and echo the exact resulting version back for confirmation.
- Echo the final confirmed version string verbatim in your first status report; every later phase must use exactly that string. If at any point the user's wording and the confirmed version diverge, stop and re-confirm. - Echo the final confirmed version string verbatim in your first status report; every later phase must use exactly that string. If at any point the user's wording and the confirmed version diverge, stop and re-confirm.
@@ -82,7 +77,58 @@ Rules:
### Console release gate ### Console release gate
Read and complete [the Console gate](references/console-gate.md) before Phase 1. Verify the latest published Console asset and exact commit; if Console main is ahead, complete its release and asset verification first. A successful build alone does not satisfy this gate. Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
1. Read the latest published Console tag and compare it with Console `main`:
```bash
CONSOLE_REPO="rustfs/console"
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
```
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
```bash
CONSOLE_SCRATCH=$(mktemp -d)
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
```
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
```bash
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
```
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
3. Find the exact tag run and wait for completion:
```bash
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
```
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
```bash
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
```
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
## Phase 1 — Version bump to the final target (once) ## Phase 1 — Version bump to the final target (once)
@@ -118,9 +164,54 @@ On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback. - Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
- Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets. - Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets.
## Phases 45Local artifact, Console, and rc acceptance ## Phase 4 — Run the artifact locally, verify the console
Read and complete [preview acceptance](references/preview-acceptance.md): verify the downloaded binary's tag/SHA and readiness, exercise Console CRUD with byte-identical download, and pass the full latest-rc command matrix. Any failure blocks final publication. Retain the results for the confirmation gate below. Work inside the session scratchpad directory; never leave stray data dirs.
```bash
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
cd "$SCRATCH" && unzip -o rustfs-*.zip
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
mkdir -p data
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
```
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
Checks (all must pass):
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
- `curl -fsS http://localhost:9000/health/ready` returns ready.
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
## Phase 5 — Validate with the latest rc client
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
```bash
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
rc ls preview/
rc mb preview/rel-check
rc cp <local-file> preview/rel-check/
rc stat preview/rel-check/<file>
rc cat preview/rel-check/<file> # matches source
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
rc cp -r <local-dir>/ preview/rel-check/dir/
rc find preview/rel-check --name "*"
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
rc rb preview/rel-check
rc admin user list preview/
rc admin user add preview/ relcheckuser relchecksecret12
rc admin user remove preview/ relcheckuser
rc alias remove preview
```
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
### Manual confirmation gate ### Manual confirmation gate
@@ -1,58 +0,0 @@
# Console Release Gate
Read during Phase 0, before changing RustFS version files or tags. Follow the parent skill's release scope and authorization rules.
### Console release gate
Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
1. Read the latest published Console tag and compare it with Console `main`:
```bash
CONSOLE_REPO="rustfs/console"
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
```
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
```bash
CONSOLE_SCRATCH=$(mktemp -d)
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
```
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
```bash
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
```
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
3. Find the exact tag run and wait for completion:
```bash
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
```
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
```bash
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
```
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
@@ -1,52 +0,0 @@
# Preview Artifact Acceptance
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and latest rc client.
## Phase 4 — Run the artifact locally, verify the console
Work inside the session scratchpad directory; never leave stray data dirs.
```bash
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
cd "$SCRATCH" && unzip -o rustfs-*.zip
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
mkdir -p data
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
```
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
Checks (all must pass):
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
- `curl -fsS http://localhost:9000/health/ready` returns ready.
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
## Phase 5 — Validate with the latest rc client
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
```bash
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
rc ls preview/
rc mb preview/rel-check
rc cp <local-file> preview/rel-check/
rc stat preview/rel-check/<file>
rc cat preview/rel-check/<file> # matches source
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
rc cp -r <local-dir>/ preview/rel-check/dir/
rc find preview/rel-check --name "*"
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
rc rb preview/rel-check
rc admin user list preview/
rc admin user add preview/ relcheckuser relchecksecret12
rc admin user remove preview/ relcheckuser
rc alias remove preview
```
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
@@ -4,16 +4,17 @@ description: "Prepare the version-file and release-asset bump for an exact RustF
--- ---
# RustFS Release Version Bump # RustFS Release Version Bump
Use this skill to prepare and verify release version files. Commit, push, and PR steps apply only when included in the user's delivery scope; publishing release tags belongs to `rustfs-release-publish`. Use this skill to publish a RustFS release (alpha, beta, or stable) with a minimal, auditable diff and a complete ship flow (`edit -> verify -> commit -> push -> PR`).
Validated baseline: release pattern used in PR `#2957`. Validated baseline: release pattern used in PR `#2957`.
## Required inputs ## Required inputs
- Exact target version, for example `1.0.0-beta.4`. - Exact target version, for example `1.0.0-beta.4`.
- Delivery scope: local (`edit/verify`), git (`commit/push`), or GitHub - Delivery scope:
(`commit/push/PR`). Derive it from the conversation; when unspecified, prepare - Local only (`edit/verify`).
and verify locally without blocking on a delivery question. - Local + git (`commit/push`).
- Full GitHub flow (`commit/push/PR`).
If target version is missing or ambiguous, stop and ask before editing. If target version is missing or ambiguous, stop and ask before editing.
@@ -22,7 +23,7 @@ Reject any target version containing `-preview`: preview identifiers are tag-onl
## Read before editing ## Read before editing
- `AGENTS.md` (root and nearest path-specific files). - `AGENTS.md` (root and nearest path-specific files).
- `.github/pull_request_template.md` only when preparing a PR. - `.github/pull_request_template.md`.
- Current branch status and diff against `origin/main`. - Current branch status and diff against `origin/main`.
## Default release file scope ## Default release file scope
@@ -49,7 +50,8 @@ Only drop a file when the current repository release process clearly no longer r
## Step-by-step workflow ## Step-by-step workflow
1. Confirm intent and isolate scope 1. Confirm intent and isolate scope
- Use the exact target and delivery scope already supplied; ask only for a missing or ambiguous target or a material release-policy choice. - Confirm target version string exactly.
- Confirm whether user requested local-only or full GitHub flow.
- Inspect current branch and ensure only release-related files are touched for this task. - Inspect current branch and ensure only release-related files are touched for this task.
2. Update workspace versions 2. Update workspace versions
@@ -80,18 +82,18 @@ Only drop a file when the current repository release process clearly no longer r
4. Verify before shipping 4. Verify before shipping
- Run: - Run:
- `make pre-commit` - `make pre-commit`
- If `make pre-commit` fails, fix task-attributable failures and rerun affected checks. Report unresolved required checks as `BLOCKED`; do not silently widen scope to fix unrelated issues. - If `make pre-commit` fails, return `BLOCKED` with root cause and do not silently widen scope to fix unrelated issues unless user asks.
5. Commit strategy (only when committing is authorized) 5. Commit strategy
- Preferred split when both parts changed: - Preferred split when both parts changed:
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`. - `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`.
- `chore(release): align release assets for <version>` for docs and packaging files. - `chore(release): align release assets for <version>` for docs and packaging files.
- If user asks for one commit, use one commit. - If user asks for one commit, use one commit.
- Stage only intended release files; do not include unrelated working tree changes. - Stage only intended release files; do not include unrelated working tree changes.
6. Push and PR (only for the authorized delivery scope) 6. Push and PR
- Push branch: - Push branch:
- Use the user-requested or configured push remote: `git push -u <push-remote> <branch>` (first push), or `git push` when tracking is already configured. - `git push -u origin <branch>` (first push), or `git push` (tracking already exists).
- Create PR with template headings unchanged: - Create PR with template headings unchanged:
- `gh pr create --base main --head <branch> --title ... --body-file ...` - `gh pr create --base main --head <branch> --title ... --body-file ...`
- PR title/body must be English. - PR title/body must be English.
@@ -12,9 +12,8 @@ matched security surface, the concise security reference under
## Workflow ## Workflow
1. Freeze the exact diff/head and identify the changed trust boundaries. 1. Freeze the exact diff/head and identify the changed trust boundaries.
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md), 2. Read [advisory-patterns.md](references/advisory-patterns.md), then apply only
then read the matching sections. Read the full map only for a broad security the matching sections. Useful headings are
audit. Useful headings are
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths, auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde. secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed, 3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
@@ -108,9 +108,9 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### Serde deserialization and input validation ### Serde deserialization and input validation
- Reject unknown fields in untrusted S3 API XML/JSON, lifecycle, policy, and replication input where compatibility permits. Check the current type and supported payload fixtures; do not infer the repository's current coverage from an older audit. Where extra fields are part of the compatibility contract, validate security-critical values explicitly. - No `#[serde(deny_unknown_fields)]` found across the entire codebase. Lesson: all structs deserialized from untrusted input (S3 API XML/JSON, lifecycle rules, bucket policies, replication configs) should have `#[serde(deny_unknown_fields)]` to reject malformed or adversarial payloads.
- `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults. - `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults.
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` with a typed error, or clamp only when the domain explicitly requires saturation. - Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` or clamp.
- XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior. - XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior.
## Useful Search Seeds ## Useful Search Seeds
+37 -28
View File
@@ -1,6 +1,6 @@
--- ---
name: test-coverage-improver name: test-coverage-improver
description: Analyze a supplied coverage report or perform an explicitly requested RustFS coverage assessment, rank uncovered risks, and propose focused tests. Do not trigger for ordinary implementation verification, a single regression test, documentation wording, or release preparation without a coverage request. description: Run project coverage checks, rank high-risk gaps, and propose high-impact tests to improve regression confidence for changed and critical code paths before release.
--- ---
# Test Coverage Improver # Test Coverage Improver
@@ -9,49 +9,58 @@ Use this skill when you need a prioritized, risk-aware plan to improve tests fro
## Usage assumptions ## Usage assumptions
- Focus scope is either changed lines/files, a module, or the whole repository. - Focus scope is either changed lines/files, a module, or the whole repository.
- Reuse a supplied coverage artifact when its revision, scope, and format match. - Coverage artifact must be generated or provided in a supported format.
- If required context is missing, call out assumptions explicitly before proposing work. - If required context is missing, call out assumptions explicitly before proposing work.
## Workflow ## Workflow
1. Define scope and baseline 1. Define scope and baseline
- Derive the revision and scope from the request, diff, or supplied report. - Confirm target language, framework, and branch.
- Default to the affected files/module; whole-workspace coverage requires that - Confirm whether the scope is changed files only or full-repo.
scope in the request. Ask only if a wrong scope would change the result.
2. Obtain coverage evidence 2. Produce coverage snapshot
- First inspect a matching existing artifact; do not regenerate it merely - Rust: `cargo llvm-cov` (or `cargo tarpaulin`) with existing repo config.
because this skill was selected. - JavaScript/TypeScript: `npm test -- --coverage` and read `coverage/coverage-final.json`.
- If measurement is needed, read the Coverage section of - Python: `pytest --cov=<pkg> --cov-report=json` and read `coverage.json`.
[the testing guide](../../../docs/testing/README.md#coverage), check disk - Collect total, per-file, and changed-line coverage.
space/tool availability, and select package/test-scoped `cargo llvm-cov`
using the repository's nextest configuration. `make coverage` measures the
whole workspace (excluding E2E) and is only for that requested scope.
- Collect only metrics the report supports. Missing branch/changed-line
coverage is unknown, not zero.
- If measurement cannot run, continue with code-based test proposals and
mark measured coverage unverified; do not invent a coverage percentage.
3. Rank highest-risk gaps 3. Rank highest-risk gaps
- Prioritize changed code, branch coverage gaps, and low-confidence boundaries. - Prioritize changed code, branch coverage gaps, and low-confidence boundaries.
- Apply the risk rubric in [coverage-prioritization.md](references/coverage-prioritization.md). - Apply the risk rubric in [coverage-prioritization.md](references/coverage-prioritization.md).
- Report up to 58 evidenced gaps; do not pad a small scope. - Keep shortlist to 58 gaps.
- For each gap, capture: file, lines, uncovered branches, and estimated risk score. - For each gap, capture: file, lines, uncovered branches, and estimated risk score.
4. Propose high-impact tests 4. Propose high-impact tests
- For each gap, name the behavior and regression, distinguishing assertions, - For each shortlisted gap, output:
relevant normal/edge/failure cases, necessary setup, and estimated effort. - Intent and expected behavior.
- Include only scenarios and setup that apply; reuse shared fixture details. - Normal, edge, and failure scenarios.
- Assertions and side effects to verify.
- Setup needs (fixtures, mocks, integration dependencies).
- Estimated effort (`S/M/L`).
5. Close with validation plan 5. Close with validation plan
- State which gaps remain after proposals. - State which gaps remain after proposals.
- Give a scoped verification command and behavior-based acceptance criterion; - Provide concrete verification command and acceptance threshold.
use a coverage threshold only when the task or repository requires one.
- List assumptions or blockers (environment, fixtures, flaky dependencies). - List assumptions or blockers (environment, fixtures, flaky dependencies).
## Report ## Output template
Summarize the supported metrics, then combine each ranked gap with its proposed ### Coverage Snapshot
test and validation. Include source lines only when supplied or inspected; - total / branch coverage
mark missing metrics or locations as unknown. Do not duplicate gaps and tests - changed-file coverage
in separate templates or fill empty categories for an otherwise small report. - top missing regions by size
### Top Gaps (ranked)
- `path:line-range` | risk score | why critical
### Test Proposals
- `path:line-range`
- Test name
- scenarios
- assertions
- effort
### Validation Plan
- command
- pass criteria
- remaining risk
@@ -1,4 +1,4 @@
interface: interface:
display_name: "Test Coverage Improver" display_name: "Test Coverage Improver"
short_description: "Find top uncovered risk areas and propose high-impact tests." short_description: "Find top uncovered risk areas and propose high-impact tests."
default_prompt: "Use $test-coverage-improver to analyze coverage for the requested scope, reuse matching reports, and propose tests for evidenced risks." default_prompt: "Run coverage checks, identify largest gaps, and recommend highest-impact test cases to improve risk coverage."
+2 -5
View File
@@ -5,11 +5,8 @@ description: Debug ILM tiering / lifecycle transition issues — NoSuchVersion o
# Tier / ILM Debugging # Tier / ILM Debugging
Playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md). Full playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md)
Read the section matching the symptom: metadata/`xl.meta`, runtime versionId, — read it before changing tier code.
manual jobs, or retained-record recovery. Read the local-first expiry invariant
before changing cleanup ordering. Before a reconcile/disposition action, read
its entire procedure and retain its exact-evidence and confirmation gates.
Quick moves: Quick moves:
-5
View File
@@ -1,5 +0,0 @@
# Bound individual tool outputs retained in context; retrieve relevant ranges
# from task-owned log files when more evidence is needed.
# https://learn.chatgpt.com/docs/config-file/config-reference
tool_output_token_limit = 4000
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=f0d15f2d1183be319d9d977d20b48bb05a29a424c266eed910c67aaa6f1ff955 sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
sha256-linux=340aa702576ebed5266b7c47e591f267a11178fa04bf3828d64f53fe92eb0907 sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=12d30fff5ed48fe95bbfb310dd507048f81782b2954e00eb72435708b1133f9c sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
sha256-linux=e917b2fdb303d01e6008ac4f1836c7b268698afe8383bd2aa4e697061df85cf6 sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
+1 -1
View File
@@ -1 +1 @@
sha256=0e338d305260229e17ccfb2adc48a6212dbdfea36a9ebfb5a4e0d38658e6cc45 sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
+1 -1
View File
@@ -1 +1 @@
sha256=6d18f9cce820c51d5589de944e8cc185f73eeca0ea9a9916651943e3759169d0 sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
-1
View File
@@ -47,7 +47,6 @@ script-tests: ## Run shell script tests
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
./scripts/validate_object_data_cache_cold_stampede.sh --self-test ./scripts/validate_object_data_cache_cold_stampede.sh --self-test
./scripts/run_scanner_heal_evidence_case.sh --self-test
.PHONY: test .PHONY: test
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override) test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
+24 -295
View File
@@ -1,5 +1,5 @@
{ {
"schema": 2, "schema": 1,
"cases": { "cases": {
"background-target-restart": { "background-target-restart": {
"gate": "G14", "gate": "G14",
@@ -8,304 +8,33 @@
"suite": "e2e_test", "suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart", "name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
"oracle": "background-target-restart.json", "oracle": "background-target-restart.json",
"evidence": "process-restart",
"unclean_shutdown_marker": false,
"min_objects": 9, "min_objects": 9,
"max_objects": 65, "max_objects": 65,
"topology": {"nodes": 4, "drives_per_node": 1}, "topology": {"nodes": 4, "drives_per_node": 1},
"erasure": {"data_blocks": 2, "parity_blocks": 2},
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4." "scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
},
"background-target-crash": {
"gate": "G14",
"task": "W21",
"lane": "e2e-nightly",
"suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_crash",
"oracle": "background-target-crash.json",
"evidence": "process-crash-restart",
"unclean_shutdown_marker": true,
"min_objects": 9,
"max_objects": 65,
"topology": {"nodes": 4, "drives_per_node": 1},
"erasure": {"data_blocks": 2, "parity_blocks": 2},
"scope": "Target process killed during partial background rebuild, real unclean-shutdown marker, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
},
"ec84-target-drive-restart": {
"gate": "G14",
"task": "W20/W21",
"lane": "e2e-distributed",
"suite": "e2e_test",
"name": "distributed::heal_test::three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart",
"oracle": "ec84-target-drive-restart.json",
"evidence": "process-restart",
"unclean_shutdown_marker": false,
"min_objects": 5,
"max_objects": 5,
"topology": {"nodes": 3, "drives_per_node": 4},
"erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12,
"scope": "3-node x 4-drive single-set EC8+4, graceful target restart, preformatted replacement drive, exact unversioned S3 bodies and physical target shards; not mixed-version, multi-pool or long-window ABBA."
},
"background-target-restart-ec8-4": {
"gate": "G14",
"task": "W21",
"lane": "e2e-nightly",
"suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart",
"oracle": "background-target-restart-ec8-4.json",
"evidence": "process-restart",
"unclean_shutdown_marker": false,
"min_objects": 9,
"max_objects": 65,
"topology": {"nodes": 3, "drives_per_node": 4},
"erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12,
"scope": "Target process restart during partial background rebuild on a single 3x4 EC8+4 set; exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
},
"background-target-crash-ec8-4": {
"gate": "G14",
"task": "W21",
"lane": "e2e-nightly",
"suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash",
"oracle": "background-target-crash-ec8-4.json",
"evidence": "process-crash-restart",
"unclean_shutdown_marker": true,
"min_objects": 9,
"max_objects": 65,
"topology": {"nodes": 3, "drives_per_node": 4},
"erasure": {"data_blocks": 8, "parity_blocks": 4},
"erasure_set_drive_count": 12,
"scope": "Target process killed during partial background rebuild on a single 3x4 EC8+4 set; real unclean-shutdown marker, exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
} }
}, },
"release_lanes": { "release_pending": {
"single-set-restart": { "G01": "W02/W04 complete root and quota authority coverage",
"status": "implemented", "G02": "W03 bounded checkpoint progress and independent version inventory",
"cases": ["background-target-restart", "background-target-crash"], "G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
"covers": ["four-node one-drive topology", "unversioned objects", "target restart/crash"] "G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
}, "G05": "W06/W07 per-object outcomes and bounded terminal retention",
"authority-coverage": { "G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
"status": "pending", "G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
"gates": ["G01", "G12"], "G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
"requires": ["root authority coverage", "quota authority coverage"] "G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
}, "G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
"checkpoint-and-crash": { "G11": "W04/W19/W24 maintenance and complete producer coverage",
"status": "pending", "G12": "W02/W15/W16 both quota paths during reset and settlement",
"gates": ["G02", "G04", "R-E"], "G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
"requires": ["bounded checkpoint progress", "boundary crash matrix", "fixed-budget restart evidence"] "G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
}, "P1": "W20 measured cold-walk share and foreground latency/throughput",
"status-and-outcome": { "P2": "W20/W24 measured post-stop convergence and cold segment reuse",
"status": "pending", "P3": "W20 measured two-hour pressure/heal capacity and recovery window",
"gates": ["G05", "G06", "R-D"], "P4": "W20 measured MRF scale and replay cost with retained responsibility",
"requires": ["per-object outcomes", "legacy status clients", "manager/event/ledger disposition"] "R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
}, "R-D": "W07/W14 manager-to-event-to-ledger exact disposition, including grace",
"mrf-responsibility": { "R-L": "W13/W14 legacy source conflicts, migration gaps and crash-safe source retirement"
"status": "pending", }
"gates": ["G07", "G08", "P4"],
"requires": ["durable MRF responsibility", "disk-full and replica-loss matrix", "MRF replay cost"]
},
"mixed-version-rollback": {
"status": "pending",
"gates": ["G03", "G09", "R-L"],
"requires": ["mixed-version peers", "rollback payloads", "crash-safe source retirement"]
},
"scheduler-pressure": {
"status": "pending",
"gates": ["G10", "P1", "P2", "P3"],
"requires": ["bounded scheduling", "foreground latency and throughput", "two-hour pressure evidence"]
},
"maintenance-producers": {
"status": "pending",
"gates": ["G11", "G13"],
"requires": ["complete producer coverage", "quorum-minus-one and remount matrix"]
},
"ec8-4-multiset": {
"status": "pending",
"gates": ["G14"],
"requires": ["3x4 EC8+4 topology", "multi-set coverage", "multi-pool coverage"]
}
},
"release_requirements": [
{
"gate": "G01",
"task": "W02/W04",
"lane": "authority-coverage",
"status": "pending",
"description": "Complete root and quota authority coverage",
"requires": ["root authority evidence", "quota authority evidence"]
},
{
"gate": "G02",
"task": "W03",
"lane": "checkpoint-and-crash",
"status": "pending",
"description": "Bounded checkpoint progress and independent version inventory",
"requires": ["bounded checkpoint oracle", "independent version inventory"]
},
{
"gate": "G03",
"task": "W17/W18",
"lane": "mixed-version-rollback",
"status": "pending",
"description": "Exact scoped ACK with durable publication and mixed peers",
"requires": ["durable scoped ACK publication", "mixed-peer evidence"],
"evidence_fields": [
"durable_root_publication_proof",
"scoped_ack_request_identity",
"participating_peer_capability_snapshot",
"mixed_peer_ack_fallback_oracle"
]
},
{
"gate": "G04",
"task": "W03/W15/W16",
"lane": "checkpoint-and-crash",
"status": "pending",
"description": "Crash at every cache, root, floor and intent boundary",
"requires": ["cache boundary crash evidence", "root/floor/intent crash evidence"]
},
{
"gate": "G05",
"task": "W06/W07",
"lane": "status-and-outcome",
"status": "pending",
"description": "Per-object outcomes and bounded terminal retention",
"requires": ["per-object outcome oracle", "terminal retention bounds"]
},
{
"gate": "G06",
"task": "W06/W08/W23",
"lane": "status-and-outcome",
"status": "pending",
"description": "Concurrent status, legacy clients and truncation",
"requires": ["concurrent status evidence", "legacy client compatibility", "truncation behavior"]
},
{
"gate": "G07",
"task": "W12/W13/W14",
"lane": "mrf-responsibility",
"status": "pending",
"description": "Durable MRF responsibility at every commit boundary",
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"]
},
{
"gate": "G08",
"task": "W12/W13/W14",
"lane": "mrf-responsibility",
"status": "pending",
"description": "MRF capacity, disk-full and replica-loss matrix",
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"]
},
{
"gate": "G09",
"task": "W13/W18/W23",
"lane": "mixed-version-rollback",
"status": "pending",
"description": "Actual mixed-version reader/writer and rollback payloads",
"requires": ["mixed-version reader evidence", "mixed-version writer evidence", "rollback payload evidence"],
"evidence_fields": [
"mixed_version_reader_evidence",
"mixed_version_writer_evidence",
"rollback_payload_evidence"
]
},
{
"gate": "G10",
"task": "W05/W09/W10/W11",
"lane": "scheduler-pressure",
"status": "pending",
"description": "Bounded scheduling and pressure recovery",
"requires": ["scheduler bound evidence", "pressure recovery evidence"]
},
{
"gate": "G11",
"task": "W04/W19/W24",
"lane": "maintenance-producers",
"status": "pending",
"description": "Maintenance and complete producer coverage",
"requires": ["maintenance producer matrix", "complete producer inventory"]
},
{
"gate": "G12",
"task": "W02/W15/W16",
"lane": "authority-coverage",
"status": "pending",
"description": "Both quota paths during reset and settlement",
"requires": ["reset quota-path evidence", "settlement quota-path evidence"]
},
{
"gate": "G13",
"task": "W07/W14",
"lane": "maintenance-producers",
"status": "pending",
"description": "Quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
"requires": ["quorum-minus-one matrix", "unknown-disk/remount matrix", "Object Lock dry-run grace evidence"]
},
{
"gate": "G14",
"task": "W20/W21",
"lane": "ec8-4-multiset",
"status": "pending",
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
"requires": ["same-window field evidence", "3x4 EC8+4 evidence", "multi-set evidence", "multi-pool evidence"]
},
{
"gate": "P1",
"task": "W20",
"lane": "scheduler-pressure",
"status": "pending",
"description": "Measured cold-walk share and foreground latency/throughput",
"requires": ["cold-walk share measurement", "foreground latency/throughput measurement"]
},
{
"gate": "P2",
"task": "W20/W24",
"lane": "scheduler-pressure",
"status": "pending",
"description": "Measured post-stop convergence and cold segment reuse",
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"]
},
{
"gate": "P3",
"task": "W20",
"lane": "scheduler-pressure",
"status": "pending",
"description": "Measured two-hour pressure/heal capacity and recovery window",
"requires": ["two-hour pressure measurement", "heal capacity measurement", "recovery-window measurement"]
},
{
"gate": "P4",
"task": "W20",
"lane": "mrf-responsibility",
"status": "pending",
"description": "Measured MRF scale and replay cost with retained responsibility",
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence"]
},
{
"gate": "R-E",
"task": "W03/W05",
"lane": "checkpoint-and-crash",
"status": "pending",
"description": "Fixed-budget real process restart through enumeration and classification",
"requires": ["fixed-budget restart evidence", "enumeration evidence", "classification evidence"]
},
{
"gate": "R-D",
"task": "W07/W14",
"lane": "status-and-outcome",
"status": "pending",
"description": "Manager-to-event-to-ledger exact disposition, including grace",
"requires": ["manager disposition evidence", "event disposition evidence", "ledger disposition evidence", "grace handling"]
},
{
"gate": "R-L",
"task": "W13/W14",
"lane": "mixed-version-rollback",
"status": "pending",
"description": "Legacy source conflicts, migration gaps and crash-safe source retirement",
"requires": ["legacy source-conflict evidence", "migration-gap evidence", "crash-safe source retirement evidence"]
}
]
} }
+1 -1
View File
@@ -9,4 +9,4 @@
# if the selected count drops below this number, so a rename or removal that # if the selected count drops below this number, so a rename or removal that
# thins the security smoke gate must update this file in the same PR. # thins the security smoke gate must update this file in the same PR.
# Adding tests does not require a bump, but bumping keeps the guard tight. # Adding tests does not require a bump, but bumping keeps the guard tight.
26 18
+2 -2
View File
@@ -16,7 +16,7 @@ name: Security Audit
on: on:
push: push:
branches: [ main, release ] branches: [ main ]
paths: paths:
- '**/Cargo.toml' - '**/Cargo.toml'
- '**/Cargo.lock' - '**/Cargo.lock'
@@ -32,7 +32,7 @@ on:
- 'scripts/security/check_workflow_pins.sh' - 'scripts/security/check_workflow_pins.sh'
pull_request: pull_request:
types: [ opened, synchronize, reopened, closed ] types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ] branches: [ main ]
paths: paths:
- '**/Cargo.toml' - '**/Cargo.toml'
- '**/Cargo.lock' - '**/Cargo.lock'
+1 -1
View File
@@ -22,7 +22,7 @@ name: Continuous Integration (docs only)
on: on:
pull_request: pull_request:
types: [ opened, synchronize, reopened ] types: [ opened, synchronize, reopened ]
branches: [ main, release ] branches: [ main ]
paths: paths:
- "**.md" - "**.md"
- "docs/**" - "docs/**"
+6 -109
View File
@@ -16,7 +16,7 @@ name: Continuous Integration
on: on:
push: push:
branches: [ main, release ] branches: [ main ]
paths-ignore: paths-ignore:
- "**.md" - "**.md"
- "docs/**" - "docs/**"
@@ -36,7 +36,7 @@ on:
- "flake.lock" - "flake.lock"
pull_request: pull_request:
types: [ opened, synchronize, reopened, closed ] types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ] branches: [ main ]
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which # 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. # reports the required "Test and Lint" check for PRs skipped here.
paths-ignore: paths-ignore:
@@ -582,59 +582,13 @@ jobs:
install-build-packaging-tools: 'false' install-build-packaging-tools: 'false'
- name: Build debug binary - name: Build debug binary
run: | run: cargo build -p rustfs --bins --features e2e-test-hooks
python3 - <<'PYBUILD'
import hashlib
import json
import os
import pathlib
import subprocess
def git(*args):
return subprocess.check_output(["git", *args], text=True).strip()
def sha256(path):
digest = hashlib.sha256()
with pathlib.Path(path).open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
if not clean_before:
raise SystemExit("hooks binary requires a clean build checkout")
lock_sha256 = sha256("Cargo.lock")
lock_git_blob = git("hash-object", "Cargo.lock")
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
host = next(line.removeprefix("host: ") for line in rustc.splitlines() if line.startswith("host: "))
if os.environ.get("CARGO_BUILD_TARGET") or pathlib.Path(os.environ.get("CARGO_TARGET_DIR", "target")).resolve() != pathlib.Path("target").resolve():
raise SystemExit("this artifact requires the native target/debug output")
subprocess.run(argv, check=True)
clean_after = not git("status", "--porcelain", "--untracked-files=normal")
if not clean_after or commit != git("rev-parse", "HEAD") or tree != git("rev-parse", "HEAD^{tree}") or lock_sha256 != sha256("Cargo.lock"):
raise SystemExit("hooks binary source changed while building")
manifest = {
"schema": 1, "commit": commit, "tree": tree,
"clean_before": clean_before, "clean_after": clean_after,
"lock_sha256": lock_sha256, "lock_git_blob": lock_git_blob,
"argv": argv, "profile": "debug", "target": host,
"features": ["e2e-test-hooks"],
"rustc_verbose": rustc,
"build_flags": {key: os.environ[key] for key in ("RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "CARGO_BUILD_TARGET", "CARGO_TARGET_DIR", "RUSTUP_TOOLCHAIN") if key in os.environ},
"binary_sha256": sha256("target/debug/rustfs"),
}
pathlib.Path("target/debug/rustfs.e2e-startup-cas-build.json").write_text(json.dumps(manifest, indent=2) + "\n")
PYBUILD
- name: Upload debug binary - name: Upload debug binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-debug-binary name: rustfs-debug-binary
path: | path: target/debug/rustfs
target/debug/rustfs
target/debug/rustfs.e2e-startup-cas-build.json
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@@ -872,14 +826,13 @@ jobs:
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible # Merge gate only (backlog#1149 ci-5): the never-automated user-visible
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption, # suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for # security-boundary, ... — via the e2e-full nextest profile. Too heavy for
# every PR, so it is gated to main/release pushes, the merge queue, and manual # every PR, so it is gated to main pushes, the merge queue, and manual
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are # dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
# owned by other lanes (see .config/nextest.toml profile.e2e-full). # owned by other lanes (see .config/nextest.toml profile.e2e-full).
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
github.event_name == 'merge_group' || github.event_name == 'merge_group' ||
(github.event_name == 'push' && (github.event_name == 'push' && github.ref == 'refs/heads/main')
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release'))
needs: [ build-rustfs-debug-binary ] needs: [ build-rustfs-debug-binary ]
runs-on: sm-standard-2 runs-on: sm-standard-2
timeout-minutes: 55 timeout-minutes: 55
@@ -959,36 +912,6 @@ jobs:
- name: Make binary executable - name: Make binary executable
run: chmod +x ./target/debug/rustfs run: chmod +x ./target/debug/rustfs
- name: Preserve startup CAS binary input
env:
STARTUP_CAS_INPUT: ${{ runner.temp }}/rustfs-startup-cas-input
run: |
python3 - <<'PYINPUT'
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
source = pathlib.Path("target/debug/rustfs")
manifest_path = source.with_name("rustfs.e2e-startup-cas-build.json")
manifest = json.loads(manifest_path.read_text())
target = pathlib.Path(os.environ["STARTUP_CAS_INPUT"])
target.mkdir(parents=True, exist_ok=True)
binary = target / "rustfs"
shutil.copy2(source, binary)
digest = hashlib.sha256()
with binary.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
raise SystemExit("downloaded hooks binary identity mismatch")
shutil.copy2(manifest_path, target / manifest_path.name)
binary.chmod(0o755)
PYINPUT
- name: Verify e2e full membership - name: Verify e2e full membership
env: env:
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json
@@ -1001,23 +924,8 @@ jobs:
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded # extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
# debug binary; each test spawns its own rustfs server on a random port. # debug binary; each test spawns its own rustfs server on a random port.
- name: Run e2e full suite - name: Run e2e full suite
env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-full-logs
RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG: rustfs::heal=debug,rustfs_heal=debug,rustfs_ecstore::disk=debug,rustfs_lock=debug,rustfs=error
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
run: cargo nextest run --profile e2e-full -p e2e_test run: cargo nextest run --profile e2e-full -p e2e_test
- name: Upload e2e full diagnostics
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-full-diagnostics-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-e2e-full-logs/
if-no-files-found: warn
retention-days: 7
- name: Upload junit - name: Upload junit
if: always() if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -1028,17 +936,6 @@ jobs:
${{ runner.temp }}/rustfs-e2e-full-list.json ${{ runner.temp }}/rustfs-e2e-full-list.json
retention-days: 7 retention-days: 7
- name: Upload startup CAS evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: fresh-startup-cas-evidence-${{ github.run_number }}
path: |
${{ runner.temp }}/rustfs-startup-cas-evidence
${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
if-no-files-found: warn
retention-days: 7
e2e-tests-rio-v2: e2e-tests-rio-v2:
name: End-to-End Tests (rio-v2) name: End-to-End Tests (rio-v2)
# Inherits the schedule/dispatch-only gate through needs: on every other # Inherits the schedule/dispatch-only gate through needs: on every other
+2 -2
View File
@@ -19,7 +19,7 @@
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is # case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs, # `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
# nightly runs, and manual dispatches all execute the same fail-closed suite. # nightly runs, and manual dispatches all execute the same fail-closed suite.
# Upgrade cases use an independent 1.0.0-rc.2 pin defined below. # Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
# #
# Isolated pool filesystems: expand/decommission/rebalance cases require # Isolated pool filesystems: expand/decommission/rebalance cases require
# independent `statfs` capacity. This job runs on GitHub-hosted # independent `statfs` capacity. This job runs on GitHub-hosted
@@ -87,7 +87,7 @@ jobs:
NO_PROXY: 127.0.0.1,localhost NO_PROXY: 127.0.0.1,localhost
HTTP_PROXY: "" HTTP_PROXY: ""
HTTPS_PROXY: "" HTTPS_PROXY: ""
# Independent 1.0.0-rc.2 source pin for distributed::upgrade_test. # Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2 UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7 UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
-8
View File
@@ -82,14 +82,6 @@ jobs:
cache_key: e2e-odm-config-rollback cache_key: e2e-odm-config-rollback
test: rc5_rollback_requires_restoring_odm_configuration test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback artifact: odm-config-rollback
- name: Multipart layouts survive the rc.5 upgrade
cache_key: e2e-multipart-layout-upgrade
test: direct_upgrade_from_rc5_preserves_multipart_layouts
artifact: multipart-layout-upgrade
- name: rc.5 multipart replication baseline
cache_key: e2e-multipart-layout-baseline
test: rc5_baseline_replicates_multipart_layouts
artifact: multipart-layout-baseline
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
env: env:
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0 - uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
output-branch: star-history output-branch: star-history
+123 -38
View File
@@ -7,11 +7,8 @@ This file contains repository-wide rules. Use the nearest subdirectory
1. System/developer instructions. 1. System/developer instructions.
2. The current user request. 2. The current user request.
3. Applicable `AGENTS.md` files, with the nearest file winning conflicts. 3. The nearest `AGENTS.md`.
4. Selected skills and reference documents. 4. This file.
Nested instructions add to ancestor rules; they do not discard non-conflicting
rules. A skill cannot expand the user's requested scope or grant authorization.
## Operating Model ## Operating Model
@@ -24,29 +21,63 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
- Do not load every skill or inspect unrelated modules preemptively. Select a - Do not load every skill or inspect unrelated modules preemptively. Select a
skill only when its description directly matches the request or changed skill only when its description directly matches the request or changed
surface. surface.
- Resolve repository workflow skills under `.agents/skills/` when a global
skill has the same name, unless the user explicitly selects another path.
- Avoid repeated reads and equivalent verification commands once enough - Avoid repeated reads and equivalent verification commands once enough
evidence exists. evidence exists.
- Search for relevant symbols/headings before reading long files; return only
matching ranges. If output is truncated, narrow the query instead of repeating
a full read. Keep reusable raw logs in task artifacts and report the evidence.
- Reuse authorization already given in the conversation. Resolve routine choices
within that scope and continue independent work while a material question is
pending. Before requesting missing approval, prepare the concrete result that
is already authorized; retain explicit merge and release gates.
## Task-Specific Guidance ## Worktree and Disk Hygiene
Read only the reference needed for the current task, once per unchanged context: - Start implementation from the latest `origin/main` and confirm the requested
change is not already present.
- An existing clean, isolated task worktree is sufficient. Create another
worktree only when the current checkout is shared, dirty with unrelated work,
or belongs to another task.
- Never commit from a shared checkout.
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
- Do not include agent, tool, contributor, account, or organization names in
branch names.
- Push to the user-requested remote or the repository's configured push remote.
Do not hard-code or infer a remote from an account name.
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
Re-check before a broad gate when space is tight.
- Remove only task-owned temporary/build artifacts. Never delete another task's
worktree or uncommitted data.
- At handoff, mention disk or cleanup details only when they affected execution
or artifacts/worktrees remain intentionally.
- Before code changes or artifact-heavy work, read [implementation rules](.agents/references/implementation.md). ## Change Style
For a read-only code review, use its change-style and boundary sections as needed.
- Before commits, pushes, PR creation/updates, or posting to PRs/issues/discussions, - Preserve existing control flow unless changing it is required for correctness.
read [Git and PR rules](.agents/references/pull-requests.md). - Prefer a direct local edit over new files, wrappers, managers, or speculative
Reuse existing authorization; a reference does not authorize posting, merging, or publishing. abstractions.
- Preserve unrelated work. Never commit from a shared checkout or delete another task's artifacts. - Add a helper only when it removes current duplication, names a real domain
- Source comments, commits, PR titles, and PR bodies are in English. boundary, or isolates a non-trivial invariant.
- Remove an in-scope path superseded by the change. If compatibility requires it,
adapt at the boundary to one canonical core and use the repository's
`RUSTFS_COMPAT_TODO` policy.
- Comments explain non-obvious invariants or reasons. Do not narrate code or
record change history.
- Mention unrelated problems when useful; do not fix them in a narrow task.
## Reuse and Boundary Rules
- Before adding helpers, constants, fixtures, or wrappers, search the touched
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
direct dependencies.
- Reuse requires matching semantics: normalization, error types, deadlines,
durability, and compatibility must fit the call site. A narrowly named local
helper is better than forced reuse with different semantics.
- Validate untrusted input at its trust boundary, then trust the validated type.
Values crossing disk, RPC, persistence, or version boundaries remain
untrusted at every consumer.
- Re-check boundary values immediately before destructive actions such as
delete, overwrite, or quorum decisions.
- Every new branch needs a concrete triggering input/state. For decoded or peer
data, corruption and mixed-version input are valid triggers.
- Required values must return a typed error when absent or corrupt; do not use a
default that converts corruption into a plausible result.
- Attach error context once where it is actionable. Do not erase typed errors
below aggregation or quorum layers.
## Sources of Truth ## Sources of Truth
@@ -116,25 +147,73 @@ requested adversarial/design reviews, and agent-instruction changes that alter
execution. Ordinary questions, diagnoses, status reports, non-adversarial code execution. Ordinary questions, diagnoses, status reports, non-adversarial code
reviews, and low-risk planning do not trigger it. reviews, and low-risk planning do not trigger it.
For applicable work and substantial PR reviews, read the [risk tiers and review shape](.agents/references/adversarial-validation.md). Risk and review shape:
Load only the matching domain probes; ordinary reviews do not become adversarial
merely because this reference exists.
A review has no finding quota; `No findings` is a complete outcome. A request to - **Exempt:** documentation, comments, formatting, or typos with no runtime,
find problems is not evidence that a defect exists. Before reporting a candidate, build, test, or agent-execution effect.
check callers, invariants, and existing tests for evidence that disproves it. - **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
Findings need `file:line` and a concrete failure or violation of an explicit changes. Run correctness and simplicity lenses.
requirement. Missing required tests/checks are verification gaps, not proof of a - **Standard:** localized behavior changes. Run one integrated final-diff pass
runtime bug; name the unprotected behavior or unmet gate. Keep optional style or covering correctness, simplicity, and test coverage; add only domain lenses
refactoring preferences out of defect findings unless that review was requested. matched by the diff.
- **High risk / substantial PR review:** high risk includes locking,
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
Fix or rebut supported findings within the authorized scope. Once the required Available domain lenses are security, concurrency/durability, compatibility,
passes are complete, stop. Reopen only for changed code, new evidence, an and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
unresolved finding, or an explicit re-review request; an unchanged diff does not explicit adversarial request, a high-risk change, or a substantial PR review;
need another pass at every conversation turn or workflow handoff. then read only its matching role references. A routine standard pass does not
load the playbook unless the reviewer needs a RustFS-specific probe.
A finding must name a concrete input/state/interleaving and wrong outcome, or a
specific missing regression check, with `file:line`. Resolve it by fixing the
diff or rebutting it with code-path/test/invariant evidence. After a non-trivial
fix, rerun only affected lenses.
For high-risk PRs, record one concise verdict per covered lens in the PR body. For high-risk PRs, record one concise verdict per covered lens in the PR body.
## Pull Request Lifecycle
- Creating or updating a PR includes one immediate snapshot of checks,
mergeability, reviews, and unresolved threads.
- Unless the user explicitly requests monitoring, a release workflow requires
it, or an automation already owns it, hand off after the PR is open with the
current state and next event to watch. Do not delay ordinary handoff with
fixed quiet-period sleeps.
- For requested monitoring, use event-driven or bounded waits. Report only state
changes, actionable failures, or a meaningful prolonged delay.
- Investigate failures/comments before changing code. Fix task-attributable
issues, rerun affected verification, push, reply or resolve the thread, then
resume the requested monitor.
- Never merge without required reviewer approval or explicit authority.
- After an observed merge, verify the commit reached the base, then clean the
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
deletion was explicitly authorized.
## Git and PR Baseline
- Follow Conventional Commits; keep the subject at most 72 characters.
- Source comments, commits, PR titles, and PR bodies are in English.
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
needed and include commands actually run.
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
- PR/issue/discussion content must not contain the literal sequence `\n` or
hard-wrapped prose paragraphs.
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
content.
- Resolve review threads after the underlying issue is fixed. If declining a
suggestion, reply with a short evidence-based reason.
## Security Baseline ## Security Baseline
- Never commit secrets, credentials, or key material. - Never commit secrets, credentials, or key material.
@@ -171,6 +250,12 @@ Use `.agents/skills/rustfs-logging-governance/SKILL.md` for logging changes.
- `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map - `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map
serialization and new fields remain `#[serde(default)]` for older readers. serialization and new fields remain `#[serde(default)]` for older readers.
## Naming
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
functions/variables, and `PascalCase` types. Do not rename unrelated existing
violations.
## Scoped Guidance ## Scoped Guidance
Before editing, locate the nearest instructions with: Before editing, locate the nearest instructions with:
@@ -180,4 +265,4 @@ git ls-files '*AGENTS.md'
``` ```
The nearest file wins for domain invariants. Keep generic workflow and The nearest file wins for domain invariants. Keep generic workflow and
validation policy in this root file and its task-specific references. validation policy in this root file.
-6
View File
@@ -7,13 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed ### Fixed
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set. - **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801. - **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
+2 -2
View File
@@ -13,7 +13,7 @@ what Claude Code needs on top: commands and pointers.
cargo build --release --bin rustfs # production binary cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers cargo fmt --all # format (required before PR)
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests) make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04 make build-docker BUILD_OS=ubuntu22.04
@@ -42,5 +42,5 @@ make build-docker BUILD_OS=ubuntu22.04
Repo-wide domain invariants (dual internal metadata keys, defensive UUID Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Storage Invariants" — read them before touching metadata or "Cross-Cutting Domain Invariants" — read them before touching metadata or
tiering code. tiering code.
Generated
-2
View File
@@ -4057,7 +4057,6 @@ dependencies = [
"sha1 0.11.0", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
"suppaftp", "suppaftp",
"tempfile",
"time", "time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@@ -9907,7 +9906,6 @@ dependencies = [
"regex", "regex",
"rmp", "rmp",
"rmp-serde", "rmp-serde",
"rustfs-config",
"rustfs-utils", "rustfs-utils",
"s3s", "s3s",
"serde", "serde",
-9
View File
@@ -109,15 +109,6 @@ Star RustFS on GitHub and be instantly notified of new releases.
## Quickstart ## Quickstart
> [!IMPORTANT]
> **Pool expansion notice:**
>
> - A single-node single-drive (SNSD) deployment is supported only as a standalone local path. It cannot expand in place or be added as a Pool. To move to a multi-drive topology, create a new deployment and migrate data through S3.
> - Keep an existing multi-drive Pool's endpoints and Erasure Set width unchanged; expand by appending a new Pool. With ellipsis-based expansion, every Pool argument must contain an ellipsis expression and expand to at least two drive endpoints.
> - Single-node multi-drive Pools and multi-node Pools with one drive per node are allowed, subject to valid Erasure Set geometry and EC settings; acceptance does not guarantee host-failure tolerance.
>
> These topology rules follow MinIO, but automatic parity selection differs between the projects. See the [Pool layout compatibility and regression tests](docs/testing/pool-layout-compatibility.md) before expanding a deployment.
To get started with RustFS, follow these steps: To get started with RustFS, follow these steps:
### 1. One-click Installation (Option 1) ### 1. One-click Installation (Option 1)
-9
View File
@@ -89,15 +89,6 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
## 快速开始 ## 快速开始
> [!IMPORTANT]
> **Pool 扩容 Notice**
>
> - 单节点单盘(SNSD)部署仅支持使用本地路径独立运行,不支持原地扩容,也不能作为 Pool 加入集群。如需改为多盘拓扑,请创建新部署并通过 S3 迁移数据。
> - 已有多盘 Pool 的端点和 Erasure Set 宽度应保持不变,扩容应追加新的 Pool。使用省略号表达式扩容时,每个 Pool 参数都必须包含省略号表达式,并展开为至少两个磁盘端点。
> - 允许单节点多盘 Pool,也允许多节点、每节点一盘的 Pool,但必须满足 Erasure Set 布局和 EC 配置要求;配置合法不代表能够容忍整台主机故障。
>
> 这些拓扑规则与 MinIO 一致,但两者的默认 parity 选择方式存在差异。扩容前请阅读 [Pool 布局兼容性与回归测试说明](docs/testing/pool-layout-compatibility.md)。
请按照以下步骤快速上手 RustFS: 请按照以下步骤快速上手 RustFS:
### 1. 一键安装脚本 (选项 1) ### 1. 一键安装脚本 (选项 1)
+2 -5
View File
@@ -33,11 +33,8 @@ Applies to all paths under `crates/`.
## Type Casting ## Type Casting
- Never use `as` for numeric conversions that may truncate or overflow. Use - Never use `as` for numeric conversions that may truncate or overflow. Use `try_into()` with explicit error handling, or clamp with `value.max(0) as usize` when the domain is bounded.
`try_into()` with typed error handling; clamp or saturate only when the domain - `f64 as usize` saturates but is fragile; clamp to `[0, usize::MAX as f64]` first.
explicitly requires it.
- Before converting floating-point input to an integer, validate finiteness,
sign, and the destination range. A lower-bound clamp alone is insufficient.
- Treat every `as` cast in a PR review as a potential bug; require justification. - Treat every `as` cast in a PR review as a potential bug; require justification.
## Testing ## Testing
-400
View File
@@ -90,93 +90,6 @@ pub struct MrfIntent {
pub attempts: u8, pub attempts: u8,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MrfDurableRepairAnchor {
pub kind: MrfKind,
pub bucket: Arc<str>,
pub object: Arc<str>,
pub version_id: Option<[u8; 16]>,
pub scope: Option<MrfScope>,
pub lease: MrfIngressLease,
pub bucket_incarnation_id: Uuid,
}
impl MrfDurableRepairAnchor {
/// Build a dischargeable anchor only when the caller supplies the storage
/// incarnation and the original ingress lease. Legacy replay records lack
/// both pieces and therefore remain fail-closed.
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
if bucket_incarnation_id.is_nil() {
return None;
}
let lease = intent.lease?;
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
Some(Self {
kind: intent.kind,
bucket: intent.bucket.clone(),
object: intent.object.clone(),
version_id,
scope,
lease,
bucket_incarnation_id,
})
}
pub fn is_proven_by(&self, event: &MrfVerifiedRepairEvent) -> bool {
let Some(lease) = event.lease else {
return false;
};
self.kind == event.kind
&& self.bucket == event.bucket
&& self.object == event.object
&& self.version_id == event.version_id
&& self.scope == event.scope
&& self.lease == lease
&& self.bucket_incarnation_id == event.bucket_incarnation_id
}
}
/// Consume only anchors proven by a complete verified-repair identity. The
/// caller remains responsible for persisting the resulting anchor set before
/// deleting older replay files.
pub fn consume_verified_mrf_repair_events(anchors: &mut Vec<MrfDurableRepairAnchor>, events: &[MrfVerifiedRepairEvent]) -> usize {
let before = anchors.len();
anchors.retain(|anchor| !events.iter().any(|event| anchor.is_proven_by(event)));
before.saturating_sub(anchors.len())
}
/// Consume recorded verified repairs for one bucket without draining
/// unrelated or still-unmatched proofs. If the caller crashes before
/// persisting the retained anchor set, the proof may be replayed by repair
/// instead of silently deleting the old responsibility.
pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &mut Vec<MrfDurableRepairAnchor>) -> usize {
let Some(registry) = MRF_VERIFIED_REPAIR_EVENTS.get() else {
return 0;
};
let Ok(mut events) = registry.lock() else {
return 0;
};
let before = anchors.len();
let mut retained = std::collections::VecDeque::with_capacity(events.len());
while let Some(event) = events.pop_front() {
if event.bucket.as_ref() != bucket {
retained.push_back(event);
continue;
}
let mut matched = false;
anchors.retain(|anchor| {
let proven = anchor.is_proven_by(&event);
matched |= proven;
!proven
});
if !matched {
retained.push_back(event);
}
}
*events = retained;
before.saturating_sub(anchors.len())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MrfScope { pub struct MrfScope {
pub pool_index: u32, pub pool_index: u32,
@@ -473,27 +386,6 @@ pub fn try_send_mrf_intent_typed(
} }
} }
/// Acquire a fresh process-local lease for one durable replay record.
///
/// Journal records deliberately do not persist leases. A replay consumer must
/// call this before submitting the record so a later verified repair event can
/// identify the exact replay admission. Replay does not reserve the live
/// producer coalescer key: the replay queue first deduplicates legacy records,
/// then manager admission owns task-level deduplication with live producers.
pub fn try_rearm_mrf_replay_intent(intent: &mut MrfIntent) -> MrfIngressResult {
if intent.lease.is_some() {
return MrfIngressResult::Enqueued;
}
if intent.bucket.len() > MRF_MAX_IDENTITY_COMPONENT || intent.object.len() > MRF_MAX_IDENTITY_COMPONENT {
return MrfIngressResult::Dropped(MrfDropReason::OversizedIdentity);
}
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
intent.version_id = version_id;
intent.scope = scope;
intent.lease = Some(MrfIngressLease::new(NEXT_MRF_LEASE.fetch_add(1, Ordering::Relaxed)));
MrfIngressResult::Enqueued
}
/// Release the ingress key once the consumer owns the intent. /// Release the ingress key once the consumer owns the intent.
pub fn release_mrf_intent(intent: &MrfIntent) { pub fn release_mrf_intent(intent: &MrfIntent) {
release_mrf_identity(intent.kind, &intent.bucket, &intent.object, intent.version_id, intent.scope, intent.lease); release_mrf_identity(intent.kind, &intent.bucket, &intent.object, intent.version_id, intent.scope, intent.lease);
@@ -540,33 +432,12 @@ pub struct MrfRepairedEvent {
pub version_id: Option<[u8; 16]>, pub version_id: Option<[u8; 16]>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MrfVerifiedRepairDisposition {
Repaired,
VerifiedHealthy,
AuthoritativelyAbsent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrfVerifiedRepairEvent {
pub kind: MrfKind,
pub bucket: Arc<str>,
pub object: Arc<str>,
pub version_id: Option<[u8; 16]>,
pub scope: Option<MrfScope>,
pub lease: Option<MrfIngressLease>,
pub bucket_incarnation_id: Uuid,
pub disposition: MrfVerifiedRepairDisposition,
}
/// Bound on the repaired-event backlog. Notices are best-effort hints; when /// Bound on the repaired-event backlog. Notices are best-effort hints; when
/// the ring is full the oldest are dropped and the affected ledger entries /// the ring is full the oldest are dropped and the affected ledger entries
/// simply expire through their own attempts/age limits. /// simply expire through their own attempts/age limits.
const MRF_REPAIRED_EVENT_CAP: usize = 4096; const MRF_REPAIRED_EVENT_CAP: usize = 4096;
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new(); static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfVerifiedRepairEvent>>> =
OnceLock::new();
/// Record a legacy notification for compatibility. This is not an /// Record a legacy notification for compatibility. This is not an
/// acknowledgement of storage verification or durable repair completion. /// acknowledgement of storage verification or durable repair completion.
@@ -607,43 +478,6 @@ pub fn take_mrf_repaired_events_for(bucket: &str) -> Vec<MrfRepairedEvent> {
taken taken
} }
/// Record a storage-owned MRF completion proof. Unlike the legacy repaired
/// event, this identity is complete enough for future durable ledgers to make
/// an exact responsibility decision.
pub fn note_mrf_verified_repair(event: MrfVerifiedRepairEvent) {
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
let Ok(mut events) = registry.lock() else {
return;
};
if events.len() >= MRF_REPAIRED_EVENT_CAP {
events.pop_front();
}
events.push_back(event);
}
/// Take verified repair events recorded for `bucket`, leaving other buckets'
/// proofs in place. Consumers still have to match kind, object, version, scope
/// lease and incarnation before discharging durable responsibility.
pub fn take_mrf_verified_repair_events_for(bucket: &str) -> Vec<MrfVerifiedRepairEvent> {
let Some(registry) = MRF_VERIFIED_REPAIR_EVENTS.get() else {
return Vec::new();
};
let Ok(mut events) = registry.lock() else {
return Vec::new();
};
let mut taken = Vec::new();
let mut retained = std::collections::VecDeque::with_capacity(events.len());
while let Some(event) = events.pop_front() {
if event.bucket.as_ref() == bucket {
taken.push(event);
} else {
retained.push_back(event);
}
}
*events = retained;
taken
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -712,212 +546,6 @@ mod tests {
assert_eq!(metadata_scope, None); assert_eq!(metadata_scope, None);
} }
#[test]
fn durable_repair_anchor_requires_lease_and_bucket_incarnation() {
let mut intent = MrfIntent {
bucket: Arc::from("durable-anchor-bucket"),
object: Arc::from("object"),
version_id: Some([0; 16]),
kind: MrfKind::PartialWrite,
scope: Some(MrfScope {
pool_index: 1,
set_index: 2,
}),
lease: None,
enqueued_at_ms: 0,
attempts: 0,
};
assert!(
MrfDurableRepairAnchor::from_intent(&intent, Uuid::new_v4()).is_none(),
"legacy replay records without the ingress lease must remain anchored"
);
intent.lease = Some(MrfIngressLease::new(7));
assert!(
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none(),
"nil bucket incarnation cannot prove durable successor ownership"
);
let anchor = MrfDurableRepairAnchor::from_intent(&intent, Uuid::new_v4())
.expect("complete identity should create a durable repair anchor");
assert_eq!(anchor.version_id, None, "nil UUID is canonicalized before matching");
assert_eq!(
anchor.scope,
Some(MrfScope {
pool_index: 1,
set_index: 2
})
);
}
#[test]
fn durable_replay_rearm_assigns_a_fresh_dischargeable_lease() {
let unique = Uuid::new_v4();
let mut intent = MrfIntent {
bucket: Arc::from(format!("replay-{unique}")),
object: Arc::from("object"),
version_id: Some([0; 16]),
kind: MrfKind::PartialWrite,
scope: Some(MrfScope {
pool_index: 2,
set_index: 3,
}),
lease: None,
enqueued_at_ms: 1,
attempts: 0,
};
assert_eq!(try_rearm_mrf_replay_intent(&mut intent), MrfIngressResult::Enqueued);
assert_eq!(intent.version_id, None, "nil versions remain canonical during replay");
assert!(intent.lease.is_some(), "replay admission must carry a fresh lease");
assert!(
MrfDurableRepairAnchor::from_intent(&intent, Uuid::new_v4()).is_some(),
"a rearmed replay record can participate in exact durable proof matching"
);
release_mrf_intent(&intent);
}
#[test]
fn verified_repair_events_consume_only_exact_durable_anchors() {
let bucket = Arc::<str>::from("proof-bucket");
let object = Arc::<str>::from("object");
let incarnation = Uuid::new_v4();
let lease = MrfIngressLease::new(11);
let anchor = MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: bucket.clone(),
object: object.clone(),
version_id: Some([3; 16]),
scope: Some(MrfScope {
pool_index: 4,
set_index: 5,
}),
lease,
bucket_incarnation_id: incarnation,
};
let event = MrfVerifiedRepairEvent {
kind: anchor.kind,
bucket,
object,
version_id: anchor.version_id,
scope: anchor.scope,
lease: Some(lease),
bucket_incarnation_id: incarnation,
disposition: MrfVerifiedRepairDisposition::Repaired,
};
for rejected in [
MrfVerifiedRepairEvent {
lease: None,
..event.clone()
},
MrfVerifiedRepairEvent {
lease: Some(MrfIngressLease::new(12)),
..event.clone()
},
MrfVerifiedRepairEvent {
bucket_incarnation_id: Uuid::new_v4(),
..event.clone()
},
MrfVerifiedRepairEvent {
version_id: Some([4; 16]),
..event.clone()
},
MrfVerifiedRepairEvent {
scope: Some(MrfScope {
pool_index: 4,
set_index: 6,
}),
..event.clone()
},
MrfVerifiedRepairEvent {
kind: MrfKind::DecodeFailure,
..event.clone()
},
MrfVerifiedRepairEvent {
bucket: Arc::from("other-bucket"),
..event.clone()
},
MrfVerifiedRepairEvent {
object: Arc::from("other"),
..event.clone()
},
] {
let mut retained = vec![anchor.clone()];
assert_eq!(consume_verified_mrf_repair_events(&mut retained, &[rejected]), 0);
assert_eq!(retained, vec![anchor.clone()]);
}
let mut retained = vec![anchor];
assert_eq!(consume_verified_mrf_repair_events(&mut retained, &[event]), 1);
assert!(retained.is_empty());
}
#[test]
fn recorded_verified_repair_consumer_retains_unmatched_proofs() {
let bucket = Arc::<str>::from(format!("recorded-proof-{}", Uuid::new_v4()));
let other_bucket = Arc::<str>::from(format!("recorded-proof-other-{}", Uuid::new_v4()));
let incarnation = Uuid::new_v4();
let lease = MrfIngressLease::new(21);
let retained_anchor = MrfDurableRepairAnchor {
kind: MrfKind::PartialWrite,
bucket: bucket.clone(),
object: Arc::from("retained"),
version_id: Some([7; 16]),
scope: Some(MrfScope {
pool_index: 1,
set_index: 2,
}),
lease,
bucket_incarnation_id: incarnation,
};
let waiting_anchor = MrfDurableRepairAnchor {
object: Arc::from("waiting"),
lease: MrfIngressLease::new(22),
..retained_anchor.clone()
};
let matched_event = MrfVerifiedRepairEvent {
kind: retained_anchor.kind,
bucket: bucket.clone(),
object: retained_anchor.object.clone(),
version_id: retained_anchor.version_id,
scope: retained_anchor.scope,
lease: Some(retained_anchor.lease),
bucket_incarnation_id: retained_anchor.bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::Repaired,
};
let same_bucket_unmatched = MrfVerifiedRepairEvent {
object: Arc::from("future"),
lease: Some(MrfIngressLease::new(23)),
..matched_event.clone()
};
let other_bucket_event = MrfVerifiedRepairEvent {
bucket: other_bucket.clone(),
..matched_event.clone()
};
note_mrf_verified_repair(matched_event);
note_mrf_verified_repair(same_bucket_unmatched.clone());
note_mrf_verified_repair(other_bucket_event.clone());
let mut anchors = vec![retained_anchor, waiting_anchor.clone()];
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&bucket, &mut anchors), 1);
assert_eq!(anchors, vec![waiting_anchor]);
let remaining_bucket_events = take_mrf_verified_repair_events_for(&bucket);
assert_eq!(
remaining_bucket_events,
vec![same_bucket_unmatched],
"same-bucket proofs without a retained anchor must remain available"
);
let remaining_other_events = take_mrf_verified_repair_events_for(&other_bucket);
assert_eq!(
remaining_other_events,
vec![other_bucket_event],
"proofs for other buckets must not be drained by this consumer"
);
}
#[tokio::test] #[tokio::test]
async fn try_send_delivers_and_respects_capacity() { async fn try_send_delivers_and_respects_capacity() {
let mut receiver = init_mrf_channel().expect("first initialization should succeed"); let mut receiver = init_mrf_channel().expect("first initialization should succeed");
@@ -982,32 +610,4 @@ mod tests {
assert_eq!(flooded.len(), MRF_REPAIRED_EVENT_CAP); assert_eq!(flooded.len(), MRF_REPAIRED_EVENT_CAP);
assert_eq!(flooded[0].object.as_ref(), "object-9", "the oldest notices past the cap are dropped"); assert_eq!(flooded[0].object.as_ref(), "object-9", "the oldest notices past the cap are dropped");
} }
#[test]
fn verified_repair_events_preserve_full_identity_and_bucket_scope() {
let bucket_incarnation_id = Uuid::new_v4();
let event = MrfVerifiedRepairEvent {
kind: MrfKind::PartialWrite,
bucket: Arc::from("verified-bucket-a"),
object: Arc::from("object-a"),
version_id: Some([4u8; 16]),
scope: Some(MrfScope {
pool_index: 2,
set_index: 3,
}),
lease: Some(MrfIngressLease::new(42)),
bucket_incarnation_id,
disposition: MrfVerifiedRepairDisposition::Repaired,
};
note_mrf_verified_repair(event.clone());
note_mrf_verified_repair(MrfVerifiedRepairEvent {
bucket: Arc::from("verified-bucket-b"),
..event.clone()
});
let taken = take_mrf_verified_repair_events_for("verified-bucket-a");
assert_eq!(taken, vec![event]);
assert!(take_mrf_verified_repair_events_for("verified-bucket-a").is_empty());
assert_eq!(take_mrf_verified_repair_events_for("verified-bucket-b").len(), 1);
}
} }
+1 -16
View File
@@ -66,11 +66,6 @@ Current guidance:
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node. - `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
## S3 API environment variables
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
## Distributed endpoint locality ## Distributed endpoint locality
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery. - `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
@@ -158,24 +153,14 @@ concurrently. Small direct PUTs stay on the legacy path.
- default is `0`. - default is `0`.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS` - `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full. - how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
- default is `10000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool. - default is `30000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing.
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. Keep it with margin below the shortest such timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING` - `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting. - maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
- default is `0`, which derives 16 times the permit limit (512 at stock settings). - default is `0`, which derives 16 times the permit limit (512 at stock settings).
- each queued HTTP/1 part holds whatever unread body the client already pushed into the connection's kernel receive buffer (an HTTP/2 part holds up to its flow-control window in process memory), so this depth also bounds that memory. RustFS leaves the receive buffer to kernel autotuning (see `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` below), which keeps an unread connection at the kernel's initial size (128 KiB on current Linux).
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS` - `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
- experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled. - experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled.
- default is disabled; enabling it with limit `0` disables foreground write admission entirely. - default is disabled; enabling it with limit `0` disables foreground write admission entirely.
## HTTP listener socket environment variables
- `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES`
- fixed `SO_RCVBUF` for the API listener, inherited by every accepted socket; `0` leaves the receive buffer to kernel autotuning.
- default is `0`. Earlier releases hard-coded 4 MiB, which Linux doubles to 8 MiB and which disables autotuning, so every connection whose body was not being read yet (a multipart part queued for a foreground write permit) could accumulate up to 8 MiB of unread body in kernel memory; at SDK-default multipart concurrency that was enough to push a node into TCP memory pressure.
- with autotuning the per-connection receive ceiling is the kernel's (`net.ipv4.tcp_rmem` max, 6 MiB on stock Linux) instead of the former fixed 8 MiB, so a single very high-bandwidth-delay connection may see a somewhat lower ceiling; raise `net.ipv4.tcp_rmem` first, and set this variable only on kernels without receive-buffer autotuning (illumos/Solaris) or where the sysctl cannot be changed.
- the send buffer stays fixed at 4 MiB because the stock Linux send autotuning ceiling (`net.ipv4.tcp_wmem` max, 4 MiB) is lower than a GB-level response stream needs.
## Remote tier timeout environment variables ## Remote tier timeout environment variables
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS` - `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
-17
View File
@@ -90,20 +90,3 @@ pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited). /// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0; pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
/// Maximum retained versions per object.
///
/// The default follows MinIO and is effectively unlimited for practical
/// deployments. Operators can lower it to bound per-object metadata growth.
/// Environment variable: RUSTFS_API_OBJECT_MAX_VERSIONS
/// MinIO-compatible alias: MINIO_API_OBJECT_MAX_VERSIONS
/// Example: RUSTFS_API_OBJECT_MAX_VERSIONS=50000
pub const ENV_API_OBJECT_MAX_VERSIONS: &str = "RUSTFS_API_OBJECT_MAX_VERSIONS";
/// Default and maximum accepted value for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
#[cfg(target_pointer_width = "64")]
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: usize = 9_223_372_036_854_775_807;
/// Default and maximum accepted value for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
#[cfg(not(target_pointer_width = "64"))]
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: usize = usize::MAX;
+8 -56
View File
@@ -376,39 +376,21 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
/// ///
/// SDK-default multipart clients send every part of an upload concurrently, so /// SDK-default multipart clients send every part of an upload concurrently, so
/// a single node routinely sees several times more parts in flight than the /// a single node routinely sees several times more parts in flight than the
/// permit pool allows. A queued part waits before body ingest, so the pool /// permit pool allows. Those parts have not ingested a body yet, so queueing
/// still bounds the number of parts being written, but the wait is not free: /// them costs a connection rather than memory or internode streams; the pool
/// RustFS does not read the request body while the part is queued (hyper only /// still bounds the number of parts being written. The wait is long enough for
/// sends `100 Continue` once the body is first polled, and the AWS SDKs send /// an ordinary queue to drain on modest hardware, and a part that cannot get a
/// the body after a 1-3 s `Expect: 100-continue` grace anyway), so the /// permit within it fails with S3 `SlowDown`/503 for the client to retry.
/// client's socket write stalls once the kernel buffers fill, and whatever /// `0` rejects immediately when the pool is full.
/// timeout the client or an intermediary has configured decides the outcome.
/// botocore applies its `connect_timeout` (60 s) to the body write, the AWS
/// SDK for Java v2 has a 30 s socket write timeout, and MinIO bounds the same
/// wait with a 10 s request deadline. The wait must leave margin under the
/// shortest of those, not merely fall below an SDK default, so the part
/// receives S3 `SlowDown`/503 for the client to retry instead of losing its
/// connection (issue #7385). `0` rejects immediately when the pool is full.
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS"; "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_000; pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 30_000;
// A queued part holds the client's body write open for the whole wait. The
// shortest write timeout among mainstream S3 SDKs is the AWS SDK for Java v2's
// 30 s socket write timeout; keep the compiled default at no more than a third
// of it. This locks only the default; the environment variable may still raise
// the wait past any client timeout.
const _: () = assert!(DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS * 3 <= 30_000);
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process. /// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
/// ///
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without /// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
/// waiting, so a genuinely saturated node still fails fast instead of holding /// waiting, so a genuinely saturated node still fails fast instead of holding
/// an unbounded set of connections open for the whole wait timeout. Each /// an unbounded set of connections open for the whole wait timeout.
/// queued HTTP/1 part also holds whatever unread body the client already
/// pushed into that connection's kernel receive buffer, and a queued HTTP/2
/// part holds up to its flow-control window in process memory, so the depth
/// bounds socket and window memory as well as connections.
/// `0` derives the depth from the permit limit. /// `0` derives the depth from the permit limit.
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING"; pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0; pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
@@ -609,36 +591,6 @@ pub const ENV_OBJECT_LOCK_RPC_TIMEOUT_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_TIMEOUT
/// Default remote lock RPC transport timeout: 3000 milliseconds. /// Default remote lock RPC transport timeout: 3000 milliseconds.
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000; pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
/// Environment variable for the minimum interval between evictions of the
/// cached lock RPC channel to one peer, in milliseconds.
///
/// A lock RPC that fails on transport, or that times out while the peer has
/// not completed any lock RPC for two deadlines, evicts the shared HTTP/2
/// channel so the next request re-dials. Evictions are rate limited per peer
/// so one slow lock endpoint cannot drive a reset/GOAWAY/reconnect loop
/// (issue #7363). `0` disables the cooldown.
///
/// Default: 5000 milliseconds.
pub const ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS";
/// Default minimum interval between lock RPC channel evictions per peer: 5000 milliseconds.
pub const DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: u64 = 5000;
/// Environment variable for how many timed-out lock RPCs per peer may keep
/// running in the background instead of being cancelled.
///
/// Cancelling a timed-out stream sends `RST_STREAM`; enough of them make the
/// peer answer `GOAWAY too_many_resets` and drop every stream on the
/// connection. A detached RPC ends on its own within the internode RPC
/// timeout, and a lock it acquires after its caller gave up is released
/// immediately. Beyond this budget timed-out RPCs are cancelled as before.
///
/// Default: 256.
pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT";
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
/// Environment variable to enable object namespace lock diagnostics. /// Environment variable to enable object namespace lock diagnostics.
/// ///
/// When enabled, RustFS emits slow lock acquisition and long lock hold /// When enabled, RustFS emits slow lock acquisition and long lock hold
-18
View File
@@ -159,24 +159,6 @@ pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
pub const ENV_HTTP1_MAX_BUF_SIZE: &str = "RUSTFS_HTTP1_MAX_BUF_SIZE"; pub const ENV_HTTP1_MAX_BUF_SIZE: &str = "RUSTFS_HTTP1_MAX_BUF_SIZE";
pub const DEFAULT_HTTP1_MAX_BUF_SIZE: usize = 64 * 1024; // 64 KB pub const DEFAULT_HTTP1_MAX_BUF_SIZE: usize = 64 * 1024; // 64 KB
/// Environment variable for a fixed kernel receive buffer (`SO_RCVBUF`, bytes)
/// on the API listener. Default: 0, which leaves the buffer to kernel
/// autotuning.
///
/// A fixed `SO_RCVBUF` is inherited by every accepted socket and disables
/// receive-buffer autotuning, so a connection whose request body is not being
/// read yet (a multipart part queued for a foreground write permit) lets up to
/// the fixed size of unread body accumulate in kernel memory — Linux doubles
/// the requested value, so the former hard-coded 4 MiB held up to 8 MiB per
/// queued connection (issue #7385). Autotuning keeps an unread connection at
/// the kernel's initial size and grows only connections that are being
/// drained. Set this only on kernels without receive-buffer autotuning
/// (illumos/Solaris) or on very high-bandwidth-delay links where the kernel's
/// autotuning ceiling (`net.ipv4.tcp_rmem` on Linux) is too low and cannot be
/// raised.
pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES";
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
/// Environment variable for the S3 request-body inter-chunk read timeout /// Environment variable for the S3 request-body inter-chunk read timeout
/// (seconds). Default: 300. Set to 0 to disable. /// (seconds). Default: 300. Set to 0 to disable.
/// ///
-3
View File
@@ -144,6 +144,3 @@ russh = { workspace = true, features = ["serde"] }
russh-sftp = { workspace = true } russh-sftp = { workspace = true }
zip.workspace = true zip.workspace = true
clap = { workspace = true, features = ["derive", "env"] } clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
tempfile.workspace = true
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use crate::common::{RustFSTestClusterEnvironment, init_logging, local_http_client, signal_process}; use crate::common::{RustFSTestClusterEnvironment, init_logging, local_http_client};
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use http::header::HOST; use http::header::HOST;
use reqwest::StatusCode; use reqwest::StatusCode;
@@ -22,6 +22,7 @@ use rustfs_signer::sign_v4;
use s3s::Body; use s3s::Body;
use serde::Deserialize; use serde::Deserialize;
use std::error::Error; use std::error::Error;
use std::process::Command;
use tokio::time::{Duration, sleep, timeout}; use tokio::time::{Duration, sleep, timeout};
use uuid::Uuid; use uuid::Uuid;
@@ -81,6 +82,15 @@ async fn parse_json_response<T: serde::de::DeserializeOwned>(
Ok(serde_json::from_slice(&body)?) Ok(serde_json::from_slice(&body)?)
} }
fn signal_process(pid: u32, signal: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let output = Command::new("kill").arg(format!("-{signal}")).arg(pid.to_string()).output()?;
if output.status.success() {
return Ok(());
}
Err(format!("kill -{signal} {pid} failed: {}", String::from_utf8_lossy(&output.stderr)).into())
}
fn offline_server_count(info: &InfoMessage) -> usize { fn offline_server_count(info: &InfoMessage) -> usize {
info.servers info.servers
.as_ref() .as_ref()
-54
View File
@@ -44,7 +44,6 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::error::Error; use std::error::Error;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::time::{Duration, Instant, sleep};
use tracing::info; use tracing::info;
use uuid::Uuid; use uuid::Uuid;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -375,33 +374,6 @@ pub(crate) fn census_object_version_on_disk(
}) })
} }
/// Wait for the background PUT tail to commit every physical part on one disk.
/// Invalid metadata remains an immediate error instead of a retryable absence.
pub(crate) async fn wait_for_complete_physical_shard_on_disk(
disk: &Path,
bucket: &str,
key: &str,
version_id: Option<&str>,
timeout: Duration,
) -> ChaosResult<VersionShardCensus> {
let deadline = Instant::now() + timeout;
loop {
let census = census_object_version_on_disk(disk, bucket, key, version_id)?;
if census.is_complete() && !census.expected_part_numbers.is_empty() {
return Ok(census);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"physical shard for {bucket}/{key}@{version_id:?} on {} did not become complete within {timeout:?}: {census:?}",
disk.display()
)
.into());
}
sleep(remaining.min(Duration::from_millis(50))).await;
}
}
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the /// `POST` a signed (SigV4, service `s3`) admin request without relying on the
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal /// external `awscurl` binary. Mirrors the admin heal calls used by the heal
/// regression suite. /// regression suite.
@@ -479,30 +451,4 @@ mod tests {
assert!(expected.matches_manifest(&expected)); assert!(expected.matches_manifest(&expected));
assert!(!changed.matches_manifest(&expected)); assert!(!changed.matches_manifest(&expected));
} }
#[tokio::test]
async fn physical_shard_readiness_fails_closed_with_last_census() {
let disk = tempfile::tempdir().expect("temporary disk");
let error = wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "missing", None, Duration::ZERO)
.await
.expect_err("missing physical shards must fail the baseline gate");
assert!(error.to_string().contains("has_xl_meta: false"));
assert!(error.to_string().contains("bucket/missing"));
}
#[tokio::test]
async fn physical_shard_readiness_does_not_retry_invalid_metadata() {
let disk = tempfile::tempdir().expect("temporary disk");
let object = disk.path().join("bucket").join("corrupt");
std::fs::create_dir_all(&object).expect("object directory");
std::fs::write(object.join("xl.meta"), b"invalid metadata").expect("corrupt metadata fixture");
let error = tokio::time::timeout(
Duration::from_secs(1),
wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "corrupt", None, Duration::from_secs(30)),
)
.await
.expect("corrupt metadata must fail immediately")
.expect_err("invalid metadata must not be accepted as a complete baseline");
assert!(!error.to_string().contains("did not become complete"));
}
} }
+15 -128
View File
@@ -34,7 +34,7 @@ use serde_json;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs as stdfs; use std::fs as stdfs;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::net::{Ipv6Addr, SocketAddr}; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Once; use std::sync::Once;
@@ -57,8 +57,6 @@ const RUSTFS_FULL_FEATURE: &str = "full";
const TEST_PORT_MIN: u16 = 20_000; const TEST_PORT_MIN: u16 = 20_000;
// Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers. // Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers.
const TEST_PORT_RANGE: u16 = 10_000; const TEST_PORT_RANGE: u16 = 10_000;
const TEST_PORT_MIN_ENV: &str = "RUSTFS_E2E_TEST_PORT_MIN";
const TEST_PORT_RANGE_ENV: &str = "RUSTFS_E2E_TEST_PORT_RANGE";
const TEST_PORT_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port"; const TEST_PORT_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port";
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock"; const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30); const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
@@ -101,74 +99,22 @@ impl Drop for PortAllocatorGuard {
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] fn advance_test_port(port: u16) -> u16 {
struct TestPortAllocatorConfig { let offset = (port - TEST_PORT_MIN + 1) % TEST_PORT_RANGE;
min: u16, TEST_PORT_MIN + offset
range: u16,
} }
impl TestPortAllocatorConfig { fn seeded_test_port() -> u16 {
fn max_exclusive(self) -> u32 { let offset = (Uuid::new_v4().as_u128() % u128::from(TEST_PORT_RANGE)) as u16;
u32::from(self.min) + u32::from(self.range) TEST_PORT_MIN + offset
}
fn contains(self, port: &u16) -> bool {
(u32::from(self.min)..self.max_exclusive()).contains(&u32::from(*port))
}
} }
fn parse_test_port_allocator_config( fn read_next_test_port() -> u16 {
min_override: Option<&str>,
range_override: Option<&str>,
) -> Result<TestPortAllocatorConfig, Box<dyn std::error::Error + Send + Sync>> {
let min = match min_override {
Some(value) => value
.parse::<u16>()
.map_err(|err| format!("{TEST_PORT_MIN_ENV} must be a valid u16: {err}"))?,
None => TEST_PORT_MIN,
};
let range = match range_override {
Some(value) => value
.parse::<u16>()
.map_err(|err| format!("{TEST_PORT_RANGE_ENV} must be a valid u16: {err}"))?,
None => TEST_PORT_RANGE,
};
if range == 0 {
return Err(format!("{TEST_PORT_RANGE_ENV} must be greater than zero").into());
}
if min < 1024 {
return Err(format!("{TEST_PORT_MIN_ENV} must be at least 1024").into());
}
let max_exclusive = u32::from(min) + u32::from(range);
if max_exclusive > u32::from(u16::MAX) + 1 {
return Err(format!("{TEST_PORT_MIN_ENV} + {TEST_PORT_RANGE_ENV} exceeds u16 port space").into());
}
Ok(TestPortAllocatorConfig { min, range })
}
fn test_port_allocator_config() -> Result<TestPortAllocatorConfig, Box<dyn std::error::Error + Send + Sync>> {
parse_test_port_allocator_config(
std::env::var(TEST_PORT_MIN_ENV).ok().as_deref(),
std::env::var(TEST_PORT_RANGE_ENV).ok().as_deref(),
)
}
fn advance_test_port(port: u16, config: TestPortAllocatorConfig) -> u16 {
let offset = (port - config.min + 1) % config.range;
config.min + offset
}
fn seeded_test_port(config: TestPortAllocatorConfig) -> u16 {
let offset = (Uuid::new_v4().as_u128() % u128::from(config.range)) as u16;
config.min + offset
}
fn read_next_test_port(config: TestPortAllocatorConfig) -> u16 {
stdfs::read_to_string(TEST_PORT_COUNTER_PATH) stdfs::read_to_string(TEST_PORT_COUNTER_PATH)
.ok() .ok()
.and_then(|value| value.trim().parse::<u16>().ok()) .and_then(|value| value.trim().parse::<u16>().ok())
.filter(|port| config.contains(port)) .filter(|port| (TEST_PORT_MIN..TEST_PORT_MIN + TEST_PORT_RANGE).contains(port))
.unwrap_or_else(|| seeded_test_port(config)) .unwrap_or_else(seeded_test_port)
} }
fn remove_stale_port_allocator_lock() { fn remove_stale_port_allocator_lock() {
@@ -264,15 +210,6 @@ pub fn local_http_client() -> HttpClient {
.expect("failed to build local reqwest client") .expect("failed to build local reqwest client")
} }
pub(crate) fn signal_process(pid: u32, signal: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let output = Command::new("kill").arg(format!("-{signal}")).arg(pid.to_string()).output()?;
if output.status.success() {
return Ok(());
}
Err(format!("kill -{signal} {pid} failed: {}", String::from_utf8_lossy(&output.stderr)).into())
}
pub(crate) async fn signed_s3_request( pub(crate) async fn signed_s3_request(
method: http::Method, method: http::Method,
url: &str, url: &str,
@@ -692,12 +629,11 @@ impl RustFSTestEnvironment {
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> { pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
use std::net::TcpListener; use std::net::TcpListener;
let _guard = PortAllocatorGuard::acquire().await?; let _guard = PortAllocatorGuard::acquire().await?;
let config = test_port_allocator_config()?; let mut next_port = read_next_test_port();
let mut next_port = read_next_test_port(config);
for _ in 0..config.range { for _ in 0..TEST_PORT_RANGE {
let port = next_port; let port = next_port;
next_port = advance_test_port(next_port, config); next_port = advance_test_port(next_port);
write_next_test_port(next_port)?; write_next_test_port(next_port)?;
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) { if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
@@ -1468,9 +1404,7 @@ impl RustFSTestClusterEnvironment {
return Err(format!("a volume proxy is already configured for node {node_idx}").into()); return Err(format!("a volume proxy is already configured for node {node_idx}").into());
} }
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?; let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
// Endpoint locality requires the server port to match. IPv6 loopback let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
// keeps that port while leaving the direct IPv4 S3 listener available.
let proxy = crate::fault_proxy::FaultProxy::start_on((Ipv6Addr::LOCALHOST, target.port()).into(), target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr()); self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy) Ok(proxy)
} }
@@ -2174,35 +2108,6 @@ mod tests {
); );
} }
#[test]
fn e2e_port_allocator_uses_default_range() {
assert_eq!(
parse_test_port_allocator_config(None, None).expect("default port allocator config"),
TestPortAllocatorConfig {
min: TEST_PORT_MIN,
range: TEST_PORT_RANGE
}
);
}
#[test]
fn e2e_port_allocator_accepts_explicit_test_range() {
let config = parse_test_port_allocator_config(Some("31000"), Some("128")).expect("explicit port range");
assert_eq!(advance_test_port(31127, config), 31000);
assert!(config.contains(&31000));
assert!(config.contains(&31127));
assert!(!config.contains(&31128));
}
#[test]
fn e2e_port_allocator_rejects_invalid_override() {
assert!(parse_test_port_allocator_config(Some("1023"), Some("1")).is_err());
assert!(parse_test_port_allocator_config(Some("65000"), Some("1000")).is_err());
assert!(parse_test_port_allocator_config(Some("31000"), Some("0")).is_err());
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
}
#[test] #[test]
fn resolves_rustfs_binary_in_configured_cargo_target_directory() { fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
let workspace = Path::new("workspace"); let workspace = Path::new("workspace");
@@ -2375,7 +2280,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn volume_proxy_rewrites_cluster_volume_endpoint() { async fn volume_proxy_rewrites_cluster_volume_endpoint() {
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2)) let mut env = RustFSTestClusterEnvironment::new(1)
.await .await
.expect("cluster environment should allocate a node"); .expect("cluster environment should allocate a node");
let direct = env.nodes[0].address.clone(); let direct = env.nodes[0].address.clone();
@@ -2390,24 +2295,6 @@ mod tests {
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}"); assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
proxy.shutdown().await; proxy.shutdown().await;
for node in &env.nodes {
let local_port = node.address.parse::<SocketAddr>().expect("node address").port();
let local_paths = volumes
.split_whitespace()
.filter_map(|volume| {
let endpoint = reqwest::Url::parse(volume).expect("volume endpoint");
rustfs_utils::is_local_host(
endpoint.host().expect("volume endpoint host"),
endpoint.port().expect("volume endpoint port"),
local_port,
)
.expect("endpoint locality")
.then(|| endpoint.path().to_string())
})
.collect::<Vec<_>>();
assert_eq!(local_paths, node.data_dirs, "the proxy must preserve local disk ownership");
}
} }
#[test] #[test]
+1 -5
View File
@@ -59,9 +59,6 @@ const POOL_META_V3_ENV: [(&str, &str); 2] = [
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) enum DistLayout { pub(crate) enum DistLayout {
/// 3 nodes × 4 drives, one erasure pool. With `EC:4` this is the
/// release-evidence EC8+4 geometry.
ThreeByFourEc84,
/// 4 nodes × 4 drives, one erasure pool spanning every endpoint. /// 4 nodes × 4 drives, one erasure pool spanning every endpoint.
FourByFour, FourByFour,
/// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout). /// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout).
@@ -97,7 +94,6 @@ impl DistCluster {
pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult<Self> { pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult<Self> {
let topology = match layout { let topology = match layout {
DistLayout::ThreeByFourEc84 => ClusterTopology::single_pool_multidrive(3, DRIVES_PER_NODE),
DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE), DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE),
DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT), DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT),
DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]), DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]),
@@ -105,7 +101,7 @@ impl DistCluster {
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?; let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?;
let pool_storage_roots = match layout { let pool_storage_roots = match layout {
DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?), DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?),
DistLayout::ThreeByFourEc84 | DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None, DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None,
}; };
let mut owned_pool_dirs = Vec::new(); let mut owned_pool_dirs = Vec::new();
if let Some(roots) = pool_storage_roots.as_deref() { if let Some(roots) = pool_storage_roots.as_deref() {
@@ -1,250 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until};
use crate::chaos::{
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post, wait_for_complete_physical_shard_on_disk,
};
use crate::common::{init_logging, rustfs_binary_path};
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
const EC84_NODE_COUNT: usize = 3;
const EC84_DRIVES_PER_NODE: usize = 4;
const EC84_DATA_BLOCKS: usize = 8;
const EC84_PARITY_BLOCKS: usize = 4;
#[derive(Clone)]
struct ExpectedShard {
key: String,
body: Vec<u8>,
baseline: VersionShardCensus,
}
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
}
let erasure_index = census
.erasure_index
.ok_or_else(|| format!("object {key} did not record an erasure index: {census:?}"))?;
if !(1..=EC84_DATA_BLOCKS + EC84_PARITY_BLOCKS).contains(&erasure_index) {
return Err(format!("object {key} has out-of-range erasure index {erasure_index}: {census:?}").into());
}
if !census.is_complete() || census.expected_part_numbers.is_empty() {
return Err(format!("object {key} does not have complete physical shard evidence: {census:?}").into());
}
Ok(())
}
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
for key in keys {
let census = census_object_version_on_disk(drive, bucket, key, None)?;
if census.has_xl_meta {
return Err(format!("replacement drive unexpectedly retained {bucket}/{key}: {census:?}").into());
}
}
Ok(())
}
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
let mut expected = Vec::new();
for index in 0..4 {
let key = format!("ec84/prefix-{}/object-{index:04}.bin", index % 2);
let body = payload_for(&key, 10 * 1024 * 1024);
put_object(client, bucket, &key, body.clone()).await?;
expected.push(ExpectedShard {
key,
body,
baseline: VersionShardCensus {
version_id: None,
has_xl_meta: false,
data_dir: None,
erasure_index: None,
data_blocks: None,
parity_blocks: None,
expected_part_numbers: Default::default(),
present_part_fingerprints: Default::default(),
inline_data_fingerprint: None,
},
});
}
Ok(expected)
}
#[tokio::test]
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
init_logging();
let server_binary = rustfs_binary_path();
let evidence_run = restart_evidence_run(
&server_binary,
ScannerHealEvidenceCase {
id: "ec84-target-drive-restart",
oracle: "ec84-target-drive-restart.json",
evidence: "process-restart",
unclean_shutdown_marker: false,
topology: EvidenceTopology::new(3, 4),
storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some("12"),
},
)?;
let mut dist = DistCluster::start_with_env(
DistLayout::ThreeByFourEc84,
&[
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
("RUSTFS_HEAL_ENABLED", "true"),
("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false"),
("RUSTFS_HEAL_MRF_ENABLE", "false"),
("RUSTFS_SCANNER_ENABLED", "false"),
],
)
.await?;
assert_eq!(dist.cluster.nodes.len(), EC84_NODE_COUNT);
assert_eq!(dist.cluster.topology.drives_per_node, EC84_DRIVES_PER_NODE);
let bucket = unique_bucket("healec84");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let mut expected = put_large_inventory(&writer, &bucket).await?;
let replaced_node = 1;
let replaced_drive_index = 2;
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
for item in &mut expected {
item.baseline =
wait_for_complete_physical_shard_on_disk(&replaced_drive, &bucket, &item.key, None, Duration::from_secs(10)).await?;
assert_ec84_geometry(&item.baseline, &item.key)?;
}
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
let format_json = std::fs::read(&format_path)?;
let pid_before = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent")?
.id();
dist.cluster.stop_node_gracefully(replaced_node).await?;
let unclean_shutdown_marker = Path::new(&dist.cluster.nodes[replaced_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown")
.is_file();
assert!(!unclean_shutdown_marker, "graceful target shutdown must remove its unclean marker");
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?;
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
std::fs::write(&format_path, format_json)?;
assert_replaced_drive_empty(
&replaced_drive,
&bucket,
&expected.iter().map(|item| item.key.clone()).collect::<Vec<_>>(),
)?;
let outage_key = "ec84/written-while-node-restarting.bin";
let outage_body = payload_for(outage_key, 10 * 1024 * 1024);
writer
.put_object()
.bucket(&bucket)
.key(outage_key)
.body(ByteStream::from(outage_body.clone()))
.send()
.await?;
dist.cluster.start_node(replaced_node).await?;
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);
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
wait_until(
Duration::from_secs(120),
|| async {
for item in &expected {
let current = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
if !current.matches_manifest(&item.baseline) {
return Ok(false);
}
}
let outage = census_object_version_on_disk(&replaced_drive, &bucket, outage_key, None)?;
Ok(outage.is_complete()
&& outage.data_blocks == Some(EC84_DATA_BLOCKS)
&& outage.parity_blocks == Some(EC84_PARITY_BLOCKS))
},
"EC8+4 replacement drive rebuilt baseline and outage shards",
)
.await?;
let inventory = expected
.iter()
.map(|item| (item.key.clone(), item.body.clone()))
.chain(std::iter::once((outage_key.to_string(), outage_body.clone())))
.collect::<BTreeMap<_, _>>();
let expected_keys = inventory.keys().cloned().collect::<HashSet<_>>();
let mut node_listings = Vec::new();
for node_index in 0..dist.cluster.nodes.len() {
let client = dist.client(node_index)?;
assert_inventory(&client, &bucket, &inventory).await?;
let listing = client.list_objects_v2().bucket(&bucket).send().await?;
let observed = listing
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_owned))
.collect::<HashSet<_>>();
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
let mut keys = observed.into_iter().collect::<Vec<_>>();
keys.sort();
node_listings.push(keys);
}
if let Some(evidence_run) = evidence_run {
let target_client = dist.client(replaced_node)?;
let mut objects = Vec::with_capacity(inventory.len());
for (key, body) in &inventory {
let response = target_client.get_object().bucket(&bucket).key(key).send().await?;
let actual = response.body.collect().await?.into_bytes();
assert_eq!(actual.as_ref(), body.as_slice(), "object body changed for {key}");
let physical = census_object_version_on_disk(&replaced_drive, &bucket, key, None)?;
assert_ec84_geometry(&physical, key)?;
let baseline = expected.iter().find(|item| item.key == *key).map(|item| &item.baseline);
objects.push(serde_json::json!({
"key": key, "version_id": null,
"expected_bytes": body.len(), "actual_bytes": actual.len(),
"expected_sha256": sha256_hex(body), "actual_sha256": sha256_hex(&actual),
"expected_physical": baseline, "physical": physical,
}));
}
let pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("restarted target is absent")?
.id();
evidence_run.write(
&server_binary,
RestartObservation {
nodes: dist.cluster.nodes.len(),
drives_per_node: dist.cluster.topology.drives_per_node,
pid_before,
pid_after,
unclean_shutdown_marker,
objects,
node_listings,
},
)?;
}
Ok(())
}
-1
View File
@@ -25,7 +25,6 @@ mod data_integrity_movement_test;
mod expand_decommission_rebalance_test; mod expand_decommission_rebalance_test;
mod extra_test; mod extra_test;
mod harness; mod harness;
mod heal_test;
mod object_lock_test; mod object_lock_test;
mod observability_test; mod observability_test;
mod replication_quota_test; mod replication_quota_test;
@@ -12,10 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::harness::{ use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, unique_bucket, wait_for_ready, wait_until, use crate::common::{admin_request, init_logging, local_http_client};
};
use crate::common::{admin_request, init_logging, local_http_client, signal_process, signed_request};
use aws_sdk_s3::operation::RequestId; use aws_sdk_s3::operation::RequestId;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes; use bytes::Bytes;
@@ -26,7 +24,7 @@ use hyper::service::service_fn;
use hyper::{Request, Response}; use hyper::{Request, Response};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use local_ip_address::local_ip; use local_ip_address::local_ip;
use rustfs_madmin::metrics::{HttpMetrics, RealtimeMetrics}; use rustfs_madmin::metrics::RealtimeMetrics;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS; use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value; use serde_json::Value;
use std::convert::Infallible; use std::convert::Infallible;
@@ -128,7 +126,6 @@ async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent()
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?; let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization(); let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
let audit_env = [ let audit_env = [
("RUST_LOG", "warn"),
("RUSTFS_AUDIT_ENABLE", "true"), ("RUSTFS_AUDIT_ENABLE", "true"),
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"), ("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()), ("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
@@ -234,186 +231,6 @@ async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent()
"audit entry leaked the root secret key" "audit entry leaked the root secret key"
); );
let result = verify_write_observations_during_peer_failure(&dist, &bucket).await;
collector.abort(); collector.abort();
result
}
async fn node_admin_body(dist: &DistCluster, node: usize, path: &str) -> TestResult<String> {
let (status, body) = timeout(
Duration::from_secs(30),
admin_request(
&dist.cluster.nodes[node].url,
Method::GET,
path,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
),
)
.await??;
assert!(status.is_success(), "node {node} admin request {path}: {status} {body}");
Ok(body)
}
async fn http_put_counts(dist: &DistCluster, node: usize) -> TestResult<[u64; 2]> {
let body = node_admin_body(dist, node, "/rustfs/admin/v3/metrics?types=512&by-host=true&n=1").await?;
let sample: RealtimeMetrics = serde_json::from_str(body.lines().next().ok_or("empty HTTP metrics stream")?)?;
assert!(sample.errors.is_empty(), "HTTP metrics returned errors: {:?}", sample.errors);
let http = sample.aggregated.http.ok_or("HTTP metrics missing at WARN log level")?;
let count = |http: &HttpMetrics, outcome: &str| {
http.requests
.iter()
.filter(|row| row.method == "PUT" && row.outcome == outcome)
.map(|row| row.total)
.sum::<u64>()
};
assert_eq!(sample.by_host.len(), 1, "HTTP admin metrics must remain node-local");
let host = sample.by_host.values().next().expect("one reporting host");
let host = host.http.as_ref().ok_or("by-host HTTP metrics missing")?;
let totals = [count(&http, "2xx"), count(&http, "5xx")];
assert_eq!(totals, [count(host, "2xx"), count(host, "5xx")]);
Ok(totals)
}
async fn observed_put(dist: &DistCluster, node: usize, bucket: &str, key: &str) -> TestResult<http::StatusCode> {
// One signed HTTP attempt: SDK retries must not change the expected denominator.
timeout(Duration::from_secs(90), async {
let response = signed_request(
Method::PUT,
&format!("{}/{bucket}/{key}", dist.cluster.nodes[node].url),
&dist.cluster.access_key,
&dist.cluster.secret_key,
Some(b"write-observation".to_vec()),
Some("application/octet-stream"),
)
.await?;
assert!(response.headers().contains_key("x-amz-request-id"), "PUT omitted correlation ID");
let status = response.status();
let body = response.text().await?;
assert!(!body.contains(&dist.cluster.secret_key), "PUT response leaked credentials");
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(status)
})
.await?
}
struct SuspendedPeer<'a> {
// Borrowing the owned child keeps its PID from being reaped/reused before cleanup.
child: &'a std::process::Child,
suspended: bool,
}
impl<'a> SuspendedPeer<'a> {
fn suspend(dist: &'a DistCluster, node: usize) -> TestResult<Self> {
let child = dist.cluster.nodes[node].process.as_ref().ok_or("peer process missing")?;
signal_process(child.id(), "STOP")?;
Ok(Self { child, suspended: true })
}
fn resume(&mut self) -> TestResult {
signal_process(self.child.id(), "CONT")?;
self.suspended = false;
Ok(())
}
}
impl Drop for SuspendedPeer<'_> {
fn drop(&mut self) {
if self.suspended {
let _ = signal_process(self.child.id(), "CONT");
}
}
}
async fn verify_write_observations_during_peer_failure(dist: &DistCluster, bucket: &str) -> TestResult {
let mut baseline = Vec::new();
for node in 0..dist.cluster.nodes.len() {
let before = http_put_counts(dist, node).await?;
assert!(
observed_put(dist, node, bucket, &format!("healthy-{node}"))
.await?
.is_success()
);
let after = http_put_counts(dist, node).await?;
assert_eq!(after, [before[0] + 1, before[1]], "node {node} lost its successful PUT denominator");
baseline.push(after);
}
// Refresh provenance immediately before the first failed probe, within the cache age budget.
node_admin_body(dist, 0, "/rustfs/admin/v3/storageinfo").await?;
let mut suspended = [SuspendedPeer::suspend(dist, 2)?, SuspendedPeer::suspend(dist, 3)?];
let storage: Value = serde_json::from_str(&node_admin_body(dist, 0, "/rustfs/admin/v3/storageinfo").await?)?;
let observations = storage["info"]["observations"]
.as_array()
.ok_or("storageinfo omitted observations")?;
let disks = storage["info"]["disks"]
.as_array()
.ok_or("storageinfo omitted disks during peer failure")?;
assert_eq!(disks.len(), 16, "failed peers must not vanish from inventory");
for node in [2, 3] {
let endpoint = &dist.cluster.nodes[node].address;
let observation = observations
.iter()
.find(|item| item["endpoint"].as_str().is_some_and(|value| value.contains(endpoint)))
.ok_or_else(|| format!("missing failed peer observation {endpoint}: {storage}"))?;
assert_eq!(observation["status"], "failed", "suspension did not affect peer RPC: {observation}");
assert_eq!(observation["cached"], true, "first failure must identify the warm cache: {observation}");
assert!(observation["last_success_unix_millis"].as_u64().is_some());
assert!(observation["snapshot_age_seconds"].as_u64().is_some_and(|age| age < 60));
let peer_disks: Vec<_> = disks
.iter()
.filter(|disk| disk["endpoint"].as_str().is_some_and(|value| value.contains(endpoint)))
.collect();
assert_eq!(peer_disks.len(), 4, "failed peer lost its four drive identities: {storage}");
for disk in peer_disks {
assert_eq!(disk["state"], "unknown");
assert_eq!(disk["runtimeState"], "unknown");
}
}
let snapshot: Value = serde_json::from_str(&node_admin_body(dist, 0, "/rustfs/admin/v4/cluster/snapshot").await?)?;
let metadata = &snapshot["snapshot"]["pool_meta_write_gate"];
assert_eq!(
metadata["state"], "writable",
"peer probe failure must not invent a metadata latch: {snapshot}"
);
assert!(metadata.get("sinceUnixSecs").is_none());
for attempt in 0..2 {
let status = observed_put(dist, 0, bucket, &format!("unavailable-{attempt}")).await?;
assert!(status.is_server_error(), "sub-quorum write unexpectedly returned {status}");
}
assert_eq!(http_put_counts(dist, 0).await?, [baseline[0][0], baseline[0][1] + 2]);
assert_eq!(
http_put_counts(dist, 1).await?,
baseline[1],
"internal RPCs must not count as external PUTs"
);
for peer in &mut suspended {
peer.resume()?;
}
wait_until(
Duration::from_secs(90),
|| async {
let storage: Value = serde_json::from_str(&node_admin_body(dist, 0, "/rustfs/admin/v3/storageinfo").await?)?;
let observations = storage["info"]["observations"]
.as_array()
.ok_or("recovery omitted observations")?;
Ok(observations.len() == 4
&& observations
.iter()
.all(|item| item["status"] == "succeeded" && item["cached"] == false))
},
"peer probes recover to fresh successful observations",
)
.await?;
wait_for_ready(&dist.cluster).await?;
assert!(observed_put(dist, 0, bucket, "recovered").await?.is_success());
assert_eq!(http_put_counts(dist, 0).await?, [baseline[0][0] + 1, baseline[0][1] + 2]);
for node in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node)?, bucket, "healthy-0", b"write-observation").await?;
assert_object_bytes(&dist.client(node)?, bucket, "recovered", b"write-observation").await?;
}
Ok(()) Ok(())
} }
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -630,10 +630,6 @@ struct MultipartPart {
body: Bytes, body: Bytes,
e_tag: String, e_tag: String,
digest: [u8; 16], digest: [u8; 16],
/// Plaintext length declared by an SSE-C passthrough sender
/// (`x-rustfs-replication-part-actual-size`); RustFS validates the 5 MiB
/// minimum against it rather than against the stored bytes.
actual_size: Option<usize>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -2827,11 +2823,6 @@ impl S3 for FakeBackend {
async fn upload_part(&self, req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> { async fn upload_part(&self, req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> {
let fault = request_fault(&req); let fault = request_fault(&req);
let declared_actual_size = req
.headers
.get("x-rustfs-replication-part-actual-size")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok());
let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned()) let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned())
.await .await
.map_err(|_| s3s::s3_error!(RequestTimeout, "fake target body limiter wait exceeded 30 seconds"))? .map_err(|_| s3s::s3_error!(RequestTimeout, "fake target body limiter wait exceeded 30 seconds"))?
@@ -2873,7 +2864,6 @@ impl S3 for FakeBackend {
body, body,
e_tag: e_tag.clone(), e_tag: e_tag.clone(),
digest, digest,
actual_size: declared_actual_size,
}, },
); );
Ok(apply_response_fault( Ok(apply_response_fault(
@@ -2943,9 +2933,7 @@ impl S3 for FakeBackend {
if requested_etag != &stored.e_tag { if requested_etag != &stored.e_tag {
return Err(s3s::s3_error!(InvalidPart, "part ETag does not match")); return Err(s3s::s3_error!(InvalidPart, "part ETag does not match"));
} }
if index + 1 != requested_parts.len() if index + 1 != requested_parts.len() && stored.body.len() < MIN_MULTIPART_PART_BYTES {
&& stored.actual_size.unwrap_or(stored.body.len()) < MIN_MULTIPART_PART_BYTES
{
return Err(s3s::s3_error!(EntityTooSmall, "non-final multipart part is smaller than 5 MiB")); return Err(s3s::s3_error!(EntityTooSmall, "non-final multipart part is smaller than 5 MiB"));
} }
selected.push((*number, stored.clone())); selected.push((*number, stored.clone()));
+1 -6
View File
@@ -114,12 +114,7 @@ impl FaultProxy {
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted /// Bind a listener on `127.0.0.1:0` and start forwarding accepted
/// connections to `target`. Starts in [`FaultMode::Pass`]. /// connections to `target`. Starts in [`FaultMode::Pass`].
pub async fn start(target: SocketAddr) -> io::Result<Self> { pub async fn start(target: SocketAddr) -> io::Result<Self> {
Self::start_on((Ipv4Addr::LOCALHOST, 0).into(), target).await let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
}
/// Bind the selected address and forward accepted connections to `target`.
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind(address).await?;
let listen_addr = listener.local_addr()?; let listen_addr = listener.local_addr()?;
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass); let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
@@ -401,11 +401,11 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?; let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch"); assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
baseline_degraded.insert(multipart_key.to_string(), mp_view); baseline_degraded.insert(multipart_key.to_string(), mp_view);
// Stop disk writers before restoring the complete layout reused by Phase B. // Restore the disk so Phase B restarts from a clean, complete disk set.
harness.kill_server();
harness.bring_disk_online(0)?; harness.bring_disk_online(0)?;
// ---- Phase B: codec streaming (gates opened) ---- // ---- Phase B: codec streaming (gates opened) ----
harness.kill_server();
for (k, v) in codec_env() { for (k, v) in codec_env() {
harness.set_env(k, v); harness.set_env(k, v);
} }
@@ -497,8 +497,6 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?; let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch"); assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
codec_degraded.insert(multipart_key.to_string(), mp_view); codec_degraded.insert(multipart_key.to_string(), mp_view);
// All server reads are complete; stop disk writers before restoring disk0.
harness.kill_server();
harness.bring_disk_online(0)?; harness.bring_disk_online(0)?;
// A/B under parity reconstruction: codec == legacy, byte-for-byte and // A/B under parity reconstruction: codec == legacy, byte-for-byte and
@@ -16,20 +16,18 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::chaos::{ use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post,
wait_for_complete_physical_shard_on_disk,
};
use crate::common::{ use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
rustfs_binary_path, rustfs_binary_path,
}; };
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use crate::storage_api::RUSTFS_META_BUCKET; use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use http::Method; use http::Method;
use sha2::{Digest, Sha256};
use std::collections::HashSet; use std::collections::HashSet;
use std::error::Error; use std::error::Error;
use std::io::{Read, Write};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -41,45 +39,104 @@ mod tests {
const POOL_METADATA_OBJECT: &str = "pool.bin"; const POOL_METADATA_OBJECT: &str = "pool.bin";
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
struct ScannerHealEvidenceCase {
id: &'static str,
oracle: &'static str,
}
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-restart", id: "background-target-restart",
oracle: "background-target-restart.json", oracle: "background-target-restart.json",
evidence: "process-restart",
unclean_shutdown_marker: false,
topology: EvidenceTopology::new(4, 1),
storage_class_standard: None,
erasure_set_drive_count: None,
}; };
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { struct RestartEvidenceContext {
id: "background-target-crash", directory: PathBuf,
oracle: "background-target-crash.json", run: RestartEvidenceRun,
evidence: "process-crash-restart", case: ScannerHealEvidenceCase,
unclean_shutdown_marker: true, }
topology: EvidenceTopology::new(4, 1),
storage_class_standard: None,
erasure_set_drive_count: None,
};
const BACKGROUND_TARGET_RESTART_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
id: "background-target-restart-ec8-4", let mut file = std::fs::File::open(path)?;
oracle: "background-target-restart-ec8-4.json", let mut digest = Sha256::new();
evidence: "process-restart", let mut buffer = [0_u8; 64 * 1024];
unclean_shutdown_marker: false, loop {
topology: EvidenceTopology::new(3, 4), let read = file.read(&mut buffer)?;
storage_class_standard: Some("EC:4"), if read == 0 {
erasure_set_drive_count: Some("12"), break;
}; }
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
const BACKGROUND_TARGET_CRASH_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase { fn restart_evidence_run(
id: "background-target-crash-ec8-4", binary: &Path,
oracle: "background-target-crash-ec8-4.json", case: ScannerHealEvidenceCase,
evidence: "process-crash-restart", ) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
unclean_shutdown_marker: true, let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
topology: EvidenceTopology::new(3, 4), return Ok(None);
storage_class_standard: Some("EC:4"), };
erasure_set_drive_count: Some("12"), if case.id.is_empty()
}; || case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
struct TcpPortBlackhole { struct TcpPortBlackhole {
port: u16, port: u16,
@@ -262,10 +319,11 @@ mod tests {
// Healing may rewrite non-identity bookkeeping in xl.meta. The census // Healing may rewrite non-identity bookkeeping in xl.meta. The census
// therefore compares the canonical selected metadata fields plus every // therefore compares the canonical selected metadata fields plus every
// physical shard. // physical shard, while the payload seed makes object mix-ups observable.
#[derive(Debug)] #[derive(Debug)]
struct PhysicalObjectManifest { struct PhysicalObjectManifest {
key: String, key: String,
payload_seed: u8,
shard_census: VersionShardCensus, shard_census: VersionShardCensus,
} }
@@ -385,79 +443,6 @@ mod tests {
) )
} }
async fn wait_for_admin_cluster_start_log(
log_path: &Path,
client_token: &str,
deadline: Instant,
) -> Result<(), Box<dyn Error + Send + Sync>> {
loop {
let coordinator_log = std::fs::read_to_string(log_path)?;
if coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
})
{
return Ok(());
}
if Instant::now() >= deadline {
return Err(
format!("node 0 must have started the exact admin task before interruption: task_id={client_token}").into(),
);
}
sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn test_admin_cluster_start_log_waits_for_exact_delayed_event() -> Result<(), Box<dyn Error + Send + Sync>> {
use std::io::Write;
let mut log = tempfile::NamedTempFile::new()?;
for (task_id, heal_type, state) in [
("other-task", "cluster", "started"),
("admin-task", "object", "started"),
("admin-task", "cluster", "completed"),
] {
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": task_id, "heal_type": heal_type, "state": state})
)?;
}
let log_path = log.path().to_path_buf();
let started = wait_for_admin_cluster_start_log(&log_path, "admin-task", Instant::now() + Duration::from_secs(1));
tokio::pin!(started);
// Poll the reader before publishing the start event, without depending
// on scheduling or a fixed writer delay to reproduce log visibility.
tokio::select! {
biased;
result = &mut started => panic!("unrelated events must leave the exact start pending: {result:?}"),
_ = std::future::ready(()) => {}
}
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": "admin-task", "heal_type": "cluster", "state": "started"})
)?;
started.await?;
Ok(())
}
#[tokio::test]
async fn test_admin_cluster_start_log_respects_existing_deadline() -> Result<(), Box<dyn Error + Send + Sync>> {
let log = tempfile::NamedTempFile::new()?;
let error = wait_for_admin_cluster_start_log(log.path(), "admin-task", Instant::now())
.await
.expect_err("missing exact start must fail at the supplied deadline");
assert!(error.to_string().contains("task_id=admin-task"), "{error}");
Ok(())
}
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool { fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"]; let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true) status["clusterStatusComplete"] == serde_json::Value::Bool(true)
@@ -894,10 +879,7 @@ mod tests {
cluster: &RustFSTestClusterEnvironment, cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64, previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> { ) -> Result<u64, Box<dyn Error + Send + Sync>> {
let started = Instant::now(); let deadline = Instant::now() + Duration::from_secs(60);
let mut deadline = started + Duration::from_secs(60);
let catch_up_deadline = deadline + Duration::from_secs(300);
let mut catch_up_wait_observed = false;
loop { loop {
let mut latest_cycle_end = 0; let mut latest_cycle_end = 0;
let mut versions_observed = false; let mut versions_observed = false;
@@ -925,61 +907,24 @@ mod tests {
let versions_scanned = metrics["versions_scanned"] let versions_scanned = metrics["versions_scanned"]
.as_u64() .as_u64()
.ok_or("scanner status is missing its version-coverage counter")?; .ok_or("scanner status is missing its version-coverage counter")?;
let cycle_result = metrics["last_cycle_result"] latest_cycle_end = latest_cycle_end.max(cycle_end);
.as_str()
.ok_or("scanner status is missing its cycle result")?;
if cycle_result == "success" {
latest_cycle_end = latest_cycle_end.max(cycle_end);
}
versions_observed |= versions_scanned > 0; versions_observed |= versions_scanned > 0;
let backlog = &status["pause_backlog"];
if !catch_up_wait_observed
&& backlog["persistence_state"].as_str() == Some("healthy")
&& backlog["durable"].as_bool() == Some(true)
&& backlog["phase"].as_str() == Some("catching_up")
&& backlog["rate_limited"].as_bool() == Some(true)
&& backlog["retry_exhausted"].as_bool() == Some(false)
{
let next_attempt = backlog["next_attempt_at_unix_secs"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its next attempt")?;
let interval = backlog["thresholds"]["catch_up_min_interval_seconds"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its catch-up interval")?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
let remaining = next_attempt.saturating_sub(now);
if remaining > 0 {
if interval > 300 || remaining > interval {
return Err(
format!("scanner catch-up schedule exceeds the bounded recovery budget: {backlog}").into()
);
}
// The durable catch-up interval overrides SCANNER_CYCLE=1.
// Honor one observed retry without restarting the deadline on every poll.
deadline = deadline
.max(Instant::now() + Duration::from_secs(remaining + 60))
.min(catch_up_deadline);
catch_up_wait_observed = true;
}
}
observations.push(format!( observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, backlog={}", "node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"], metrics["current_cycle"],
metrics["current_cycle_active"], metrics["current_cycle_active"],
metrics["leader_lock_state"], metrics["leader_lock_state"],
metrics["last_cycle_result"], metrics["last_cycle_result"],
backlog,
)); ));
} }
// Only a successful coordinator cycle counts as completion; deferred // The coordinator records cycle completion, but remote workers
// and superseded attempts also advance its end timestamp. Remote // record scanned versions. Both witnesses need not share a node.
// workers record version coverage, so the witnesses can span nodes.
if latest_cycle_end > previous_cycle_end && versions_observed { if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end); return Ok(latest_cycle_end);
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
return Err(format!( return Err(format!(
"enabled scanner did not complete a successful object-scanning cycle after {previous_cycle_end}: {observations:?}" "enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
) )
.into()); .into());
} }
@@ -999,47 +944,17 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart() async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> { -> Result<(), Box<dyn Error + Send + Sync>> {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
) )
.await? .await?
} }
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
)
.await?
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
)
.await?
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
)
.await?
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>> async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{ {
timeout( timeout(
Duration::from_secs(720), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart), run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
) )
.await? .await?
@@ -1071,39 +986,24 @@ mod tests {
enum InterruptionScenario { enum InterruptionScenario {
IsolatedTargetRestart, IsolatedTargetRestart,
BackgroundTargetRestart, BackgroundTargetRestart,
BackgroundTargetCrash,
BackgroundTargetRestartEc84,
BackgroundTargetCrashEc84,
BackgroundCoordinatorRestart, BackgroundCoordinatorRestart,
TargetEndpointBlackhole, TargetEndpointBlackhole,
} }
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> { async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let server_binary = rustfs_binary_path(); let server_binary = rustfs_binary_path();
let evidence_case = match scenario { let evidence_run = if scenario == InterruptionScenario::BackgroundTargetRestart {
InterruptionScenario::BackgroundTargetRestart => Some(BACKGROUND_TARGET_RESTART_EVIDENCE), restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
InterruptionScenario::BackgroundTargetCrash => Some(BACKGROUND_TARGET_CRASH_EVIDENCE), } else {
InterruptionScenario::BackgroundTargetRestartEc84 => Some(BACKGROUND_TARGET_RESTART_EC84_EVIDENCE), None
InterruptionScenario::BackgroundTargetCrashEc84 => Some(BACKGROUND_TARGET_CRASH_EC84_EVIDENCE),
_ => None,
};
let evidence_run = match evidence_case {
Some(case) => restart_evidence_run(&server_binary, case)?,
None => None,
}; };
let mut evidence_objects = Vec::new(); let mut evidence_objects = Vec::new();
let (background_enabled, interruption_node, interruption_kind) = match scenario { let (background_enabled, interruption_node, interruption_kind) = match scenario {
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"), InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"), InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"),
InterruptionScenario::BackgroundTargetRestartEc84 => (true, 1, "background_target_restart_ec8_4"),
InterruptionScenario::BackgroundTargetCrashEc84 => (true, 1, "background_target_crash_ec8_4"),
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"), InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"), InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
}; };
let topology = evidence_case
.map(|case| case.topology)
.unwrap_or_else(|| EvidenceTopology::new(4, 1));
init_logging(); init_logging();
info!( info!(
event = "heal_interruption_started", event = "heal_interruption_started",
@@ -1115,15 +1015,9 @@ mod tests {
"Starting root-heal interruption test" "Starting root-heal interruption test"
); );
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology.cluster_topology()).await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true"); cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
cluster.set_env("RUSTFS_HEAL_ENABLED", "true"); cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
if let Some(storage_class) = evidence_case.and_then(|case| case.storage_class_standard) {
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", storage_class);
}
if let Some(erasure_set_drive_count) = evidence_case.and_then(|case| case.erasure_set_drive_count) {
cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", erasure_set_drive_count);
}
// Heal control uses the first lexicographically sorted grid host. // Heal control uses the first lexicographically sorted grid host.
// Keep that coordinator distinct from the remote target at index 1. // Keep that coordinator distinct from the remote target at index 1.
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url)); cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
@@ -1146,19 +1040,10 @@ mod tests {
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG") let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string()); .unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log); cluster.set_env("RUST_LOG", server_rust_log);
let log_dir = if let Some(directory) = std::env::var_os("RUSTFS_HEAL_CHAOS_LOG_DIR") { let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
PathBuf::from(directory)
} else if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
let cluster_name = Path::new(&cluster.temp_dir)
.file_name()
.ok_or("cluster directory has no name")?;
PathBuf::from(directory).join(cluster_name).join("heal")
} else {
PathBuf::from(&cluster.temp_dir).join("logs")
};
std::fs::create_dir_all(&log_dir)?; std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() { for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, log_dir.join(format!("node{node_index}.log")).to_string_lossy())?; cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
} }
cluster.start_with_binary(&server_binary).await?; cluster.start_with_binary(&server_binary).await?;
let clients = cluster.create_all_clients()?; let clients = cluster.create_all_clients()?;
@@ -1181,22 +1066,10 @@ mod tests {
.and_then(|value| value.parse::<usize>().ok()) .and_then(|value| value.parse::<usize>().ok())
.unwrap_or(4 * 1024 * 1024) .unwrap_or(4 * 1024 * 1024)
.clamp(1024 * 1024, 16 * 1024 * 1024); .clamp(1024 * 1024, 16 * 1024 * 1024);
let mut created_online_objects = Vec::with_capacity(online_object_count);
let mut expected_manifests = Vec::with_capacity(online_object_count); let mut expected_manifests = Vec::with_capacity(online_object_count);
let mut unclean_shutdown_marker_observed = None; for index in 0..online_object_count {
let mut attempt_count = 0usize; let key = format!("cluster/online/object-{index:04}.bin");
let max_online_attempts = online_object_count.saturating_mul(topology.total_drives().max(1)); let payload_seed = u8::try_from(index + 1).expect("clamped object count must fit in u8");
while expected_manifests.len() < online_object_count {
if attempt_count >= max_online_attempts {
return Err(format!(
"target replacement drive held only {}/{} baseline object shards after {attempt_count} writes",
expected_manifests.len(),
online_object_count
)
.into());
}
let key = format!("cluster/online/object-{attempt_count:04}.bin");
let payload_seed = ((attempt_count % 251) + 1) as u8;
timeout( timeout(
Duration::from_secs(30), Duration::from_secs(30),
clients[0] clients[0]
@@ -1208,13 +1081,6 @@ mod tests {
) )
.await??; .await??;
let shard_census = census_object_version_on_disk(&replaced_disk, bucket, &key, None)?; let shard_census = census_object_version_on_disk(&replaced_disk, bucket, &key, None)?;
if !shard_census.has_xl_meta {
timeout(Duration::from_secs(30), clients[0].delete_object().bucket(bucket).key(&key).send()).await??;
attempt_count += 1;
continue;
}
let shard_census =
wait_for_complete_physical_shard_on_disk(&replaced_disk, bucket, &key, None, Duration::from_secs(10)).await?;
assert!( assert!(
shard_census.is_complete(), shard_census.is_complete(),
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}" "node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
@@ -1223,9 +1089,11 @@ mod tests {
!shard_census.expected_part_numbers.is_empty(), !shard_census.expected_part_numbers.is_empty(),
"chaos objects must use physical part shards rather than inline data: {shard_census:?}" "chaos objects must use physical part shards rather than inline data: {shard_census:?}"
); );
created_online_objects.push((key.clone(), payload_seed)); expected_manifests.push(PhysicalObjectManifest {
expected_manifests.push(PhysicalObjectManifest { key, shard_census }); key,
attempt_count += 1; payload_seed,
shard_census,
});
} }
let expected_pool_metadata = if background_enabled { let expected_pool_metadata = if background_enabled {
@@ -1271,38 +1139,31 @@ mod tests {
if node_index == 1 { if node_index == 1 {
continue; continue;
} }
for (drive_index, drive) in node.data_dirs.iter().enumerate() { let census = census_object_version_on_disk(Path::new(&node.data_dir), bucket, outage_key, None)?;
let census = census_object_version_on_disk(Path::new(drive), bucket, outage_key, None)?; assert!(
assert!( census.is_complete(),
census.is_complete(), "online node {node_index} must hold a complete outage-object shard: {census:?}"
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}" );
); let erasure_index = census
let erasure_index = census.erasure_index.ok_or_else(|| { .erasure_index
format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}") .ok_or_else(|| format!("online node {node_index} outage-object shard has no erasure index: {census:?}"))?;
})?; assert!(
assert!( (1..=cluster.nodes.len()).contains(&erasure_index),
(1..=topology.total_drives()).contains(&erasure_index), "online node {node_index} outage-object erasure index is out of range: {census:?}"
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}" );
); assert!(
assert!( outage_peer_erasure_indices.insert(erasure_index),
outage_peer_erasure_indices.insert(erasure_index), "outage-object erasure index {erasure_index} is duplicated across online nodes"
"outage-object erasure index {erasure_index} is duplicated across online drives" );
);
}
} }
assert_eq!( assert_eq!(
outage_peer_erasure_indices.len(), outage_peer_erasure_indices.len(),
topology.total_drives().saturating_sub(cluster.nodes[1].data_dirs.len()), cluster.nodes.len().saturating_sub(1),
"every online drive must contribute one unique outage-object erasure index" "every online node must contribute one unique outage-object erasure index"
);
let missing_outage_erasure_indices = (1..=topology.total_drives())
.filter(|index| !outage_peer_erasure_indices.contains(index))
.collect::<HashSet<_>>();
assert_eq!(
missing_outage_erasure_indices.len(),
cluster.nodes[1].data_dirs.len(),
"the stopped node must account for every missing outage-object erasure index"
); );
let expected_outage_target_erasure_index = (1..=cluster.nodes.len())
.find(|index| !outage_peer_erasure_indices.contains(index))
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#; let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
if !background_enabled { if !background_enabled {
@@ -1401,9 +1262,6 @@ mod tests {
} }
sleep(Duration::from_millis(50)).await; sleep(Duration::from_millis(50)).await;
} }
// Task execution and its non-blocking log writer advance independently.
// Observe the exact start before taking the partial-rebuild snapshot.
wait_for_admin_cluster_start_log(&log_dir.join("node0.log"), client_token, partial_deadline).await?;
let (partial_count, partial_manifest) = loop { let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a // Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window. // full-corpus hash pass consume the interruption window.
@@ -1438,6 +1296,19 @@ mod tests {
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body) let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?; .map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?; let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"]; let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!( assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(), pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
@@ -1562,15 +1433,7 @@ mod tests {
"Restored target endpoint forwarding" "Restored target endpoint forwarding"
); );
} else { } else {
let graceful_restart = matches!( cluster.stop_node(interruption_node)?;
scenario,
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
);
if graceful_restart {
cluster.stop_node_gracefully(interruption_node).await?;
} else {
cluster.stop_node(interruption_node)?;
}
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests); let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
assert!( assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(), stopped_count > 0 && stopped_count < expected_manifests.len(),
@@ -1586,12 +1449,9 @@ mod tests {
.join(".rustfs.sys") .join(".rustfs.sys")
.join("unclean-shutdown"); .join("unclean-shutdown");
if background_enabled { if background_enabled {
let marker_exists = unclean_shutdown_marker.is_file();
unclean_shutdown_marker_observed = Some(marker_exists);
let expected_marker = !graceful_restart;
assert!( assert!(
marker_exists == expected_marker, unclean_shutdown_marker.is_file(),
"background restart/crash lane observed unexpected unclean-shutdown marker state" "background restart must retain the real unclean-shutdown marker"
); );
} else { } else {
match std::fs::remove_file(&unclean_shutdown_marker) { match std::fs::remove_file(&unclean_shutdown_marker) {
@@ -1644,19 +1504,7 @@ mod tests {
} }
} }
if Instant::now() >= heal_deadline { if Instant::now() >= heal_deadline {
let mut mismatched_manifests = Vec::new(); let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
for expected in &expected_manifests {
let actual = census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?;
if !actual.matches_manifest(&expected.shard_census) {
mismatched_manifests.push(serde_json::json!({
"key": expected.key,
"expected": expected.shard_census,
"actual": actual,
}));
}
}
let matching = expected_manifests.len() - mismatched_manifests.len();
let mismatched_manifests = serde_json::Value::Array(mismatched_manifests);
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?; let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata = let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?; census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
@@ -1679,7 +1527,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(), Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
}; };
return Err(format!( return Err(format!(
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, mismatched_manifests={mismatched_manifests}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}", "root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len() expected_manifests.len()
) )
.into()); .into());
@@ -1701,35 +1549,29 @@ mod tests {
"outage object must have a complete target shard: {outage_census:?}" "outage object must have a complete target shard: {outage_census:?}"
); );
assert_eq!( assert_eq!(
outage_census
.erasure_index
.filter(|index| missing_outage_erasure_indices.contains(index)),
outage_census.erasure_index, outage_census.erasure_index,
"the outage object must be rebuilt into one of the stopped node's missing erasure slots" Some(expected_outage_target_erasure_index),
"the outage object must be rebuilt into its own missing erasure slot"
); );
if let Some(cycle_end) = scanner_cycle_floor { if let Some(cycle_end) = scanner_cycle_floor {
wait_for_scanner_cycle_after(&cluster, cycle_end).await?; wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
} }
let mut expected_keys = created_online_objects let mut expected_keys = expected_manifests
.iter() .iter()
.map(|(key, _)| key.clone()) .map(|manifest| manifest.key.clone())
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
assert!(expected_keys.insert(outage_key.to_string())); assert!(expected_keys.insert(outage_key.to_string()));
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?; let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
let target_client = cluster.create_s3_client(1)?; let target_client = cluster.create_s3_client(1)?;
for (key, payload_seed) in &created_online_objects { for expected in &expected_manifests {
let response = target_client.get_object().bucket(bucket).key(key).send().await?; let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
let actual = response.body.collect().await?.into_bytes(); let actual = response.body.collect().await?.into_bytes();
let expected_body = deterministic_object_body(object_size_bytes, *payload_seed); let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed);
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {key}"); assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key);
if evidence_run.is_some() if evidence_run.is_some() {
&& let Some(expected) = expected_manifests
.iter()
.find(|manifest| manifest.key.as_str() == key.as_str())
{
evidence_objects.push(serde_json::json!({ evidence_objects.push(serde_json::json!({
"key": expected.key, "version_id": expected.shard_census.version_id, "key": expected.key, "version_id": expected.shard_census.version_id,
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(), "expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
@@ -1803,18 +1645,31 @@ mod tests {
if let Some(evidence_context) = evidence_run { if let Some(evidence_context) = evidence_run {
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id(); let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
assert_ne!(target_pid, restarted_pid, "target must be a new process"); assert_ne!(target_pid, restarted_pid, "target must be a new process");
evidence_context.write( assert_eq!(
&server_binary, file_sha256(&server_binary)?,
RestartObservation { evidence_context.run.binary.sha256,
nodes: cluster.nodes.len(), "server build changed during restart"
drives_per_node: cluster.nodes[0].data_dirs.len(), );
pid_before: target_pid, let evidence = serde_json::json!({
pid_after: restarted_pid, "schema": 1, "case": evidence_context.case.id, "evidence": "process-restart",
unclean_shutdown_marker: unclean_shutdown_marker_observed.ok_or("missing shutdown marker observation")?, "run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
objects: evidence_objects, "test_build": compiled_test_identity(),
node_listings, "binary_sha256": evidence_context.run.binary.sha256,
}, "test_binary_sha256": evidence_context.run.test_binary.sha256,
)?; "topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
"pid_before": target_pid, "pid_after": restarted_pid,
"objects": evidence_objects, "node_listings": node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
} }
Ok(()) Ok(())
@@ -21,7 +21,7 @@
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks //! One S3 GET can select readers on multiple EC nodes, so the counter tracks
//! distributed reader selection rather than HTTP request count. //! distributed reader selection rather than HTTP request count.
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, signal_process}; use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
@@ -1676,8 +1676,6 @@ async fn four_node_inline_storage_and_get_boundaries() -> TestResult {
let collector = OtlpMetricCollector::start().await?; let collector = OtlpMetricCollector::start().await?;
let mut cluster = RustFSTestClusterEnvironment::new(4).await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
configure_reader_metric_cluster(&mut cluster, &collector); configure_reader_metric_cluster(&mut cluster, &collector);
// Inspect every disk only after the PUT rename fanout has drained.
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
cluster.start().await?; cluster.start().await?;
for (state_index, state) in [VersionState::Unversioned, VersionState::Enabled, VersionState::Suspended] for (state_index, state) in [VersionState::Unversioned, VersionState::Enabled, VersionState::Suspended]
@@ -2207,33 +2205,6 @@ async fn four_node_manual_transition_job_status_survives_node_restart() -> TestR
Ok(()) Ok(())
} }
struct SuspendedTransitionTarget<'a> {
// Keep the owned child borrowed until it is resumed so its PID cannot be reused.
child: &'a std::process::Child,
suspended: bool,
}
impl<'a> SuspendedTransitionTarget<'a> {
fn suspend(child: &'a std::process::Child) -> TestResult<Self> {
signal_process(child.id(), "STOP")?;
Ok(Self { child, suspended: true })
}
fn resume(&mut self) -> TestResult {
signal_process(self.child.id(), "CONT")?;
self.suspended = false;
Ok(())
}
}
impl Drop for SuspendedTransitionTarget<'_> {
fn drop(&mut self) {
if self.suspended {
let _ = signal_process(self.child.id(), "CONT");
}
}
}
#[tokio::test] #[tokio::test]
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult { async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
init_logging(); init_logging();
@@ -2261,6 +2232,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple()); let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
let prefix = "transition/distributed-admission/"; let prefix = "transition/distributed-admission/";
hot_client.create_bucket().bucket(&bucket).send().await?; hot_client.create_bucket().bucket(&bucket).send().await?;
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
for index in 0u8..64 { for index in 0u8..64 {
let key = format!("{prefix}object-{index:02}.bin"); let key = format!("{prefix}object-{index:02}.bin");
hot_client hot_client
@@ -2271,21 +2243,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
.send() .send()
.await?; .await?;
} }
// Lifecycle PUT starts its own backfill. Keep its first page on a separate
// node and stop it at queue backpressure before it reaches the tested prefix:
// one active worker, one queued item, then the first rejected item.
for index in 0u8..3 {
hot_client
.put_object()
.bucket(&bucket)
.key(format!("transition/automatic-admission/object-{index:02}.bin"))
.body(ByteStream::from(payload(KIB, index)))
.send()
.await?;
}
let mut suspended_cold = SuspendedTransitionTarget::suspend(cold.process.as_ref().ok_or("cold-tier process missing")?)?;
let lifecycle_client = hot.create_s3_client(2)?;
put_lifecycle_with_transition_retry(&lifecycle_client, &bucket, &tier_name).await?;
let (node0, node1) = tokio::join!( let (node0, node1) = tokio::join!(
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64), start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
@@ -2345,31 +2302,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
assert_eq!(status["job_id"].as_str(), Some(job_id)); assert_eq!(status["job_id"].as_str(), Some(job_id));
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint)); assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let status = read_manual_transition_job_status_endpoint(&hot, accepted.0, status_endpoint).await?;
assert_eq!(
status["status"].as_str(),
Some("running"),
"blocked cold tier must keep the admitted job running: {status}"
);
if status["report"]["skipped_queue_full"].as_u64().is_some_and(|count| count > 0) {
assert!(
status["report"]["enqueued"].as_u64().is_some_and(|count| count > 0),
"the job must own pending transitions while the cold tier is suspended: {status}"
);
break;
}
if Instant::now() >= deadline {
return Err(format!(
"manual transition job did not reach queue backpressure while the cold tier was suspended: {status}"
)
.into());
}
sleep(Duration::from_millis(50)).await;
}
suspended_cold.resume()?;
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?; let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
assert_eq!(terminal["job_id"].as_str(), Some(job_id)); assert_eq!(terminal["job_id"].as_str(), Some(job_id));
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
@@ -59,36 +59,6 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256)); assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
// A missing key in the healthy store is a client error, unlike a store outage.
let missing_key_object = "test-missing-kms-key";
let missing_key_error = s3_client
.put_object()
.bucket(TEST_BUCKET)
.key(missing_key_object)
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"must not be published"))
.server_side_encryption(ServerSideEncryption::AwsKms)
.ssekms_key_id("rustfs-e2e-test-missing-key")
.send()
.await
.expect_err("an unknown key in a healthy Local KMS store must reject the write");
assert_eq!(missing_key_error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
missing_key_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("KMS.NotFoundException")
);
let missing_key_absence = s3_client
.get_object()
.bucket(TEST_BUCKET)
.key(missing_key_object)
.send()
.await
.expect_err("a write rejected by a missing KMS key must not publish an object");
assert_eq!(missing_key_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(
missing_key_absence.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NoSuchKey")
);
// Temporarily rename the key directory to simulate unavailability // Temporarily rename the key directory to simulate unavailability
info!("🔧 Simulating key directory unavailability"); info!("🔧 Simulating key directory unavailability");
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir); let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
-3
View File
@@ -23,9 +23,6 @@ pub mod common;
#[cfg(test)] #[cfg(test)]
pub mod chaos; pub mod chaos;
#[cfg(test)]
mod scanner_heal_evidence;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8) // Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151). // and on-demand-migration source scenarios (backlog#2151).
#[cfg(test)] #[cfg(test)]
@@ -356,238 +356,3 @@ async fn tampered_presigned_put_returns_signature_does_not_match() -> Result<(),
); );
Ok(()) Ok(())
} }
/// GHSA-g8w9-qw9q-fghr: a presigned PUT signed with `SignedHeaders=host` must
/// not honour `x-amz-*` headers the uploader adds afterwards. The presign
/// authorised one plain upload; the extra headers would set tags, storage
/// class and a website redirect the presigner never covered. AWS S3 rejects
/// this with 403 `AccessDenied`, and so must RustFS — and the object must not
/// be stored at all, not merely stored without the properties.
#[tokio::test]
async fn ghsa_g8w9_presigned_put_rejects_unsigned_x_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "presigned-put-unsigned-amz-headers.txt";
let pr = env
.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.presigned(valid_config())
.await?;
assert!(
!pr.headers().any(|(name, _)| name.eq_ignore_ascii_case("x-amz-tagging")),
"fixture must presign a plain PutObject without tagging so the header below is unsigned"
);
let unsigned: Vec<(&str, &str)> = vec![
("x-amz-tagging", "owner=attacker&classification=public"),
("x-amz-website-redirect-location", "https://attacker.example/phish"),
("x-amz-storage-class", "REDUCED_REDUNDANCY"),
];
let headers = pr.headers().chain(unsigned.iter().copied());
let resp = send_raw(pr.method(), pr.uri(), headers, Some(b"should-not-be-stored".to_vec())).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status.as_u16(),
403,
"presigned PUT with unsigned x-amz-* headers must be 403, body:\n{body}"
);
assert_error_code(&body, "AccessDenied");
assert!(
body.contains("were not signed"),
"rejection must name unsigned headers as the cause, got:\n{body}"
);
let error = env
.create_s3_client()
.head_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("presigned PUT with unsigned x-amz-* headers must not store the object");
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(404),
"absence probe after the rejected upload must return HTTP 404, got {error:?}"
);
Ok(())
}
/// GHSA-g8w9-qw9q-fghr positive control: when the presigner itself sets the
/// property, the SDK lists `x-amz-tagging` in `SignedHeaders`, the uploader
/// replays it, and the upload succeeds with the tags applied. Without this the
/// negative test above could pass because the server rejects every tagged
/// presigned upload.
#[tokio::test]
async fn ghsa_g8w9_presigned_put_accepts_signed_x_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "presigned-put-signed-tagging.txt";
let pr = env
.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.tagging("owner=app")
.presigned(valid_config())
.await?;
assert!(
pr.headers().any(|(name, _)| name.eq_ignore_ascii_case("x-amz-tagging")),
"fixture must carry x-amz-tagging as a signed header"
);
assert!(
pr.uri().contains("x-amz-tagging"),
"X-Amz-SignedHeaders must list x-amz-tagging, uri: {}",
pr.uri()
);
let resp = send_presigned(&pr, Some(b"stored-with-signed-tagging".to_vec())).await?;
let status = resp.status();
let body = resp.text().await?;
assert!(
status.is_success(),
"presigned PUT with signed x-amz-tagging must succeed, got {status}, body:\n{body}"
);
let tags = env
.create_s3_client()
.get_object_tagging()
.bucket(BUCKET)
.key(key)
.send()
.await?;
let tag_set: Vec<(String, String)> = tags
.tag_set()
.iter()
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
.collect();
assert_eq!(tag_set, vec![("owner".to_string(), "app".to_string())], "signed tagging must be applied");
info!("signed presigned tagging control passed");
Ok(())
}
/// GHSA-g8w9-qw9q-fghr on the read side: a presigned GET signed with
/// `SignedHeaders=host` must not accept an unsigned SSE-C header. The header
/// would otherwise select a decryption path the presigner never authorised.
#[tokio::test]
async fn ghsa_g8w9_presigned_get_rejects_unsigned_x_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let pr = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(CANONICAL_KEY)
.presigned(valid_config())
.await?;
let unsigned: Vec<(&str, &str)> = vec![("x-amz-server-side-encryption-customer-algorithm", "AES256")];
let headers = pr.headers().chain(unsigned.iter().copied());
let resp = send_raw(pr.method(), pr.uri(), headers, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status.as_u16(),
403,
"presigned GET with an unsigned x-amz-* header must be 403, body:\n{body}"
);
assert_error_code(&body, "AccessDenied");
assert!(
!body.contains(std::str::from_utf8(CANONICAL_BODY)?),
"rejected GET must not leak the object body"
);
Ok(())
}
/// GHSA-g8w9-qw9q-fghr: an unsigned `x-amz-copy-source` would turn a presigned
/// PutObject into a CopyObject of an arbitrary readable key, since operation
/// routing happens before authorization. The presigned upload must fail and
/// leave nothing behind.
#[tokio::test]
async fn ghsa_g8w9_presigned_put_rejects_unsigned_copy_source() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "presigned-put-unsigned-copy-source.txt";
let pr = env
.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.presigned(valid_config())
.await?;
let copy_source = format!("/{BUCKET}/{CANONICAL_KEY}");
let unsigned: Vec<(&str, &str)> = vec![("x-amz-copy-source", copy_source.as_str())];
let headers = pr.headers().chain(unsigned.iter().copied());
let resp = send_raw(pr.method(), pr.uri(), headers, None).await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status.as_u16(),
403,
"presigned PUT with an unsigned copy source must be 403, body:\n{body}"
);
assert_error_code(&body, "AccessDenied");
let error = env
.create_s3_client()
.head_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("rejected copy must not create the destination object");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
Ok(())
}
/// GHSA-g8w9-qw9q-fghr boundary control: the rule covers `x-amz-*` only. A
/// plain `Content-Type` on a `SignedHeaders=host` presigned PUT is outside
/// SigV4's signed-header requirement (AWS S3 accepts it too) and must keep
/// working, so the negative tests above cannot pass by rejecting every
/// unsigned header.
#[tokio::test]
async fn ghsa_g8w9_presigned_put_still_accepts_unsigned_non_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let key = "presigned-put-unsigned-content-type.txt";
let pr = env
.create_s3_client()
.put_object()
.bucket(BUCKET)
.key(key)
.presigned(valid_config())
.await?;
let unsigned: Vec<(&str, &str)> = vec![("content-type", "text/x-rustfs-test")];
let headers = pr.headers().chain(unsigned.iter().copied());
let resp = send_raw(pr.method(), pr.uri(), headers, Some(b"plain-header-upload".to_vec())).await?;
let status = resp.status();
let body = resp.text().await?;
assert!(
status.is_success(),
"presigned PUT with an unsigned Content-Type must succeed, got {status}, body:\n{body}"
);
let head = env.create_s3_client().head_object().bucket(BUCKET).key(key).send().await?;
assert_eq!(
head.content_type(),
Some("text/x-rustfs-test"),
"unsigned Content-Type must still be applied"
);
Ok(())
}
@@ -79,11 +79,6 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
.await .await
.map_err(|e| anyhow!("{}", e))?; .map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
// A signed S3 request establishes readiness before the first SFTP login.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?; let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
cmptst_01::run_medium_binary_round_trip(&sftp).await?; cmptst_01::run_medium_binary_round_trip(&sftp).await?;
@@ -106,6 +101,8 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
// reach the finalised object as x-amz-meta-* user metadata // reach the finalised object as x-amz-meta-* user metadata
// through the CreateMultipartUpload input field. The S3 client // through the CreateMultipartUpload input field. The S3 client
// connects to the same rustfs process this suite already drives. // connects to the same rustfs process this suite already drives.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?; cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
drop(sftp); drop(sftp);
+10 -7
View File
@@ -168,10 +168,6 @@ pub async fn test_sftp_core_operations() -> Result<()> {
.await .await
.map_err(|e| anyhow!("{}", e))?; .map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp().await?; let (session, sftp) = connect_sftp().await?;
// --- 1. Subsystem canary: SFTP session reachable after password auth --- // --- 1. Subsystem canary: SFTP session reachable after password auth ---
@@ -352,6 +348,16 @@ pub async fn test_sftp_core_operations() -> Result<()> {
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await; let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
info!("PASS: bad-password authentication rejected"); info!("PASS: bad-password authentication rejected");
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
// The rustfs binary spawned for this suite serves both SFTP on port
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
// initialising after TCP is listening, so list_buckets is polled
// until it succeeds before any cross-protocol assertion runs.
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
info!("PASS: S3 endpoint reachable from cross-protocol client");
// --- SFTP write, S3 read: SHA256 round-trip --- // --- SFTP write, S3 read: SHA256 round-trip ---
// SFTP creates the object, then assert_cross_protocol_sha_match // SFTP creates the object, then assert_cross_protocol_sha_match
// fetches it via both S3 GetObject and SFTP READ and compares // fetches it via both S3 GetObject and SFTP READ and compares
@@ -516,9 +522,6 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
.await .await
.map_err(|e| anyhow!("{}", e))?; .map_err(|e| anyhow!("{}", e))?;
let s3 = build_test_s3_client(&format!("http://{IDLE_S3_ADDRESS}"));
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?; let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
// Confirm the session is live before the wait so a failure in the // Confirm the session is live before the wait so a failure in the
@@ -627,7 +627,7 @@ async fn put_bucket_replication_rules(
Ok(()) Ok(())
} }
pub(crate) async fn delete_bucket_replication( async fn delete_bucket_replication(
env: &RustFSTestEnvironment, env: &RustFSTestEnvironment,
bucket: &str, bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> { ) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
@@ -9055,11 +9055,9 @@ async fn test_replication_check_flags_multipart_only_version_minting_target() ->
.is_some_and(|error| error.contains("CreateMultipartUpload")), .is_some_and(|error| error.contains("CreateMultipartUpload")),
"the failure must name the multipart path: {payload}" "the failure must name the multipart path: {payload}"
); );
// The PutObject leg mirrored, so it is the multipart probe that failed; // The PutObject leg mirrored, so it is the multipart probe that failed.
// the mutation phases address the id the PUT reported and still run.
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}"); assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "OK", "{payload}"); assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
assert_eq!(target_report["Phases"]["VersionDelete"]["Status"], "OK", "{payload}");
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}"); assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
let probe_key = target let probe_key = target
@@ -9247,9 +9245,6 @@ async fn test_replication_check_flags_version_minting_target() -> TestResult {
let target_bucket = "version-fidelity-dst"; let target_bucket = "version-fidelity-dst";
target.create_bucket(target_bucket); target.create_bucket(target_bucket);
target.assign_own_version_ids(true); target.assign_own_version_ids(true);
// Wasabi shape: the probe version the VersionDelete phase removed answers
// NoSuchVersion to cleanup's second DELETE, which must count as clean.
target.reject_unknown_version_deletes(true);
let mut source_env = RustFSTestEnvironment::new().await?; let mut source_env = RustFSTestEnvironment::new().await?;
let mut env_vars = replication_fast_env(); let mut env_vars = replication_fast_env();
@@ -10190,199 +10185,3 @@ async fn test_get_object_tagging_proxies_unreplicated_object_to_replication_targ
target.shutdown().await; target.shutdown().await;
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// backlog#2363
// ---------------------------------------------------------------------------
/// Wait until the source reports a terminal replication status for `key`.
async fn wait_terminal_replication_status(
client: &Client,
bucket: &str,
key: &str,
ssec: bool,
timeout: Duration,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let deadline = tokio::time::Instant::now() + timeout;
loop {
let request = client.head_object().bucket(bucket).key(key);
let head = if ssec {
request
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?
} else {
request.send().await?
};
let status = head.replication_status().map(|status| status.as_str().to_string());
if matches!(status.as_deref(), Some("COMPLETED") | Some("FAILED")) {
return Ok(status.unwrap_or_default());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("{bucket}/{key}: replication never reached a terminal status; last {status:?}").into());
}
sleep(Duration::from_millis(250)).await;
}
}
/// backlog#2363: SSE-C ciphertext passthrough of objects the source stored
/// compressed. The replica on a RustFS target must decrypt to the original
/// bytes for a single PUT and for a multipart upload.
#[tokio::test]
async fn test_bucket_replication_sse_c_compressed_passthrough() -> TestResult {
init_logging();
const PART_SIZE: usize = 5 * 1024 * 1024;
let mut source_env = RustFSTestEnvironment::new().await?;
let mut target_env = RustFSTestEnvironment::new().await?;
let mut source_process_env = replication_fast_env();
source_process_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_process_env.extend_from_slice(FAST_SCANNER_ENV);
source_process_env.extend_from_slice(&[
("NO_PROXY", "127.0.0.1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
("RUSTFS_COMPRESSION_ENABLED", "true"),
("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true"),
]);
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
target_env
.start_rustfs_server_without_cleanup_with_env(&[
("NO_PROXY", "127.0.0.1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
])
.await?;
let source_bucket = "ssec-compressed-src";
let target_bucket = "ssec-compressed-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
let text = |len: usize, seed: u32| -> Vec<u8> {
let mut out = Vec::with_capacity(len + 64);
let mut line = 0u64;
while out.len() < len {
out.extend_from_slice(format!("ssec compressed passthrough seed={seed} line={line} lorem ipsum dolor\n").as_bytes());
line += 1;
}
out.truncate(len);
out
};
let single_key = "ssec-compressed-single.txt";
let single_body = text(1024 * 1024 + 17, 1);
source_client
.put_object()
.bucket(source_bucket)
.key(single_key)
.content_type("text/plain")
.body(ByteStream::from(single_body.clone()))
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
let multipart_key = "ssec-compressed-multipart.txt";
let multipart_parts = [text(PART_SIZE, 2), text(1024 * 1024 + 4096, 3)];
let multipart_body: Vec<u8> = multipart_parts.concat();
let created = source_client
.create_multipart_upload()
.bucket(source_bucket)
.key(multipart_key)
.content_type("text/plain")
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
let upload_id = created.upload_id().ok_or("missing multipart upload id")?.to_string();
let mut completed = Vec::new();
for (index, part) in multipart_parts.iter().enumerate() {
let part_number = i32::try_from(index + 1)?;
let uploaded = source_client
.upload_part()
.bucket(source_bucket)
.key(multipart_key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from(part.clone()))
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
completed.push(
CompletedPart::builder()
.part_number(part_number)
.set_e_tag(uploaded.e_tag().map(str::to_string))
.build(),
);
}
source_client
.complete_multipart_upload()
.bucket(source_bucket)
.key(multipart_key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
let mut failures = Vec::new();
for (key, body) in [(single_key, &single_body), (multipart_key, &multipart_body)] {
let status = wait_terminal_replication_status(&source_client, source_bucket, key, true, Duration::from_secs(120)).await?;
if status != "COMPLETED" {
failures.push(format!("{key}: source reports {status}"));
continue;
}
let replica = target_client
.get_object()
.bucket(target_bucket)
.key(key)
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await;
match replica {
Ok(replica) => {
let content_length = replica.content_length();
match replica.body.collect().await {
Ok(collected) => {
let bytes = collected.into_bytes();
if bytes.as_ref() != body.as_slice() {
failures.push(format!(
"{key}: replica bytes differ (content_length={content_length:?}, got {} bytes, want {})",
bytes.len(),
body.len()
));
}
}
Err(err) => failures.push(format!("{key}: replica body read failed: {err}")),
}
}
Err(err) => failures.push(format!("{key}: replica GET failed: {err}")),
}
}
assert!(
failures.is_empty(),
"SSE-C compressed passthrough replicas must decrypt to the source bytes: {failures:?}"
);
Ok(())
}
@@ -36,12 +36,11 @@ use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
use crate::fake_s3_target::{FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation, RequestRecord}; use crate::fake_s3_target::{FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation, RequestRecord};
use crate::on_demand_migration::common::{OdmEnvOptions, OdmTestEnv, fake_source_client}; use crate::on_demand_migration::common::{OdmEnvOptions, OdmTestEnv, fake_source_client};
use crate::replication_extension_test::{ use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, delete_bucket_replication, enable_bucket_versioning, LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, get_replication_reset_status,
get_replication_reset_status, put_bucket_replication, put_bucket_replication_with_delete_statuses, put_bucket_replication, put_bucket_replication_with_delete_statuses, set_replication_target_with_options,
set_replication_target_with_options, start_bucket_replication_reset, start_bucket_replication_reset,
}; };
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTime}; use aws_sdk_s3::primitives::{ByteStream, DateTime};
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold, Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold,
@@ -641,141 +640,6 @@ async fn matrix_mint_own_version_ids_addresses_mutations_through_the_ledger() ->
Ok(()) Ok(())
} }
/// rustfs/backlog#2340 (pending purge lifecycle): a permanent delete whose
/// replication keeps failing leaves the version in xl.meta as a PENDING purge,
/// hidden from listings. Once the bucket's replication configuration is
/// removed nothing can ever confirm that purge remotely, so the delete worker
/// must settle it locally (abandoned, with the replica left on the former
/// target) — otherwise the bucket stays `BucketNotEmpty` forever with a
/// residue the client cannot see.
#[tokio::test]
async fn matrix_removed_replication_config_abandons_pending_purge() -> TestResult {
init_logging();
let target = FakeS3Target::start().await?;
let target_bucket = "matrix-abandoned-purge-dst".to_string();
target.create_bucket_with_object_lock(target_bucket.clone());
TargetMode::MintOwnVersionIds.apply(&target);
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[
("NO_PROXY", "127.0.0.1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
// The scanner heal pass is what revisits a pending purge.
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
]);
let env = OdmTestEnv::start_with(OdmEnvOptions {
env: env_vars,
..OdmEnvOptions::default()
})
.await?;
let source_env = &env.rustfs;
let source_bucket = "matrix-abandoned-purge-src";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
enable_bucket_versioning(source_env, source_bucket).await?;
let target_arn = set_replication_target_with_options(
source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket: &target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
let key = "purge/orphaned.bin";
let put = source_client
.put_object()
.bucket(source_bucket)
.key(key)
.body(ByteStream::from(payload(4 * 1024, 0x07)))
.send()
.await?;
let source_version = put.version_id().ok_or("source PUT returned no version id")?.to_string();
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, key).await?,
"COMPLETED"
);
let replica = single_target_version(&target, &target_bucket, key)?;
// The target refuses every purge: the version stays a pending purge.
// More refusals than any scanner cycle can consume within the test.
target.inject_for_key(FakeTargetOperation::DeleteObject, key, FakeTargetFault::ResponseStatus(503), 4_000);
source_client
.delete_object()
.bucket(source_bucket)
.key(key)
.version_id(&source_version)
.send()
.await?;
wait_until("the refused purge to reach the target at least once", || async {
Ok(target.count_requests(FakeTargetOperation::DeleteObject, key) >= 1)
})
.await?;
let listed = source_client.list_object_versions().bucket(source_bucket).send().await?;
assert!(
listed.versions().is_empty() && listed.delete_markers().is_empty(),
"a pending purge is hidden from listings: {listed:?}"
);
let blocked = source_client.delete_bucket().bucket(source_bucket).send().await;
assert!(
blocked
.as_ref()
.err()
.and_then(|err| err.as_service_error())
.is_some_and(|err| err.code() == Some("BucketNotEmpty")),
"the hidden pending purge must block DeleteBucket while the target is still configured: {blocked:?}"
);
// Removing the replication configuration orphans the purge; the scanner
// heal pass must settle it locally so the bucket becomes deletable.
let response = delete_bucket_replication(source_env, source_bucket).await?;
assert!(response.status().is_success(), "DeleteBucketReplication: {}", response.status());
wait_until("DeleteBucket to succeed once the orphaned purge is abandoned", || async {
match source_client.delete_bucket().bucket(source_bucket).send().await {
Ok(_) => Ok(true),
Err(err) if err.as_service_error().is_some_and(|err| err.code() == Some("BucketNotEmpty")) => Ok(false),
Err(err) => Err(err.into()),
}
})
.await?;
// Abandoned means abandoned: the replica stays on the former target and,
// once the attempts in flight at removal time have drained, no further
// purge attempts are sent to it.
assert_eq!(
single_target_version(&target, &target_bucket, key)?,
replica,
"an abandoned purge must not touch the replica on the former target"
);
sleep(Duration::from_secs(3)).await;
let settled = target.count_requests(FakeTargetOperation::DeleteObject, key);
sleep(Duration::from_secs(3)).await;
assert_eq!(
target.count_requests(FakeTargetOperation::DeleteObject, key),
settled,
"purge attempts must stop once the target is no longer configured"
);
target.shutdown().await;
Ok(())
}
fn single_target_version(target: &FakeS3Target, target_bucket: &str, key: &str) -> Result<String, Box<dyn Error + Send + Sync>> { fn single_target_version(target: &FakeS3Target, target_bucket: &str, key: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
let versions = target.stored_versions(target_bucket, key); let versions = target.stored_versions(target_bucket, key);
match versions.as_slice() { match versions.as_slice() {
@@ -1,181 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Build-bound evidence for scanner and heal restart tests.
use crate::common::ClusterTopology;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
pub(crate) struct ScannerHealEvidenceCase {
pub(crate) id: &'static str,
pub(crate) oracle: &'static str,
pub(crate) evidence: &'static str,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) topology: EvidenceTopology,
pub(crate) storage_class_standard: Option<&'static str>,
pub(crate) erasure_set_drive_count: Option<&'static str>,
}
#[derive(Clone, Copy)]
pub(crate) struct EvidenceTopology {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
}
impl EvidenceTopology {
pub(crate) const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self { nodes, drives_per_node }
}
pub(crate) fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
pub(crate) fn cluster_topology(self) -> ClusterTopology {
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
}
}
pub(crate) struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
pub(crate) fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
pub(crate) struct RestartObservation {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
pub(crate) pid_before: u32,
pub(crate) pid_after: u32,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) objects: Vec<serde_json::Value>,
pub(crate) node_listings: Vec<Vec<String>>,
}
impl RestartEvidenceContext {
pub(crate) fn write(self, binary: &Path, observed: RestartObservation) -> Result<(), Box<dyn Error + Send + Sync>> {
assert_ne!(observed.pid_before, observed.pid_after, "target must be a new process");
assert_eq!(file_sha256(binary)?, self.run.binary.sha256, "server build changed during restart");
let evidence = serde_json::json!({
"schema": 1, "case": self.case.id, "evidence": self.case.evidence,
"run_id": self.run.run_id, "source_revision": self.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": self.run.binary.sha256,
"test_binary_sha256": self.run.test_binary.sha256,
"topology": {"nodes": observed.nodes, "drives_per_node": observed.drives_per_node},
"pid_before": observed.pid_before, "pid_after": observed.pid_after,
"unclean_shutdown_marker": observed.unclean_shutdown_marker,
"objects": observed.objects, "node_listings": observed.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(self.directory.join(self.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
}
@@ -1473,17 +1473,20 @@ fn layout_cases() -> Vec<LayoutCase> {
true, true,
), ),
// SSE-C passthrough replicates the stored ciphertext part by part; a // SSE-C passthrough replicates the stored ciphertext part by part; a
// compressible first part is stored well below 5 MiB, so the sender // compressible first part is stored well below 5 MiB and a standard
// declares each part's plaintext length and the target validates the // target rejects it with EntityTooSmall. rc.5 fails the same way (see
// 5 MiB minimum against it (rustfs/backlog#2363). rc.5 as the sender // `rc5_baseline_replicates_multipart_layouts`), so the outcome is
// still fails this layout (see `rc5_baseline_replicates_multipart_layouts`). // recorded rather than asserted here; tracked as rustfs/backlog#2363.
case( LayoutCase {
LAYOUT_PLAIN_BUCKET, assert_replication: false,
"plain/ssec-compressed-multipart-2.txt", ..case(
two.clone(), LAYOUT_PLAIN_BUCKET,
layout_text(total(&two), 7), "plain/ssec-compressed-multipart-2.txt",
true, two.clone(),
), layout_text(total(&two), 7),
true,
)
},
case( case(
LAYOUT_ENCRYPTED_BUCKET, LAYOUT_ENCRYPTED_BUCKET,
"encrypted/single.bin", "encrypted/single.bin",
@@ -1835,20 +1838,27 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
transport.uploaded_parts, expected, transport.uploaded_parts, expected,
"{label}: stored parts must replicate as the same multipart layout" "{label}: stored parts must replicate as the same multipart layout"
); );
// An object still PENDING when the next scanner cycle arrives is // The current build can drive an existing object twice (two
// not driven a second time (rustfs/backlog#2362); the journal is // full CreateMultipartUpload/UploadPart/Complete rounds with
// logged so a duplicate round is visible if this ever regresses. // distinct upload ids) while its status is still PENDING; the
assert_eq!( // rc.5 baseline drives once. That is a scheduling difference,
transport.completes, 1, // not a layout one, tracked as rustfs/backlog#2362.
"{label}: exactly one CompleteMultipartUpload; journal {:?}", assert!(transport.completes >= 1, "{label}: at least one CompleteMultipartUpload");
transport.journal if transport.completes > 1 {
); tracing::warn!(
target: "e2e_test::upgrade_compatibility_test",
object = %label,
completes = transport.completes,
journal = ?transport.journal,
"existing-object replication drove the same object more than once (rustfs/backlog#2362)"
);
}
assert_eq!( assert_eq!(
transport.single_puts, 0, transport.single_puts, 0,
"{label}: a multipart layout must not go out as a single PutObject" "{label}: a multipart layout must not go out as a single PutObject"
); );
} else { } else {
assert_eq!(transport.single_puts, 1, "{label}: a single PUT replicates as exactly one PutObject"); assert!(transport.single_puts >= 1, "{label}: a single PUT replicates as PutObject");
assert!(transport.uploaded_parts.is_empty(), "{label}: a single PUT must not go out as multipart"); assert!(transport.uploaded_parts.is_empty(), "{label}: a single PUT must not go out as multipart");
} }
if !case.ssec { if !case.ssec {
@@ -1918,59 +1928,3 @@ async fn rc5_baseline_replicates_multipart_layouts() -> TestResult {
tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "rc.5 baseline replication outcomes"); tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "rc.5 baseline replication outcomes");
Ok(()) Ok(())
} }
/// backlog#2362 under the same conditions that reproduced it with the rc.5
/// writer, but with the workspace build on both sides so it runs in the
/// ordinary lane: every pre-existing layout is driven through exactly one
/// upload round even though the scanner re-scans it every second while the
/// first round is still in flight.
#[tokio::test]
async fn existing_object_replication_drives_each_layout_once() -> TestResult {
init_logging();
let server_env = layout_server_env();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &server_env).await?;
let writer = env.create_s3_client();
env.create_test_bucket(LAYOUT_PLAIN_BUCKET).await?;
env.create_test_bucket(LAYOUT_ENCRYPTED_BUCKET).await?;
enable_versioning(&writer, LAYOUT_PLAIN_BUCKET).await?;
enable_versioning(&writer, LAYOUT_ENCRYPTED_BUCKET).await?;
put_default_sse_s3_encryption(&writer, LAYOUT_ENCRYPTED_BUCKET).await?;
let mut cases = layout_cases();
for case in cases.iter_mut() {
layout_write(&writer, case).await?;
let head = layout_head(&writer, case).await?;
case.rc5_etag = head
.e_tag()
.ok_or_else(|| format!("{}: HEAD omitted the ETag", case.label()))?
.trim_matches('"')
.to_string();
}
// The objects come from an earlier process lifetime: the scanner starts
// cold and every object is a candidate at once.
env.restart_server_preserving_data(vec![], &server_env).await?;
let client = env.create_s3_client();
let (_target, transports) = replicate_layouts(&env, &client, &cases).await?;
let mut duplicates = Vec::new();
for (case, transport) in cases.iter().zip(&transports) {
assert_eq!(
transport.status,
"COMPLETED",
"{}: existing-object replication must complete",
case.label()
);
let rounds = if case.is_multipart_layout() {
transport.completes
} else {
transport.single_puts
};
if rounds != 1 {
duplicates.push(format!("{}: {rounds} upload rounds; journal {:?}", case.label(), transport.journal));
}
}
assert!(duplicates.is_empty(), "each existing object must be driven exactly once: {duplicates:?}");
Ok(())
}
-2
View File
@@ -118,8 +118,6 @@ hotpath-cpu = [
# injection, xl.meta transition assertions) via `api::tier::test_util`. # injection, xl.meta transition assertions) via `api::tier::test_util`.
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6). # Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
test-util = [] test-util = []
# Observes real startup CAS only in the dedicated E2E binary.
e2e-test-hooks = []
[dependencies] [dependencies]
hotpath.workspace = true hotpath.workspace = true
+5 -8
View File
@@ -33,7 +33,6 @@ pub mod bucket {
pub use crate::bucket::bucket_target_sys::{ pub use crate::bucket::bucket_target_sys::{
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError, AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query, SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query,
resolve_delete_api_version_id,
}; };
} }
@@ -384,8 +383,6 @@ pub mod data_usage {
pub mod disk { pub mod disk {
pub use crate::disk::disk_store::get_object_disk_read_timeout; pub use crate::disk::disk_store::get_object_disk_read_timeout;
pub use crate::disk::local::ScanGuard; pub use crate::disk::local::ScanGuard;
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{ pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp, BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
@@ -418,8 +415,8 @@ pub mod disk {
pub mod error { pub mod error {
pub use crate::error::{ pub use crate::error::{
Error, PoolMetadataError, PoolMetadataFailure, Result, StorageError, classify_system_path_failure_reason, Error, Result, StorageError, classify_system_path_failure_reason, is_err_bucket_not_found, is_err_object_not_found,
is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found, is_err_version_not_found,
}; };
} }
@@ -563,11 +560,11 @@ pub mod store_list {
} }
pub mod storage { pub mod storage {
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME}; pub use crate::core::pools::HealLifecycleExpiryContext;
pub use crate::store::HealWalkVersion; pub use crate::store::HealWalkVersion;
pub use crate::store::{ pub use crate::store::{
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients, find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx, prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
}; };
} }
@@ -1425,12 +1425,7 @@ fn build_remove_object_headers(version_id: Option<&str>, opts: &RemoveObjectOpti
/// and silently creates a delete marker instead of removing the version, while /// and silently creates a delete marker instead of removing the version, while
/// the source stamps `VersionPurgeStatus=Complete` (backlog#799 B8 / #857). /// the source stamps `VersionPurgeStatus=Complete` (backlog#799 B8 / #857).
/// Non-replication callers always pass the version through unchanged. /// Non-replication callers always pass the version through unchanged.
/// The `versionId` a replicated DELETE puts on the wire: none for a fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
/// delete-marker creation (the target mints the marker; the source version
/// travels in the internal headers for RustFS peers), the addressed version
/// otherwise. A generic S3 target given the version id on a marker-creation
/// DELETE would permanently delete that version instead.
pub fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
if opts.replication_request && opts.replication_delete_marker { if opts.replication_request && opts.replication_delete_marker {
None None
} else { } else {
@@ -4036,10 +4036,6 @@ impl ManualTransitionRunReport {
|| self.skipped_queue_timeout > 0 || self.skipped_queue_timeout > 0
} }
fn has_enqueue_backpressure(&self) -> bool {
self.skipped_queue_full > 0 || self.skipped_queue_closed > 0 || self.skipped_queue_timeout > 0
}
pub fn was_truncated(&self) -> bool { pub fn was_truncated(&self) -> bool {
self.truncated_by_limit || self.truncated_by_duration || self.cancelled self.truncated_by_limit || self.truncated_by_duration || self.cancelled
} }
@@ -4255,7 +4251,7 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
} }
report.scanned = report.scanned.saturating_add(1); report.scanned = report.scanned.saturating_add(1);
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await; enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
if report.has_enqueue_backpressure() { if report.has_partial_enqueue() {
report.next_marker.clone_from(&previous_marker); report.next_marker.clone_from(&previous_marker);
report.next_version_idmarker.clone_from(&previous_version_marker); report.next_version_idmarker.clone_from(&previous_version_marker);
report.continuation_token = report.continuation_token =
@@ -9954,18 +9950,6 @@ mod tests {
assert_eq!(report.skipped_queue_closed, 0); assert_eq!(report.skipped_queue_closed, 0);
assert_eq!(report.skipped_queue_timeout, 0); assert_eq!(report.skipped_queue_timeout, 0);
assert!(report.has_partial_enqueue()); assert!(report.has_partial_enqueue());
assert!(report.has_enqueue_backpressure());
}
#[test]
fn manual_transition_in_flight_skip_does_not_stop_the_scan() {
let options = ManualTransitionRunOptions::default();
let mut report = ManualTransitionRunReport::new("bucket", &options);
report.record_enqueue_outcome(TransitionEnqueueOutcome::AlreadyInFlight);
assert!(report.has_partial_enqueue());
assert!(!report.has_enqueue_backpressure());
} }
#[test] #[test]
+10 -27
View File
@@ -54,9 +54,6 @@ pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetD
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new(); pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_BUCKET_METADATA: &str = "bucket_metadata";
const EVENT_BUCKET_METADATA_LOAD_FAILED: &str = "bucket_metadata_load_failed";
#[cfg(any(test, feature = "test-util"))] #[cfg(any(test, feature = "test-util"))]
struct ConfigWriteLockProbeState { struct ConfigWriteLockProbeState {
@@ -1617,20 +1614,13 @@ impl BucketMetadataSys {
let results = join_all(futures).await; let results = join_all(futures).await;
for (bucket, res) in buckets.iter().zip(results) { for (idx, res) in results.into_iter().enumerate() {
match res { match res {
Ok(()) => {} Ok(()) => {}
Err(e) => { Err(e) => {
if failed_buckets.insert(bucket.clone()) { error!("Unable to load bucket metadata, will be retried: {:?}", e);
error!( if let Some(bucket) = buckets.get(idx) {
event = EVENT_BUCKET_METADATA_LOAD_FAILED, failed_buckets.insert(bucket.clone());
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
result = "retry_pending",
bucket = %bucket,
error_code = ?e.code(),
"Unable to load bucket metadata; retry scheduled"
);
} }
} }
} }
@@ -1657,19 +1647,12 @@ impl BucketMetadataSys {
}); });
} }
let results = join_all(futures).await; let results = join_all(futures).await;
for (bucket, result) in buckets.iter().zip(results) { for (idx, result) in results.into_iter().enumerate() {
if let Err(err) = result if let Err(err) = result {
&& failed_buckets.insert(bucket.clone()) error!("Unable to load bucket metadata, will be retried: {:?}", err);
{ if let Some(bucket) = buckets.get(idx) {
error!( failed_buckets.insert(bucket.clone());
event = EVENT_BUCKET_METADATA_LOAD_FAILED, }
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
result = "retry_pending",
bucket = %bucket,
error_code = ?err.code(),
"Unable to load bucket metadata; retry scheduled"
);
} }
} }
} }
@@ -75,7 +75,6 @@ use tracing::{debug, info, instrument, warn};
const EVENT_REPLICATION_WORKER_RESIZE_SKIPPED: &str = "replication_worker_resize_skipped"; const EVENT_REPLICATION_WORKER_RESIZE_SKIPPED: &str = "replication_worker_resize_skipped";
const EVENT_REPLICATION_WORKER_RESIZED: &str = "replication_worker_resized"; const EVENT_REPLICATION_WORKER_RESIZED: &str = "replication_worker_resized";
const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure"; const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure";
const EVENT_REPLICATION_IN_FLIGHT_SKIPPED: &str = "replication_in_flight_skipped";
const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped"; const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped";
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered"; const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable"; const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
@@ -1090,9 +1089,6 @@ pub struct ReplicationPool<S: ReplicationStorage> {
workers: RwLock<Vec<Sender<ReplicationOperation>>>, workers: RwLock<Vec<Sender<ReplicationOperation>>>,
lrg_workers: RwLock<Vec<Sender<ReplicationOperation>>>, lrg_workers: RwLock<Vec<Sender<ReplicationOperation>>>,
/// Object versions queued or being replicated right now (backlog#2362).
in_flight: Arc<ReplicationInFlight>,
// MRF (Most Recent Failures) channels // MRF (Most Recent Failures) channels
mrf_replica_tx: Sender<ReplicationOperation>, mrf_replica_tx: Sender<ReplicationOperation>,
// Shared among N MRF workers; Arc allows spawning more than one worker. // Shared among N MRF workers; Arc allows spawning more than one worker.
@@ -1151,7 +1147,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
storage, storage,
workers: RwLock::new(Vec::new()), workers: RwLock::new(Vec::new()),
lrg_workers: RwLock::new(Vec::new()), lrg_workers: RwLock::new(Vec::new()),
in_flight: Arc::new(ReplicationInFlight::default()),
mrf_replica_tx, mrf_replica_tx,
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)), mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
mrf_save_tx, mrf_save_tx,
@@ -1207,13 +1202,12 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let active_counter = self.active_lrg_workers.clone(); let active_counter = self.active_lrg_workers.clone();
let storage = self.storage.clone(); let storage = self.storage.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
let in_flight = self.in_flight.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let mut rx = rx; let mut rx = rx;
while let Some(operation) = rx.recv().await { while let Some(operation) = rx.recv().await {
let _active = ActiveWorkerGuard::new(active_counter.clone()); let _active = ActiveWorkerGuard::new(active_counter.clone());
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await; process_replication_operation(operation, stats.clone(), storage.clone()).await;
} }
}); });
@@ -1267,13 +1261,12 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let active_counter = self.active_workers.clone(); let active_counter = self.active_workers.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
let storage = self.storage.clone(); let storage = self.storage.clone();
let in_flight = self.in_flight.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let mut rx = rx; let mut rx = rx;
while let Some(operation) = rx.recv().await { while let Some(operation) = rx.recv().await {
let _active = ActiveWorkerGuard::new(active_counter.clone()); let _active = ActiveWorkerGuard::new(active_counter.clone());
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await; process_replication_operation(operation, stats.clone(), storage.clone()).await;
} }
}); });
@@ -1312,7 +1305,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let active_counter = self.active_mrf_workers.clone(); let active_counter = self.active_mrf_workers.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
let storage = self.storage.clone(); let storage = self.storage.clone();
let in_flight = self.in_flight.clone();
let mrf_rx = Arc::clone(&self.mrf_replica_rx); let mrf_rx = Arc::clone(&self.mrf_replica_rx);
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
@@ -1332,7 +1324,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let Some(operation) = operation else { break }; let Some(operation) = operation else { break };
let _active = ActiveWorkerGuard::new(active_counter.clone()); let _active = ActiveWorkerGuard::new(active_counter.clone());
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await; process_replication_operation(operation, stats.clone(), storage.clone()).await;
} }
}); });
self.task_handles.lock().await.push(handle); self.task_handles.lock().await.push(handle);
@@ -1462,24 +1454,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
/// Queues a replica task /// Queues a replica task
pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission { pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission {
// A version that is already queued or being uploaded is not driven a
// second time: the scanner heal pass sees it as PENDING until the
// first upload lands and would otherwise re-queue it every cycle
// (backlog#2362). The key is released when the worker finishes, or
// below when no worker accepts the task.
if !self.in_flight.try_begin(&ri) {
debug!(
event = EVENT_REPLICATION_IN_FLIGHT_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION,
bucket = %ri.bucket,
object = %ri.name,
version_id = ?ri.version_id,
op_type = ?ri.op_type,
"Replication task already in flight; not queued again"
);
return ReplicationQueueAdmission::Skipped;
}
let target_arns = ri.dsc.replicate_target_arns(); let target_arns = ri.dsc.replicate_target_arns();
// If object is large, queue it to a static set of large workers // If object is large, queue it to a static set of large workers
if should_queue_large_object(ri.size) { if should_queue_large_object(ri.size) {
@@ -1510,9 +1484,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let resize = large_worker_backpressure_resize(existing, self.active_lrg_workers(), max_l_workers); let resize = large_worker_backpressure_resize(existing, self.active_lrg_workers(), max_l_workers);
drop(lrg_workers); drop(lrg_workers);
// Queue to MRF if worker is busy. The MRF replay re-enters // Queue to MRF if worker is busy.
// this function, so the version is no longer in flight.
self.in_flight.finish(&ri);
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "large_object").await; let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "large_object").await;
if let Some(resize) = resize { if let Some(resize) = resize {
@@ -1521,7 +1493,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
return admission; return admission;
} }
} }
self.in_flight.finish(&ri);
return ReplicationQueueAdmission::Missed; return ReplicationQueueAdmission::Missed;
} }
@@ -1530,7 +1501,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
let ch = self.worker_queue_channel(&ri.op_type, &ri.bucket, &ri.name, ri.size).await; let ch = self.worker_queue_channel(&ri.op_type, &ri.bucket, &ri.name, ri.size).await;
let Some(channel) = ch else { let Some(channel) = ch else {
self.in_flight.finish(&ri);
return ReplicationQueueAdmission::Missed; return ReplicationQueueAdmission::Missed;
}; };
@@ -1542,9 +1512,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type); self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type);
self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size); self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size);
// Queue to MRF if all workers are busy. The MRF replay re-enters this // Queue to MRF if all workers are busy.
// function, so the version is no longer in flight.
self.in_flight.finish(&ri);
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "object").await; let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "object").await;
// Try to scale up workers based on priority // Try to scale up workers based on priority
@@ -1843,7 +1811,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
) { ) {
while let Some(operation) = rx.recv().await { while let Some(operation) = rx.recv().await {
let _active = ActiveWorkerGuard::new(active_counter.clone()); let _active = ActiveWorkerGuard::new(active_counter.clone());
process_replication_operation(operation, stats.clone(), self.storage.clone(), self.in_flight.clone()).await; process_replication_operation(operation, stats.clone(), self.storage.clone()).await;
} }
} }
@@ -1861,7 +1829,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
) { ) {
while let Some(operation) = rx.recv().await { while let Some(operation) = rx.recv().await {
let _active = ActiveWorkerGuard::new(active_counter.clone()); let _active = ActiveWorkerGuard::new(active_counter.clone());
process_replication_operation(operation, stats.clone(), storage.clone(), self.in_flight.clone()).await; process_replication_operation(operation, stats.clone(), storage.clone()).await;
} }
} }
@@ -1878,7 +1846,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
) { ) {
while let Some(operation) = rx.recv().await { while let Some(operation) = rx.recv().await {
let _active = ActiveWorkerGuard::new(active_counter.clone()); let _active = ActiveWorkerGuard::new(active_counter.clone());
process_replication_operation(operation, stats.clone(), self.storage.clone(), self.in_flight.clone()).await; process_replication_operation(operation, stats.clone(), self.storage.clone()).await;
} }
} }
@@ -2313,64 +2281,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
} }
} }
/// Object versions currently queued or being uploaded, keyed by bucket,
/// object name and version. `queue_replica_task` admits a version only once
/// while it is in flight; the scanner heal pass and MRF replays that arrive
/// in the meantime are `Skipped` instead of driving a second complete upload
/// (backlog#2362). Entries are removed when the worker finishes the task or
/// when no worker accepted it.
#[derive(Debug, Default)]
pub(crate) struct ReplicationInFlight {
keys: std::sync::Mutex<std::collections::HashSet<(String, String, Option<uuid::Uuid>)>>,
}
impl ReplicationInFlight {
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<(String, String, Option<uuid::Uuid>)>> {
self.keys.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Claim `ri`; `false` when the same version is already in flight.
fn try_begin(&self, ri: &ReplicateObjectInfo) -> bool {
self.lock().insert((ri.bucket.clone(), ri.name.clone(), ri.version_id))
}
fn finish(&self, ri: &ReplicateObjectInfo) {
self.lock().remove(&(ri.bucket.clone(), ri.name.clone(), ri.version_id));
}
#[cfg(test)]
fn len(&self) -> usize {
self.lock().len()
}
}
/// Releases the in-flight claim when the worker is done with the task,
/// including when replication panics.
struct ReplicationInFlightGuard {
in_flight: Arc<ReplicationInFlight>,
key: ReplicateObjectInfo,
}
impl ReplicationInFlightGuard {
fn new(in_flight: Arc<ReplicationInFlight>, ri: &ReplicateObjectInfo) -> Self {
Self {
in_flight,
key: ReplicateObjectInfo {
bucket: ri.bucket.clone(),
name: ri.name.clone(),
version_id: ri.version_id,
..Default::default()
},
}
}
}
impl Drop for ReplicationInFlightGuard {
fn drop(&mut self) {
self.in_flight.finish(&self.key);
}
}
struct ActiveWorkerGuard { struct ActiveWorkerGuard {
counter: Arc<AtomicI32>, counter: Arc<AtomicI32>,
} }
@@ -2432,12 +2342,10 @@ async fn process_replication_operation<S: ReplicationStorage>(
operation: ReplicationOperation, operation: ReplicationOperation,
stats: Arc<ReplicationStats>, stats: Arc<ReplicationStats>,
storage: Arc<S>, storage: Arc<S>,
in_flight: Arc<ReplicationInFlight>,
) { ) {
match operation { match operation {
ReplicationOperation::Object(obj_info) => { ReplicationOperation::Object(obj_info) => {
let _backlog = ReplicationBacklogGuard::for_object(stats, obj_info.as_ref()); let _backlog = ReplicationBacklogGuard::for_object(stats, obj_info.as_ref());
let _in_flight = ReplicationInFlightGuard::new(in_flight, obj_info.as_ref());
replicate_object(*obj_info, storage).await; replicate_object(*obj_info, storage).await;
} }
ReplicationOperation::Delete(del_info) => { ReplicationOperation::Delete(del_info) => {
@@ -3171,11 +3079,7 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
} }
let rcfg = match ReplicationMetadataStore::optional_replication_config(bucket).await { let rcfg = match ReplicationMetadataStore::optional_replication_config(bucket).await {
Ok(Some(config)) => Some(config), Ok(Some(config)) => config,
// A bucket without a configuration still owes its pending purges an
// answer: the delete worker finishes them locally as abandoned, which
// is what makes the bucket deletable again (rustfs/backlog#2340).
Ok(None) if owes_version_purge(&oi) => None,
Ok(None) => return ReplicationQueueAdmission::Skipped, Ok(None) => return ReplicationQueueAdmission::Skipped,
Err(err) => { Err(err) => {
debug!( debug!(
@@ -3225,7 +3129,7 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
} }
}; };
let rcfg_wrapper = ReplicationConfig::new(rcfg, tgts); let rcfg_wrapper = ReplicationConfig::new(Some(rcfg), tgts);
queue_replication_heal_internal(bucket, oi, rcfg_wrapper, retry_count) queue_replication_heal_internal(bucket, oi, rcfg_wrapper, retry_count)
.await .await
.admission .admission
@@ -3253,17 +3157,6 @@ pub async fn queue_replication_metadata(bucket: &str, oi: ObjectInfo, retry_coun
} }
} }
/// A version purge the persisted state still owes to named targets. Without
/// the target list nothing can be settled, so such a version keeps the
/// ordinary "no configuration, nothing to heal" skip.
fn owes_version_purge(oi: &ObjectInfo) -> bool {
!oi.version_purge_status.is_empty()
&& oi
.version_purge_status_internal
.as_deref()
.is_some_and(|statuses| !statuses.trim().is_empty())
}
/// queue_replication_heal_internal enqueues objects that failed replication OR eligible for resyncing through /// queue_replication_heal_internal enqueues objects that failed replication OR eligible for resyncing through
/// an ongoing resync operation or via existing objects replication configuration setting. /// an ongoing resync operation or via existing objects replication configuration setting.
pub(crate) async fn queue_replication_heal_internal( pub(crate) async fn queue_replication_heal_internal(
@@ -3282,11 +3175,7 @@ pub(crate) async fn queue_replication_heal_internal(
}; };
} }
// Without a configuration or targets there is nothing to replicate — if rcfg.config.is_none() || rcfg.remotes.is_none() {
// except a version purge the bucket still owes: its stored decision names
// the targets, and the delete worker settles the ones no longer
// configured as abandoned (rustfs/backlog#2340).
if (rcfg.config.is_none() || rcfg.remotes.is_none()) && !owes_version_purge(&oi) {
return ReplicationHealQueueResult { return ReplicationHealQueueResult {
object_info: roi, object_info: roi,
admission: ReplicationQueueAdmission::Skipped, admission: ReplicationQueueAdmission::Skipped,
@@ -3821,7 +3710,6 @@ mod tests {
stats: Arc::new(ReplicationStats::new()), stats: Arc::new(ReplicationStats::new()),
workers: RwLock::new(Vec::new()), workers: RwLock::new(Vec::new()),
lrg_workers: RwLock::new(Vec::new()), lrg_workers: RwLock::new(Vec::new()),
in_flight: Arc::new(ReplicationInFlight::default()),
mrf_replica_tx, mrf_replica_tx,
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)), mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
mrf_save_tx, mrf_save_tx,
@@ -3888,90 +3776,6 @@ mod tests {
assert_eq!(current_queue(&pool, "admission-bucket").await, (1, 4096)); assert_eq!(current_queue(&pool, "admission-bucket").await, (1, 4096));
} }
#[tokio::test]
async fn queue_replica_task_admits_a_version_once_while_it_is_in_flight() {
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
let (tx, _rx) = mpsc::channel(4);
pool.workers.write().await.push(tx);
let ri = ReplicateObjectInfo {
bucket: "in-flight-bucket".to_string(),
name: "object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
size: 4096,
op_type: ReplicationType::Object,
..Default::default()
};
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Queued);
// backlog#2362: the scanner heal pass sees the version as PENDING
// until the worker lands it; a second request must not drive it again.
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Skipped);
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (1, 4096));
// Another version of the same key is independent work.
let newer = ReplicateObjectInfo {
version_id: Some(uuid::Uuid::new_v4()),
..ri.clone()
};
assert_eq!(pool.queue_replica_task(newer).await, ReplicationQueueAdmission::Queued);
assert_eq!(pool.in_flight.len(), 2);
// Once the worker finishes, the same version may be queued again
// (for example after a FAILED status).
pool.in_flight.finish(&ri);
assert_eq!(pool.queue_replica_task(ri).await, ReplicationQueueAdmission::Queued);
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (3, 3 * 4096));
}
#[tokio::test]
async fn queue_replica_task_releases_the_version_when_no_worker_accepts_it() {
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
let ri = ReplicateObjectInfo {
bucket: "no-worker-bucket".to_string(),
name: "object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
size: 4096,
op_type: ReplicationType::Object,
..Default::default()
};
// No worker channel: the task is missed and must not stay claimed.
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Missed);
assert_eq!(pool.in_flight.len(), 0);
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Missed);
// A full worker channel hands the task to the MRF save path; the MRF
// replay re-enters the queue, so the claim is released here too.
let (tx, _rx) = mpsc::channel(1);
pool.workers.write().await.push(tx);
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Queued);
let overflow = ReplicateObjectInfo {
version_id: Some(uuid::Uuid::new_v4()),
..ri
};
assert_eq!(pool.queue_replica_task(overflow).await, ReplicationQueueAdmission::Queued);
assert_eq!(pool.in_flight.len(), 1, "only the version held by the worker channel stays in flight");
}
#[test]
fn in_flight_guard_releases_the_version_on_drop() {
let in_flight = Arc::new(ReplicationInFlight::default());
let ri = ReplicateObjectInfo {
bucket: "guard-bucket".to_string(),
name: "object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
..Default::default()
};
assert!(in_flight.try_begin(&ri));
assert!(!in_flight.try_begin(&ri));
{
let _guard = ReplicationInFlightGuard::new(in_flight.clone(), &ri);
assert_eq!(in_flight.len(), 1);
}
assert_eq!(in_flight.len(), 0);
assert!(in_flight.try_begin(&ri));
}
#[tokio::test] #[tokio::test]
async fn regular_worker_admission_counts_target_backlog_before_receive() { async fn regular_worker_admission_counts_target_backlog_before_receive() {
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
@@ -22,8 +22,7 @@ use super::replication_filemeta_boundary::ReplicationGenerationSnapshot;
use super::replication_filemeta_boundary::{ use super::replication_filemeta_boundary::{
REPLICATE_EXISTING, ReplicateDecision, ReplicateObjectInfo, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction, REPLICATE_EXISTING, ReplicateDecision, ReplicateObjectInfo, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
ReplicationState, ReplicationStatusType, ReplicationType, VersionPurgeStatusType, get_replication_state, ReplicationState, ReplicationStatusType, ReplicationType, VersionPurgeStatusType, get_replication_state,
parse_replicate_decision, replicate_decision_for_admitted_targets, replication_statuses_map, target_reset_header, parse_replicate_decision, replication_statuses_map, target_reset_header, version_purge_statuses_map,
version_purge_statuses_map,
}; };
use super::replication_lock_boundary::ReplicationLockTiming; use super::replication_lock_boundary::ReplicationLockTiming;
use super::replication_logging::{EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION_RESYNC}; use super::replication_logging::{EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION_RESYNC};
@@ -94,14 +93,12 @@ use rustfs_utils::{DEFAULT_SIP_HASH_KEY, get_env_usize, sip_hash};
use s3s::dto::ReplicationConfiguration; use s3s::dto::ReplicationConfiguration;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt::Display; use std::fmt::Display;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::task::{Context, Poll};
use std::time::Instant; use std::time::Instant;
use time::OffsetDateTime; use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf}; use tokio::io::AsyncRead;
use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore}; use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore};
use tokio::task::{JoinHandle, JoinSet}; use tokio::task::{JoinHandle, JoinSet};
use tokio::time::Duration as TokioDuration; use tokio::time::Duration as TokioDuration;
@@ -133,8 +130,6 @@ const EVENT_REPLICATION_DRIFTED_REPLICA_LOCATED: &str = "replication_drifted_rep
const EVENT_REPLICATION_OBJECT_FAILED: &str = "replication_object_failed"; const EVENT_REPLICATION_OBJECT_FAILED: &str = "replication_object_failed";
const EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED: &str = "replication_purge_object_lock_denied"; const EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED: &str = "replication_purge_object_lock_denied";
const EVENT_REPLICATION_PURGE_REPLICA_UNRESOLVED: &str = "replication_purge_replica_unresolved"; const EVENT_REPLICATION_PURGE_REPLICA_UNRESOLVED: &str = "replication_purge_replica_unresolved";
const EVENT_REPLICATION_PURGE_ABANDONED: &str = "replication_purge_abandoned";
const METRIC_VERSION_PURGE_ABANDONED_TOTAL: &str = "rustfs_replication_version_purge_abandoned_total";
const EVENT_REPLICATION_DRIFTED_REPLICA_METADATA_SYNCED: &str = "replication_drifted_replica_metadata_synced"; const EVENT_REPLICATION_DRIFTED_REPLICA_METADATA_SYNCED: &str = "replication_drifted_replica_metadata_synced";
const METRIC_VERSION_PURGE_REPLICA_TOTAL: &str = "rustfs_replication_version_purge_replica_total"; const METRIC_VERSION_PURGE_REPLICA_TOTAL: &str = "rustfs_replication_version_purge_replica_total";
@@ -2057,26 +2052,6 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
let target_statuses = replication_statuses_map(&oi.replication_status_internal.clone().unwrap_or_default()); let target_statuses = replication_statuses_map(&oi.replication_status_internal.clone().unwrap_or_default());
let target_purge_statuses = version_purge_statuses_map(&oi.version_purge_status_internal.clone().unwrap_or_default()); let target_purge_statuses = version_purge_statuses_map(&oi.version_purge_status_internal.clone().unwrap_or_default());
// A version purge is owed to the targets its purge state names, whatever
// the configuration says now: the decision string is not persisted, so a
// heal after restart (or after the configuration was removed or edited)
// would otherwise never revisit the purge and the hidden version would
// block DeleteBucket forever (rustfs/backlog#2340). The delete worker
// settles a target the configuration no longer names as abandoned.
let dsc = if delete_path && !dsc.replicate_any() {
let owed: Vec<String> = target_purge_statuses
.iter()
.filter(|(_, status)| matches!(status, VersionPurgeStatusType::Pending | VersionPurgeStatusType::Failed))
.map(|(arn, _)| arn.clone())
.collect();
if owed.is_empty() {
dsc
} else {
replicate_decision_for_admitted_targets(&owed)
}
} else {
dsc
};
let existing_obj_resync = if delete_path && !has_stored_delete_decision && !delete_state.0 && !delete_state.1 { let existing_obj_resync = if delete_path && !has_stored_delete_decision && !delete_state.0 && !delete_state.1 {
Default::default() Default::default()
} else { } else {
@@ -2433,11 +2408,6 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
}; };
let purge_source = version_purge_source(&storage, &bucket, &dobj, &dsc).await.map(Arc::new); let purge_source = version_purge_source(&storage, &bucket, &dobj, &dsc).await.map(Arc::new);
let configured_arns = if is_version_delete_replication(&dobj.delete_object) {
configured_replication_arns(&bucket).await
} else {
None
};
let mut join_set = JoinSet::new(); let mut join_set = JoinSet::new();
@@ -2459,17 +2429,6 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
continue; continue;
} }
// The bucket no longer replicates to this target: nothing can ever
// confirm the purge remotely, so finish it locally as abandoned.
if let Some(configured) = configured_arns.as_ref()
&& !configured.contains(&tgt_entry.arn)
{
rinfos
.targets
.push(abandoned_purge_target_info(&bucket, &dobj, &tgt_entry.arn));
continue;
}
// Get the remote target client // Get the remote target client
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(&bucket, &tgt_entry.arn).await else { let Some(tgt_client) = ReplicationTargetStore::remote_target_client(&bucket, &tgt_entry.arn).await else {
debug!( debug!(
@@ -3240,56 +3199,6 @@ fn unavailable_delete_target_info(dobj: &DeletedObjectReplicationInfo, arn: &str
rinfo rinfo
} }
/// The target ARNs the bucket's replication configuration still names, or
/// `None` when that cannot be decided right now (unreadable/invalid
/// configuration): a purge is only abandoned on positive evidence. No
/// configuration at all names no target.
async fn configured_replication_arns(bucket: &str) -> Option<HashSet<String>> {
match get_replication_config(bucket).await {
Ok(Some(config)) => Some(config.configured_target_arns()),
Ok(None) => Some(HashSet::new()),
Err(_) => None,
}
}
/// Finish a version purge locally for a target the bucket no longer
/// replicates to (the rule or the whole configuration was removed).
///
/// The source keeps a purged version in xl.meta, hidden from listings, until
/// every target confirms the purge — and once the operator removed the
/// target nothing ever will: the version stayed PENDING forever, blocking
/// `DeleteBucket` with a residue the client could neither see nor remove
/// (rustfs/backlog#2340). Reporting the purge as complete lets the normal
/// writeback drop the version. The replica, if any, stays on the former
/// target: that is the operator's data now, and this event is the record.
fn abandoned_purge_target_info(bucket: &str, dobj: &DeletedObjectReplicationInfo, arn: &str) -> ReplicatedTargetInfo {
let mut rinfo = dobj
.delete_object
.replication_state
.clone()
.unwrap_or_default()
.target_state(arn);
rinfo.op_type = dobj.op_type;
if rinfo.version_purge_status == VersionPurgeStatusType::Complete {
return rinfo;
}
warn!(
event = EVENT_REPLICATION_PURGE_ABANDONED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket,
object = dobj.delete_object.object_name,
version_id = ?dobj.delete_object.version_id.or(dobj.delete_object.delete_marker_version_id),
arn,
reason = "target_not_configured",
"Replicated version purge abandoned: the bucket no longer replicates to this target, so the version is purged locally"
);
counter!(METRIC_VERSION_PURGE_ABANDONED_TOTAL).increment(1);
rinfo.version_purge_status = VersionPurgeStatusType::Complete;
rinfo.error = None;
rinfo
}
/// What the source still knows about a data version being purged, read once /// What the source still knows about a data version being purged, read once
/// per delete: the version stays in xl.meta with a PENDING purge status until /// per delete: the version stays in xl.meta with a PENDING purge status until
/// every target confirms, so its ETag and target-version ledger are available /// every target confirms, so its ETag and target-version ledger are available
@@ -4538,6 +4447,7 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
let source_version_id = self.version_id; let source_version_id = self.version_id;
let assigned_version_id = if is_multipart { let assigned_version_id = if is_multipart {
drop(gr);
replicate_object_with_multipart(MultipartReplicationContext { replicate_object_with_multipart(MultipartReplicationContext {
storage: storage.clone(), storage: storage.clone(),
cli: tgt_client.clone(), cli: tgt_client.clone(),
@@ -4548,7 +4458,6 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
obj_opts: &obj_opts, obj_opts: &obj_opts,
arn: &rinfo.arn, arn: &rinfo.arn,
put_opts, put_opts,
full_stream: Some(gr.stream),
}) })
.await .await
} else { } else {
@@ -5285,6 +5194,7 @@ async fn replicate_all_payload_to_target<S: ReplicationObjectIO>(
} }
if ctx.is_multipart { if ctx.is_multipart {
drop(gr);
replicate_object_with_multipart(MultipartReplicationContext { replicate_object_with_multipart(MultipartReplicationContext {
storage: ctx.storage.clone(), storage: ctx.storage.clone(),
cli: ctx.tgt_client.clone(), cli: ctx.tgt_client.clone(),
@@ -5295,7 +5205,6 @@ async fn replicate_all_payload_to_target<S: ReplicationObjectIO>(
obj_opts: ctx.obj_opts, obj_opts: ctx.obj_opts,
arn: ctx.arn, arn: ctx.arn,
put_opts: ctx.put_opts, put_opts: ctx.put_opts,
full_stream: Some(gr.stream),
}) })
.await .await
} else { } else {
@@ -5367,15 +5276,11 @@ struct MultipartReplicationContext<'a, S: ReplicationObjectIO> {
obj_opts: &'a ObjectOptions, obj_opts: &'a ObjectOptions,
arn: &'a str, arn: &'a str,
put_opts: PutObjectOptions, put_opts: PutObjectOptions,
full_stream: Option<Box<dyn AsyncRead + Unpin + Send + Sync>>,
} }
async fn replicate_object_with_multipart<S: ReplicationObjectIO>( async fn replicate_object_with_multipart<S: ReplicationObjectIO>(
mut ctx: MultipartReplicationContext<'_, S>, ctx: MultipartReplicationContext<'_, S>,
) -> std::io::Result<Option<String>> { ) -> std::io::Result<Option<String>> {
if ctx.obj_opts.raw_data_movement_read || !ctx.object_info.is_compressed() {
ctx.full_stream = None;
}
let mut attempts = 1; let mut attempts = 1;
let upload_id = loop { let upload_id = loop {
match ctx match ctx
@@ -5563,21 +5468,6 @@ struct MultipartReplicationReadPlan {
next_offset: i64, next_offset: i64,
} }
#[derive(Clone)]
struct SharedReplicationReader(Arc<StdMutex<Box<dyn AsyncRead + Unpin + Send + Sync>>>);
impl AsyncRead for SharedReplicationReader {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
// UploadPart owns its body, but successive parts consume one source.
// The mutex only covers polling; no guard survives an await.
let mut reader = self
.0
.lock()
.map_err(|_| std::io::Error::other("replication source stream lock poisoned"))?;
Pin::new(&mut **reader).poll_read(cx, buf)
}
}
fn multipart_replication_read_plan( fn multipart_replication_read_plan(
object_info: &ObjectInfo, object_info: &ObjectInfo,
obj_opts: &ObjectOptions, obj_opts: &ObjectOptions,
@@ -5636,10 +5526,8 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
obj_opts, obj_opts,
arn, arn,
put_opts, put_opts,
full_stream,
} = ctx; } = ctx;
let full_stream = full_stream.map(|reader| SharedReplicationReader(Arc::new(StdMutex::new(reader))));
let mut uploaded_parts: Vec<CompletedPart> = Vec::new(); let mut uploaded_parts: Vec<CompletedPart> = Vec::new();
let mut header_size = replication_put_object_header_size(&put_opts); let mut header_size = replication_put_object_header_size(&put_opts);
@@ -5658,13 +5546,7 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
)?; )?;
offset = part_plan.next_offset; offset = part_plan.next_offset;
let byte_stream = if let Some(reader) = &full_stream { let byte_stream = if let Some(range_spec) = part_plan.range {
let limit = u64::try_from(part_plan.part_size)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "negative multipart replication part size"))?;
let part_stream =
wrap_with_bandwidth_monitor_with_header(Box::new(reader.clone().take(limit)), src_bucket, arn, header_size);
async_read_to_bytestream(part_stream)
} else if let Some(range_spec) = part_plan.range {
let part_reader = storage let part_reader = storage
.get_object_reader(src_bucket, object, Some(range_spec), HeaderMap::new(), obj_opts) .get_object_reader(src_bucket, object, Some(range_spec), HeaderMap::new(), obj_opts)
.await .await
@@ -5676,17 +5558,6 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
}; };
header_size = 0; header_size = 0;
// Passthrough parts are the stored bytes; the replica learns each
// part's plaintext length from this header (backlog#2363).
let mut part_options = PutObjectPartOptions::default();
if obj_opts.raw_data_movement_read && part_info.actual_size > 0 {
rustfs_utils::http::insert_header(
&mut part_options.custom_header,
rustfs_utils::http::SUFFIX_REPLICATION_PART_ACTUAL_SIZE,
part_info.actual_size.to_string(),
);
}
let object_part = cli let object_part = cli
.put_object_part( .put_object_part(
dst_bucket, dst_bucket,
@@ -5695,7 +5566,7 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
part_plan.part_number, part_plan.part_number,
part_plan.part_size, part_plan.part_size,
byte_stream, byte_stream,
&part_options, &PutObjectPartOptions { ..Default::default() },
) )
.await .await
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
@@ -5710,25 +5581,6 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
); );
} }
if let Some(mut reader) = full_stream {
// Range planning also trusts the recorded logical part boundaries.
// A full decoded stream avoids repeating or skipping bytes when those
// boundaries are stale, and its EOF must precede publication.
let expected_size = object_info
.get_actual_size()
.map_err(|err| std::io::Error::other(err.to_string()))?;
let mut remaining = [0_u8; 1];
if offset != expected_size || reader.read(&mut remaining).await? != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"compressed replication part lengths do not cover the complete source",
));
}
// Releasing the reader also releases the source read lock, before
// CompleteMultipartUpload or replication status writes can run.
drop(reader);
}
let actual_size = replication_multipart_complete_actual_size(&object_info.user_defined); let actual_size = replication_multipart_complete_actual_size(&object_info.user_defined);
let completed = cli let completed = cli
@@ -6867,27 +6719,6 @@ mod tests {
assert_eq!(server.join().expect("test HTTP server should finish").len(), 2); assert_eq!(server.join().expect("test HTTP server should finish").len(), 2);
} }
#[test]
fn abandoned_purge_completes_locally_and_keeps_a_finished_target_untouched() {
let arn = "arn:rustfs:replication::removed-target";
let dobj = version_purge_dobj(arn);
let rinfo = abandoned_purge_target_info("source", &dobj, arn);
assert_eq!(rinfo.version_purge_status, VersionPurgeStatusType::Complete);
assert_eq!(rinfo.arn, arn);
assert!(rinfo.error.is_none());
let mut finished = version_purge_dobj(arn);
finished
.delete_object
.replication_state
.as_mut()
.expect("purge state")
.purge_targets
.insert(arn.to_string(), VersionPurgeStatusType::Complete);
let rinfo = abandoned_purge_target_info("source", &finished, arn);
assert_eq!(rinfo.version_purge_status, VersionPurgeStatusType::Complete);
}
#[tokio::test] #[tokio::test]
async fn version_purge_refuses_a_corrupt_ledger() { async fn version_purge_refuses_a_corrupt_ledger() {
let target = test_target_client("http://127.0.0.1:1".to_string()); let target = test_target_client("http://127.0.0.1:1".to_string());
@@ -7561,42 +7392,6 @@ mod tests {
assert_eq!(roi.target_purge_statuses.get(role), Some(&VersionPurgeStatusType::Pending)); assert_eq!(roi.target_purge_statuses.get(role), Some(&VersionPurgeStatusType::Pending));
} }
/// The decision string is not persisted: after a restart, or once the
/// configuration is gone, a heal of a failed purge must still name the
/// targets the purge state records — that is what lets the delete worker
/// settle a removed target as abandoned instead of skipping forever.
#[tokio::test]
async fn heal_owes_a_failed_purge_to_the_targets_its_purge_state_names_without_a_configuration() {
let bucket = format!("heal-orphaned-purge-{}", Uuid::new_v4());
let arn = "arn:rustfs:replication:us-east-1:removed:bucket";
ReplicationVersioningStore::install_prefix_state_test_config(
&bucket,
VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
..Default::default()
},
);
let oi = ObjectInfo {
bucket,
name: "purge/orphaned.bin".to_string(),
version_id: Some(Uuid::new_v4()),
mod_time: Some(OffsetDateTime::now_utc()),
version_purge_status: VersionPurgeStatusType::Failed,
version_purge_status_internal: Some(format!("{arn}=FAILED;")),
..Default::default()
};
let mut roi = get_heal_replicate_object_info(&oi, &ReplicationConfig::new(None, None))
.await
.expect("a purge without a configuration must still classify");
assert!(roi.dsc.targets_map.get(arn).is_some_and(|target| target.replicate), "{:?}", roi.dsc);
assert!(matches!(
super::super::replication_queue_boundary::replication_heal_queue_action(&mut roi),
super::super::replication_queue_boundary::ReplicationHealQueueAction::QueueDelete(_)
));
}
#[tokio::test] #[tokio::test]
async fn heal_pending_purge_reads_one_versioning_generation() { async fn heal_pending_purge_reads_one_versioning_generation() {
let bucket = format!("heal-versioning-snapshot-{}", Uuid::new_v4()); let bucket = format!("heal-versioning-snapshot-{}", Uuid::new_v4());
@@ -8302,222 +8097,73 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn multipart_transport_preserves_legacy_zero_actual_sizes() { async fn multipart_transport_preserves_legacy_zero_actual_sizes() {
run_transport(4096, None, false).await; run_transport(4096, None).await;
} }
#[tokio::test] #[tokio::test]
async fn multipart_transport_uploads_an_empty_last_part_without_reading_a_range() { async fn multipart_transport_uploads_an_empty_last_part_without_reading_a_range() {
run_transport(0, None, false).await; run_transport(0, None).await;
} }
#[tokio::test] #[tokio::test]
async fn multipart_transport_preserves_transformed_unknown_nonempty_parts() { async fn multipart_transport_preserves_transformed_unknown_nonempty_parts() {
for unknown_part in [(0, 0), (1, 0), (0, -1), (1, -1)] { for unknown_part in [(0, 0), (1, 0), (0, -1), (1, -1)] {
run_transport(4096, Some(unknown_part), false).await; run_transport(4096, Some(unknown_part)).await;
} }
} }
#[tokio::test] #[tokio::test]
async fn multipart_transport_preserves_transformed_empty_tail() { async fn multipart_transport_preserves_transformed_empty_tail() {
run_transport(0, Some((1, 0)), false).await; run_transport(0, Some((1, 0))).await;
} }
/// SSE-C passthrough of a compressed object: the stored bytes go out async fn run_transport(tail_size: usize, unknown_part: Option<(usize, i64)>) {
/// as-is and every UploadPart declares the part's plaintext length const FIRST_SIZE: usize = 5 * 1024 * 1024;
/// (backlog#2363). let body = Bytes::from([vec![0x35; FIRST_SIZE], vec![0xa7; tail_size]].concat());
#[tokio::test] let etag = faster_hex::hex_string(rustfs_utils::hash::HashAlgorithm::Md5.hash_encode(&body).as_ref());
async fn multipart_transport_declares_passthrough_part_lengths() {
run_transport(4096, None, true).await;
}
#[tokio::test]
async fn multipart_transport_checks_positive_compressed_part_totals() {
const MIB: i64 = 1024 * 1024;
for etag_present in [true, false] {
run_compressed_transport([5 * MIB, MIB], etag_present, true, None).await;
run_compressed_transport([5 * MIB, MIB / 2], etag_present, false, None).await;
}
}
#[tokio::test]
async fn multipart_transport_checks_positive_compressed_part_boundaries() {
const MIB: i64 = 1024 * 1024;
for etag_present in [true, false] {
run_compressed_transport([5 * MIB + MIB / 2, MIB / 2], etag_present, false, None).await;
}
}
#[derive(Clone, Copy)]
enum SourceReadFault {
Short,
Extra,
}
#[tokio::test]
async fn multipart_transport_requires_complete_compressed_stream() {
const MIB: i64 = 1024 * 1024;
for fault in [SourceReadFault::Short, SourceReadFault::Extra] {
run_compressed_transport([5 * MIB, MIB], true, false, Some(fault)).await;
}
}
async fn run_compressed_transport(
actual_sizes: [i64; 2],
etag_present: bool,
valid_layout: bool,
fault: Option<SourceReadFault>,
) {
use crate::io_support::rio::TryGetIndex as _;
use rustfs_utils::CompressionAlgorithm;
use tokio::io::AsyncReadExt as _;
const MIB: usize = 1024 * 1024;
let body = Bytes::from([vec![0x11; 5 * MIB], vec![0x22; MIB / 2], vec![0x33; MIB / 2]].concat());
let mut stored = Vec::new();
let mut parts = Vec::new();
for (index, plaintext) in [body.slice(..5 * MIB), body.slice(5 * MIB..)].into_iter().enumerate() {
let mut compressor = crate::io_support::rio::compression_reader(
std::io::Cursor::new(plaintext),
CompressionAlgorithm::default(),
false,
);
let mut compressed = Vec::new();
compressor.read_to_end(&mut compressed).await.expect("compress source part");
parts.push(ObjectPartInfo {
number: index + 1,
size: compressed.len(),
actual_size: actual_sizes[index],
index: compressor
.try_get_index()
.map(crate::io_support::rio::compression_index_storage_bytes),
..Default::default()
});
stored.extend_from_slice(&compressed);
}
let mut metadata = HashMap::new();
rustfs_utils::http::insert_str(
&mut metadata,
rustfs_utils::http::SUFFIX_COMPRESSION,
crate::io_support::rio::compression_metadata_value(CompressionAlgorithm::default()),
);
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, body.len().to_string());
let source = Arc::new(Source { let source = Arc::new(Source {
info: ObjectInfo { info: ObjectInfo {
bucket: "source".to_string(), size: i64::try_from(body.len() + if unknown_part.is_some() { 16 } else { 0 }).expect("stored size"),
name: "object".to_string(), actual_size: i64::try_from(body.len()).expect("body size"),
size: i64::try_from(stored.len()).expect("stored size"), etag: Some(etag.clone()),
actual_size: i64::try_from(body.len()).expect("plaintext size"),
etag: etag_present
.then(|| faster_hex::hex_string(rustfs_utils::hash::HashAlgorithm::Md5.hash_encode(&body).as_ref())),
version_id: Some(Uuid::new_v4()), version_id: Some(Uuid::new_v4()),
user_defined: Arc::new(metadata), user_defined: Arc::new(if unknown_part.is_some() {
parts: Arc::new(parts), HashMap::from([("x-amz-server-side-encryption".to_string(), "AES256".to_string())])
} else {
HashMap::new()
}),
parts: Arc::new(vec![
ObjectPartInfo {
number: 1,
size: FIRST_SIZE + if unknown_part.is_some() { 8 } else { 0 },
actual_size: if let Some((0, size)) = unknown_part {
size
} else if unknown_part.is_some() || tail_size == 0 {
i64::try_from(FIRST_SIZE).expect("first part size")
} else {
0
},
..Default::default()
},
ObjectPartInfo {
number: 2,
size: tail_size + if unknown_part.is_some() { 8 } else { 0 },
actual_size: if let Some((1, size)) = unknown_part {
size
} else if unknown_part.is_some() {
i64::try_from(tail_size).expect("tail logical size")
} else {
0
},
..Default::default()
},
]),
..Default::default() ..Default::default()
}, },
body: body.clone(), body: body.clone(),
ranges: StdMutex::new(Vec::new()), ranges: StdMutex::new(Vec::new()),
full_reads: std::sync::atomic::AtomicUsize::new(0), full_reads: std::sync::atomic::AtomicUsize::new(0),
}); });
let opts = ObjectOptions {
version_id: source.info.version_id.map(|id| id.to_string()),
..Default::default()
};
let mut full = source
.get_object_reader("source", "object", None, HeaderMap::new(), &opts)
.await
.expect("open complete compressed source");
let decoded = full.read_all().await.expect("decode complete source");
assert!(
decoded.as_slice() == body.as_ref(),
"the independent full GET must match every source byte"
);
let (target, journal, server) = start_transport_target().await;
let (put_opts, is_multipart) = replication_put_object_options("STANDARD", &source.info).expect("replication options");
let mut reader = source
.get_object_reader("source", "object", None, HeaderMap::new(), &opts)
.await
.expect("open replication source");
match fault {
Some(SourceReadFault::Short) => {
reader.stream = Box::new(
reader
.stream
.take(u64::try_from(body.len() - 1).expect("short stream length")),
);
}
Some(SourceReadFault::Extra) => {
reader.stream = Box::new(reader.stream.chain(std::io::Cursor::new(vec![0x44])));
}
None => {}
}
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
replicate_all_payload_to_target(
ReplicateAllPayloadContext {
storage: &source,
tgt_client: &target,
bucket: "source",
object: "object",
object_info: &source.info,
obj_opts: &opts,
arn: &target.arn,
transfer_size: i64::try_from(body.len()).expect("plaintext size"),
is_multipart,
put_opts,
},
reader,
),
)
.await;
server.abort();
assert!(server.await.expect_err("fixture server is stopped").is_cancelled());
let result = result.expect("replication must finish");
let requests = journal.lock().expect("request journal lock");
let completes: Vec<_> = requests
.iter()
.filter(|request| request.method == http::Method::POST && request.query.contains_key("uploadId"))
.collect();
if result.is_err() {
assert!(!valid_layout, "valid compressed layout must replicate: {result:?}");
assert!(completes.is_empty(), "failed replication must not publish a partial object");
assert!(
requests
.iter()
.any(|request| request.method == http::Method::DELETE && request.query.contains_key("uploadId")),
"failed multipart upload must be aborted"
);
return;
}
assert!(fault.is_none(), "a short or oversized source must fail before Complete");
assert_eq!(requests.len(), 4, "initiate, two parts and complete without retries");
assert_eq!(completes.len(), 1, "publish the replica once");
let mut replica = Vec::new();
for number in [1, 2] {
let part = requests
.iter()
.find(|request| request.query.get("partNumber") == Some(&number.to_string()))
.expect("completed source part");
assert_eq!(part.method, http::Method::PUT);
replica.extend_from_slice(&part.body);
let complete_xml = std::str::from_utf8(&completes[0].body).expect("complete XML");
assert!(complete_xml.contains(&format!("<PartNumber>{number}</PartNumber>")));
}
assert!(
replica.as_slice() == body.as_ref(),
"replica differs from complete GET: sizes={actual_sizes:?}, source={}, replica={}",
body.len(),
replica.len()
);
if let Some(etag) = &source.info.etag {
assert_eq!(
rustfs_utils::http::get_header(&completes[0].headers, rustfs_utils::http::SUFFIX_SOURCE_ETAG).as_deref(),
Some(etag.as_str())
);
}
}
async fn start_transport_target() -> (Arc<TargetClient>, Arc<StdMutex<Vec<RequestRecord>>>, tokio::task::JoinHandle<()>) {
let journal = Arc::new(StdMutex::new(Vec::<RequestRecord>::new())); let journal = Arc::new(StdMutex::new(Vec::<RequestRecord>::new()));
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await .await
@@ -8537,13 +8183,7 @@ mod tests {
let query: HashMap<String, String> = url::form_urlencoded::parse( let query: HashMap<String, String> = url::form_urlencoded::parse(
request.uri.query().unwrap_or_default().as_bytes(), request.uri.query().unwrap_or_default().as_bytes(),
).into_owned().collect(); ).into_owned().collect();
let body = match body.collect().await { let body = body.collect().await.expect("read complete multipart request body").to_bytes();
Ok(body) => body.to_bytes(),
Err(_) => return Ok::<_, Infallible>(hyper::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.body(Full::new(Bytes::new()))
.expect("incomplete upload response")),
};
let response = if request.method == http::Method::POST && query.contains_key("uploads") { let response = if request.method == http::Method::POST && query.contains_key("uploads") {
"<InitiateMultipartUploadResult><Bucket>target-bucket</Bucket><Key>object</Key><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>" "<InitiateMultipartUploadResult><Bucket>target-bucket</Bucket><Key>object</Key><UploadId>upload-1</UploadId></InitiateMultipartUploadResult>"
} else if request.method == http::Method::PUT { } else if request.method == http::Method::PUT {
@@ -8585,85 +8225,9 @@ mod tests {
.force_path_style(true) .force_path_style(true)
.build(); .build();
Arc::get_mut(&mut target).expect("unshared test target").client = Arc::new(aws_sdk_s3::Client::from_conf(config)); Arc::get_mut(&mut target).expect("unshared test target").client = Arc::new(aws_sdk_s3::Client::from_conf(config));
(target, journal, server)
}
async fn run_transport(tail_size: usize, unknown_part: Option<(usize, i64)>, passthrough: bool) {
const FIRST_SIZE: usize = 5 * 1024 * 1024;
// The stored (compressed ciphertext) bytes of a passthrough part
// are shorter than the plaintext they represent.
const PASSTHROUGH_PLAINTEXT_FACTOR: usize = 4;
let body = Bytes::from([vec![0x35; FIRST_SIZE], vec![0xa7; tail_size]].concat());
let etag = faster_hex::hex_string(rustfs_utils::hash::HashAlgorithm::Md5.hash_encode(&body).as_ref());
let mut user_defined = if unknown_part.is_some() {
HashMap::from([("x-amz-server-side-encryption".to_string(), "AES256".to_string())])
} else {
HashMap::new()
};
if passthrough {
user_defined.insert(rustfs_utils::http::SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string());
rustfs_utils::http::insert_str(
&mut user_defined,
rustfs_utils::http::SUFFIX_COMPRESSION,
"klauspost/compress/s2".to_string(),
);
}
let plaintext_len = |stored: usize| {
i64::try_from(if passthrough {
stored * PASSTHROUGH_PLAINTEXT_FACTOR
} else {
stored
})
.expect("plaintext size")
};
let source = Arc::new(Source {
info: ObjectInfo {
size: i64::try_from(body.len() + if unknown_part.is_some() { 16 } else { 0 }).expect("stored size"),
actual_size: plaintext_len(body.len()),
etag: Some(etag.clone()),
version_id: Some(Uuid::new_v4()),
user_defined: Arc::new(user_defined),
parts: Arc::new(vec![
ObjectPartInfo {
number: 1,
size: FIRST_SIZE + if unknown_part.is_some() { 8 } else { 0 },
actual_size: if let Some((0, size)) = unknown_part {
size
} else if passthrough {
plaintext_len(FIRST_SIZE)
} else if unknown_part.is_some() || tail_size == 0 {
i64::try_from(FIRST_SIZE).expect("first part size")
} else {
0
},
..Default::default()
},
ObjectPartInfo {
number: 2,
size: tail_size + if unknown_part.is_some() { 8 } else { 0 },
actual_size: if let Some((1, size)) = unknown_part {
size
} else if passthrough {
plaintext_len(tail_size)
} else if unknown_part.is_some() {
i64::try_from(tail_size).expect("tail logical size")
} else {
0
},
..Default::default()
},
]),
..Default::default()
},
body: body.clone(),
ranges: StdMutex::new(Vec::new()),
full_reads: std::sync::atomic::AtomicUsize::new(0),
});
let (target, journal, server) = start_transport_target().await;
let (put_opts, is_multipart) = replication_put_object_options("STANDARD", &source.info).expect("replication options"); let (put_opts, is_multipart) = replication_put_object_options("STANDARD", &source.info).expect("replication options");
let opts = ObjectOptions { let opts = ObjectOptions {
version_id: source.info.version_id.map(|id| id.to_string()), version_id: source.info.version_id.map(|id| id.to_string()),
raw_data_movement_read: passthrough,
..Default::default() ..Default::default()
}; };
let reader = source let reader = source
@@ -8746,36 +8310,6 @@ mod tests {
requests[index].headers.get("content-length").expect("part content length"), requests[index].headers.get("content-length").expect("part content length"),
expected.len().to_string().as_str() expected.len().to_string().as_str()
); );
let declared = rustfs_utils::http::get_header(
&requests[index].headers,
rustfs_utils::http::SUFFIX_REPLICATION_PART_ACTUAL_SIZE,
);
if passthrough {
assert_eq!(
declared.as_deref(),
Some(plaintext_len(expected.len()).to_string().as_str()),
"passthrough parts declare their plaintext length"
);
} else {
assert!(declared.is_none(), "decrypted transport carries no passthrough part length");
}
}
if passthrough {
let create = &requests[0];
assert_eq!(
rustfs_utils::http::get_header(&create.headers, rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION)
.as_deref(),
Some("klauspost/compress/s2"),
"the session carries the source's compression scheme"
);
assert_eq!(
rustfs_utils::http::get_header(
&create.headers,
rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE
)
.as_deref(),
Some(plaintext_len(body.len()).to_string().as_str())
);
} }
let complete = &requests[3]; let complete = &requests[3];
assert_eq!(complete.method, http::Method::POST); assert_eq!(complete.method, http::Method::POST);
@@ -247,23 +247,6 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
meta.insert(key.to_string(), value.to_string()); meta.insert(key.to_string(), value.to_string());
} }
// A compressed SSE-C object passes through as its stored bytes. The target
// cannot infer the compression layout from ciphertext, so the scheme and
// the plaintext size travel as transport headers; each UploadPart carries
// its own plaintext length (backlog#2363).
if is_ssec && let Some(scheme) = get_str(&object_info.user_defined, rustfs_utils::http::SUFFIX_COMPRESSION) {
insert_header_map(&mut meta, rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION, scheme);
if let Ok(actual_size) = object_info.get_actual_size()
&& actual_size >= 0
{
insert_header_map(
&mut meta,
rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE,
actual_size.to_string(),
);
}
}
// Managed SSE replicates as plaintext (the replication reader decrypts via // Managed SSE replicates as plaintext (the replication reader decrypts via
// the object-encryption resolver) and re-encrypts on the target with the // the object-encryption resolver) and re-encrypts on the target with the
// target's own KMS. Send only the encryption intent — never the source // target's own KMS. Send only the encryption intent — never the source
@@ -643,59 +626,6 @@ mod tests {
} }
} }
#[test]
fn compressed_ssec_objects_declare_their_compression_layout_on_the_wire() {
use rustfs_utils::http::{
SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_REPLICATION_COMPRESSION, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE,
insert_str,
};
let mut ssec_compressed = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
insert_str(&mut ssec_compressed, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
insert_str(&mut ssec_compressed, SUFFIX_ACTUAL_SIZE, "6295552".to_string());
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef-2".to_string()),
size: 4321,
actual_size: 6295552,
user_defined: Arc::new(ssec_compressed),
..Default::default()
};
// SSE-C passthrough sends stored bytes: the scheme and the plaintext
// size travel as transport headers, never as the internal key
// (backlog#2363).
let (options, _) = replication_put_object_options("STANDARD", &object_info).expect("ssec put options");
assert_eq!(
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION).as_deref(),
Some("klauspost/compress/s2")
);
assert_eq!(
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE).as_deref(),
Some("6295552")
);
assert!(
!options
.user_metadata
.keys()
.any(|key| rustfs_utils::http::is_internal_key(key)),
"internal metadata never leaves the source as plain metadata: {:?}",
options.user_metadata
);
// A compressed object that is not SSE-C is decompressed by the
// replication reader and travels as plaintext: no layout headers.
let mut plain_compressed = HashMap::new();
insert_str(&mut plain_compressed, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
insert_str(&mut plain_compressed, SUFFIX_ACTUAL_SIZE, "6295552".to_string());
let plain = ObjectInfo {
user_defined: Arc::new(plain_compressed),
..object_info
};
let (options, _) = replication_put_object_options("STANDARD", &plain).expect("plain put options");
assert!(get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION).is_none());
assert!(get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE).is_none());
}
#[test] #[test]
fn legacy_transformed_single_put_parts_keep_the_previous_replication_route() { fn legacy_transformed_single_put_parts_keep_the_previous_replication_route() {
let [_, (_, compressed), (_, encrypted), (_, ssec)] = replication_route_metadata(); let [_, (_, compressed), (_, encrypted), (_, ssec)] = replication_route_metadata();
-38
View File
@@ -285,20 +285,6 @@ fn peer_replay_state(audience: &str) -> PeerReplayState {
.unwrap_or_default() .unwrap_or_default()
} }
pub(crate) fn clear_peer_replay_state_for_addr(addr: &str) -> std::io::Result<()> {
let uri = addr
.parse::<Uri>()
.map_err(|_| std::io::Error::other("Invalid gRPC peer URI"))?;
let audience = uri
.authority()
.map(|authority| normalize_tonic_rpc_audience(authority.as_str()))
.ok_or_else(|| std::io::Error::other("Missing gRPC peer authority"))??;
if let Ok(mut states) = PEER_REPLAY_STATES.lock() {
states.remove(&audience);
}
Ok(())
}
fn apply_peer_replay_response( fn apply_peer_replay_response(
audience: String, audience: String,
sent_state: PeerReplayState, sent_state: PeerReplayState,
@@ -633,13 +619,6 @@ mod tests {
.remove(audience); .remove(audience);
} }
fn set_peer_capability(audience: &str, state: PeerReplayState) {
PEER_REPLAY_STATES
.lock()
.expect("peer capability cache lock must not be poisoned")
.insert(audience.to_string(), state);
}
fn rolling_mutation_request(method: &'static str) -> tonic::Request<()> { fn rolling_mutation_request(method: &'static str) -> tonic::Request<()> {
let mut request = tonic::Request::new(rustfs_protos::proto_gen::node_service::GenerallyLockRequest { let mut request = tonic::Request::new(rustfs_protos::proto_gen::node_service::GenerallyLockRequest {
args: "canonical mutation request".to_string(), args: "canonical mutation request".to_string(),
@@ -1111,23 +1090,6 @@ mod tests {
clear_peer_capability(audience); clear_peer_capability(audience);
} }
#[test]
fn clear_peer_replay_state_for_addr_removes_normalized_audience() {
let audience = "clear-peer-replay-state-test:9000";
let boot_epoch = Uuid::new_v4();
set_peer_capability(
audience,
PeerReplayState {
boot_epoch: Some(boot_epoch),
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch }),
},
);
clear_peer_replay_state_for_addr("http://clear-peer-replay-state-test:9000").expect("peer URI should clear replay state");
assert_eq!(peer_replay_state(audience), PeerReplayState::default());
}
#[test] #[test]
fn interceptor_snapshot_prevents_delayed_legacy_response_from_revoking_capability() { fn interceptor_snapshot_prevents_delayed_legacy_response_from_revoking_capability() {
ensure_test_rpc_secret(); ensure_test_rpc_secret();
@@ -13,9 +13,8 @@
// limitations under the License. // limitations under the License.
use crate::cluster::rpc::client::{ use crate::cluster::rpc::client::{
AuthenticatedChannel, TonicInterceptor, clear_peer_replay_state_for_addr, embedded_tonic_status, AuthenticatedChannel, TonicInterceptor, embedded_tonic_status, gen_tonic_signature_interceptor, heal_control_time_out_client,
gen_tonic_signature_interceptor, heal_control_time_out_client, is_network_like_status, message_has_network_needle, is_network_like_status, message_has_network_needle, node_service_time_out_client, tier_mutation_control_time_out_client,
node_service_time_out_client, tier_mutation_control_time_out_client,
}; };
use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof}; use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -545,16 +544,6 @@ fn validate_heal_control_response_proof(canonical_response: &[u8], proof: &[u8])
.map_err(|_| Error::other("peer returned an invalid heal control response proof")) .map_err(|_| Error::other("peer returned an invalid heal control response proof"))
} }
fn heal_control_auth_may_need_replay_scope_refresh(err: &Error) -> bool {
matches!(
err,
Error::Io(io_err)
if embedded_tonic_status(io_err).is_some_and(|status| {
status.code() == tonic::Code::Unauthenticated && status.message() == "No valid auth token"
})
)
}
fn decode_remote_version_state_capability(expected_member: &str, result: &[u8]) -> Result<Uuid> { fn decode_remote_version_state_capability(expected_member: &str, result: &[u8]) -> Result<Uuid> {
let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(result).map_err(Error::other)?; let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(result).map_err(Error::other)?;
if topology_member != expected_member { if topology_member != expected_member {
@@ -1731,72 +1720,45 @@ impl PeerRestClient {
return Err(Error::other("heal control command exceeds size limit")); return Err(Error::other("heal control command exceeds size limit"));
} }
let capability_probe = rustfs_protos::is_heal_control_capability_probe(&command); let capability_probe = rustfs_protos::is_heal_control_capability_probe(&command);
let result = self self.finalize_result(
.heal_control_once(version, &topology_fingerprint, &command, capability_probe) async {
.await; let mut client = self
if result .get_heal_control_client()
.as_ref() .await?
.err() .max_encoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE)
.is_some_and(heal_control_auth_may_need_replay_scope_refresh) .max_decoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE);
{ let canonical_body = rustfs_protos::canonical_heal_control_request_body(version, &topology_fingerprint, &command)
self.prepare_heal_control_auth_retry().await; .map_err(|_| Error::other("heal control request length cannot be represented"))?;
return self let mut request = Request::new(HealControlRequest {
.finalize_result( version,
self.heal_control_once(version, &topology_fingerprint, &command, capability_probe) topology_fingerprint: topology_fingerprint.clone(),
.await, command: command.clone().into(),
) });
.await; request.set_timeout(rustfs_protos::heal_control_execution_timeout());
} set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
self.finalize_result(result).await let response = client.heal_control(request).await?.into_inner();
} if !response.success {
return Err(Error::other(
async fn prepare_heal_control_auth_retry(&self) { response
if let Err(err) = clear_peer_replay_state_for_addr(&self.grid_host) { .error_info
debug!( .unwrap_or_else(|| "peer heal control failed without an error".to_string()),
peer = %self.grid_host, ));
error = %err, }
"could not clear heal control replay state before retry" if !capability_probe {
); let canonical_response = rustfs_protos::canonical_heal_control_response_body(
} version,
self.evict_connection().await; &topology_fingerprint,
} &command,
&response.result,
async fn heal_control_once( )
&self,
version: u32,
topology_fingerprint: &str,
command: &[u8],
capability_probe: bool,
) -> Result<Vec<u8>> {
let mut client = self
.get_heal_control_client()
.await?
.max_encoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE)
.max_decoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE);
let canonical_body = rustfs_protos::canonical_heal_control_request_body(version, topology_fingerprint, command)
.map_err(|_| Error::other("heal control request length cannot be represented"))?;
let mut request = Request::new(HealControlRequest {
version,
topology_fingerprint: topology_fingerprint.to_string(),
command: command.to_vec().into(),
});
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
let response = client.heal_control(request).await?.into_inner();
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer heal control failed without an error".to_string()),
));
}
if !capability_probe {
let canonical_response =
rustfs_protos::canonical_heal_control_response_body(version, topology_fingerprint, command, &response.result)
.map_err(|_| Error::other("heal control response length cannot be represented"))?; .map_err(|_| Error::other("heal control response length cannot be represented"))?;
validate_heal_control_response_proof(&canonical_response, &response.response_proof)?; validate_heal_control_response_proof(&canonical_response, &response.response_proof)?;
} }
Ok(response.result.to_vec()) Ok(response.result.to_vec())
}
.await,
)
.await
} }
/// Confirms that a peer supports the current heal-control coordination /// Confirms that a peer supports the current heal-control coordination
@@ -3766,22 +3728,6 @@ mod tests {
} }
} }
#[test]
fn heal_control_auth_retry_is_limited_to_transport_auth_rejection() {
assert!(heal_control_auth_may_need_replay_scope_refresh(&Error::from(
tonic::Status::unauthenticated("No valid auth token")
)));
assert!(!heal_control_auth_may_need_replay_scope_refresh(&Error::from(
tonic::Status::permission_denied("bad signature")
)));
assert!(!heal_control_auth_may_need_replay_scope_refresh(&Error::from(
tonic::Status::unauthenticated("application rejected heal control")
)));
assert!(!heal_control_auth_may_need_replay_scope_refresh(&Error::other(
"Io error: code: 'Unauthenticated', message: \"No valid auth token\""
)));
}
#[test] #[test]
fn peer_rest_client_network_classifier_keeps_slow_peers_online() { fn peer_rest_client_network_classifier_keeps_slow_peers_online() {
// The per-RPC channel deadline (RUSTFS_INTERNODE_RPC_TIMEOUT, 30s) // The per-RPC channel deadline (RUSTFS_INTERNODE_RPC_TIMEOUT, 30s)
+53 -685
View File
@@ -22,152 +22,21 @@ use rustfs_lock::{
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result, LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
types::{LockId, LockMetadata, LockPriority}, types::{LockId, LockMetadata, LockPriority},
}; };
use rustfs_protos::proto_gen::node_service::{ use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, GenerallyLockRequest, PingRequest};
BatchGenerallyLockRequest, BatchGenerallyLockResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult,
PingRequest,
};
use rustfs_protos::{ use rustfs_protos::{
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder, ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
proto_gen::node_service::node_service_client::NodeServiceClient, proto_gen::node_service::node_service_client::NodeServiceClient,
}; };
use std::collections::HashMap; use std::{sync::OnceLock, time::Duration};
use std::future::Future; use tokio::time::timeout;
use std::pin::Pin; use tonic::Request;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::{Instant, timeout};
use tonic::service::interceptor::InterceptedService; use tonic::service::interceptor::InterceptedService;
use tonic::{Request, Response};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> { fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
set_tonic_rolling_mutation_body_digest(request) set_tonic_rolling_mutation_body_digest(request)
} }
/// Work to run if an RPC that already timed out for its caller completes later.
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
/// The liveness window is this many RPC deadlines: a peer that completed a
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
/// Recent history of the shared lock channel to one peer (issue #7363).
///
/// A single request deadline says nothing about the HTTP/2 connection it ran
/// on: a peer whose lock service is merely slow keeps answering other streams.
/// Evicting the cached channel on every timeout turned that slowness into a
/// `RST_STREAM`/`GOAWAY too_many_resets`/re-dial loop across the cluster, so
/// eviction now requires the peer to have gone quiet and is rate limited.
#[derive(Debug, Clone, Copy, Default)]
struct LockPeerChannelHealth {
last_success: Option<Instant>,
last_eviction: Option<Instant>,
consecutive_timeouts: u32,
/// Timed-out RPCs still running in the background for this peer.
detached_rpcs: usize,
}
fn lock_peer_channel_health() -> &'static Mutex<HashMap<String, LockPeerChannelHealth>> {
static HEALTH: OnceLock<Mutex<HashMap<String, LockPeerChannelHealth>>> = OnceLock::new();
HEALTH.get_or_init(Mutex::default)
}
fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannelHealth) -> R) -> R {
let mut peers = lock_peer_channel_health()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
update(peers.entry(addr.to_string()).or_default())
}
#[cfg(test)]
fn lock_peer_health_for_test(addr: &str) -> LockPeerChannelHealth {
lock_peer_channel_health()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(addr)
.copied()
.unwrap_or_default()
}
#[cfg(test)]
fn reset_lock_peer_health_for_test(addr: &str) {
lock_peer_channel_health()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(addr);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvictionTrigger {
/// The caller's deadline expired while the stream was still open.
Timeout,
/// The transport itself reported the failure (refused, reset, GOAWAY, ...).
Transport,
}
impl EvictionTrigger {
fn as_str(self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Transport => "transport",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvictionVerdict {
Evict,
/// The peer completed a lock RPC within the liveness window: slow, not gone.
PeerRecentlyServed,
/// The channel was re-dialed within the cooldown; let it prove itself first.
CoolingDown,
}
impl EvictionVerdict {
fn as_str(self) -> &'static str {
match self {
Self::Evict => "evict",
Self::PeerRecentlyServed => "peer_recently_served",
Self::CoolingDown => "cooling_down",
}
}
}
/// Decide whether a failed lock RPC may evict the shared channel to its peer.
fn eviction_verdict(
health: &LockPeerChannelHealth,
now: Instant,
trigger: EvictionTrigger,
liveness_window: Duration,
cooldown: Duration,
) -> EvictionVerdict {
if trigger == EvictionTrigger::Timeout
&& health
.last_success
.is_some_and(|at| now.saturating_duration_since(at) < liveness_window)
{
return EvictionVerdict::PeerRecentlyServed;
}
if health
.last_eviction
.is_some_and(|at| now.saturating_duration_since(at) < cooldown)
{
return EvictionVerdict::CoolingDown;
}
EvictionVerdict::Evict
}
/// Lock ids whose batch entry the server reports as granted.
fn acquired_lock_ids(lock_ids: &[LockId], results: &[GenerallyLockResult]) -> Vec<LockId> {
results
.iter()
.zip(lock_ids)
.filter(|(result, _)| result.success)
.map(|(_, lock_id)| lock_id.clone())
.collect()
}
/// Remote lock client implementation /// Remote lock client implementation
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RemoteClient { pub struct RemoteClient {
@@ -329,202 +198,14 @@ impl RemoteClient {
) )
} }
fn eviction_cooldown() -> Duration { async fn execute_rpc<T, F>(&self, op: &'static str, resource_summary: &str, future: F) -> std::result::Result<T, LockError>
Duration::from_millis(rustfs_utils::get_env_u64(
rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
))
}
fn detached_rpc_limit() -> usize {
rustfs_utils::get_env_usize(
rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT,
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT,
)
}
fn liveness_window(deadline: Duration) -> Duration {
deadline.saturating_mul(LOCK_RPC_LIVENESS_WINDOW_DEADLINES)
}
fn record_rpc_success(&self) {
with_lock_peer_health(&self.addr, |health| {
health.last_success = Some(Instant::now());
health.consecutive_timeouts = 0;
});
}
/// Apply the per-peer eviction policy after a failed RPC.
async fn maybe_evict_connection(
&self,
op: &'static str,
reason: &str,
resource_summary: &str,
trigger: EvictionTrigger,
deadline: Duration,
) {
let now = Instant::now();
let cooldown = Self::eviction_cooldown();
let liveness_window = Self::liveness_window(deadline);
let (verdict, consecutive_timeouts) = with_lock_peer_health(&self.addr, |health| {
if trigger == EvictionTrigger::Timeout {
health.consecutive_timeouts = health.consecutive_timeouts.saturating_add(1);
}
let verdict = eviction_verdict(health, now, trigger, liveness_window, cooldown);
if verdict == EvictionVerdict::Evict {
health.last_eviction = Some(now);
}
(verdict, health.consecutive_timeouts)
});
if verdict == EvictionVerdict::Evict {
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction(&self.addr, trigger.as_str());
self.evict_connection(op, reason, resource_summary).await;
return;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction_suppressed(&self.addr, verdict.as_str());
debug!(
addr = %self.addr,
op,
resource_summary,
trigger = trigger.as_str(),
verdict = verdict.as_str(),
consecutive_timeouts,
"Keeping cached remote lock connection after RPC failure"
);
}
/// Keep a timed-out RPC running instead of cancelling its stream.
///
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
/// in the server's pending-accept queue until it answers `GOAWAY
/// too_many_resets` and kills every stream on the connection. A detached
/// stream ends on its own within the internode RPC timeout, the number per
/// peer is bounded, and a lock granted after its caller gave up is released.
fn detach_timed_out_rpc<T: Send + 'static>(
&self,
op: &'static str,
resource_summary: &str,
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
late: LateCompletion<T>,
) {
let limit = Self::detached_rpc_limit();
let admitted = with_lock_peer_health(&self.addr, |health| {
if health.detached_rpcs >= limit {
false
} else {
health.detached_rpcs += 1;
true
}
});
if !admitted {
handle.abort();
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
debug!(
addr = %self.addr,
op,
resource_summary,
limit,
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
);
return;
}
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
let addr = self.addr.clone();
tokio::spawn(async move {
let outcome = handle.await;
with_lock_peer_health(&addr, |health| health.detached_rpcs = health.detached_rpcs.saturating_sub(1));
match outcome {
Ok(Ok(response)) => {
with_lock_peer_health(&addr, |health| {
health.last_success = Some(Instant::now());
health.consecutive_timeouts = 0;
});
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
if let Some(late) = late {
late(response).await;
}
}
Ok(Err(status)) => {
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
debug!(
addr = %addr,
op,
tonic_code = ?status.code(),
tonic_message = status.message(),
"Detached remote lock RPC failed after its caller timed out"
);
}
Err(join_error) => {
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
}
}
});
}
fn late_release_hook(&self, lock_id: LockId) -> LateCompletion<Response<GenerallyLockResponse>> {
let client = self.clone();
Some(Box::new(move |response: Response<GenerallyLockResponse>| {
Box::pin(async move {
if response.get_ref().success {
client.release_late_acquisitions(vec![lock_id]).await;
}
})
}))
}
fn late_release_batch_hook(&self, lock_ids: Vec<LockId>) -> LateCompletion<Response<BatchGenerallyLockResponse>> {
let client = self.clone();
Some(Box::new(move |response: Response<BatchGenerallyLockResponse>| {
Box::pin(async move {
let acquired = acquired_lock_ids(&lock_ids, &response.get_ref().results);
if !acquired.is_empty() {
client.release_late_acquisitions(acquired).await;
}
})
}))
}
/// A lock granted after its caller stopped waiting is an orphan until its
/// lease expires; hand it back right away, best effort.
async fn release_late_acquisitions(&self, lock_ids: Vec<LockId>) {
let outcome = match self.release_locks_batch(&lock_ids).await {
Ok(released) if released.iter().all(|released| *released) => "released",
Ok(_) => "partial",
Err(_) => "failed",
};
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
if outcome == "released" {
debug!(addr = %self.addr, count = lock_ids.len(), "Released remote locks granted after their caller timed out");
} else {
warn!(
addr = %self.addr,
count = lock_ids.len(),
outcome,
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
);
}
}
async fn execute_rpc<T, Fut>(
&self,
op: &'static str,
resource_summary: &str,
deadline: Duration,
future: Fut,
late: LateCompletion<T>,
) -> std::result::Result<T, LockError>
where where
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static, F: std::future::Future<Output = std::result::Result<T, tonic::Status>>,
T: Send + 'static,
{ {
let mut handle = tokio::spawn(future); let lock_timeout = Self::rpc_timeout();
match timeout(deadline, &mut handle).await { match timeout(lock_timeout, future).await {
Ok(Ok(Ok(response))) => { Ok(Ok(response)) => Ok(response),
self.record_rpc_success(); Ok(Err(err)) => {
Ok(response)
}
Ok(Ok(Err(err))) => {
let reason = err.to_string(); let reason = err.to_string();
// Only evict (and re-dial) the cached channel when the failure is a genuine // Only evict (and re-dial) the cached channel when the failure is a genuine
// transport problem. A server-produced application status (auth denied, peer // transport problem. A server-produced application status (auth denied, peer
@@ -536,7 +217,7 @@ impl RemoteClient {
debug!( debug!(
addr = %self.addr, addr = %self.addr,
op, op,
timeout_ms = deadline.as_millis(), timeout_ms = lock_timeout.as_millis(),
resource_summary, resource_summary,
tonic_code = ?err.code(), tonic_code = ?err.code(),
tonic_message = err.message(), tonic_message = err.message(),
@@ -547,7 +228,7 @@ impl RemoteClient {
warn!( warn!(
addr = %self.addr, addr = %self.addr,
op, op,
timeout_ms = deadline.as_millis(), timeout_ms = lock_timeout.as_millis(),
resource_summary, resource_summary,
tonic_code = ?err.code(), tonic_code = ?err.code(),
tonic_message = err.message(), tonic_message = err.message(),
@@ -556,29 +237,17 @@ impl RemoteClient {
); );
} }
if transport_failure { if transport_failure {
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline) self.evict_connection(op, &reason, resource_summary).await;
.await;
} }
Err(LockError::internal(format!("{op} RPC failed: {reason}"))) Err(LockError::internal(format!("{op} RPC failed: {reason}")))
} }
Ok(Err(join_error)) => {
warn!(
addr = %self.addr,
op,
resource_summary,
error = %join_error,
"Remote lock RPC task ended abnormally"
);
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
}
Err(_) => { Err(_) => {
let reason = format!("RPC timed out after {deadline:?}"); let reason = format!("RPC timed out after {:?}", lock_timeout);
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
if Self::is_scanner_leader_lock(resource_summary) { if Self::is_scanner_leader_lock(resource_summary) {
debug!( debug!(
addr = %self.addr, addr = %self.addr,
op, op,
timeout_ms = deadline.as_millis(), timeout_ms = lock_timeout.as_millis(),
resource_summary, resource_summary,
"Remote lock RPC timed out for scanner leader lock" "Remote lock RPC timed out for scanner leader lock"
); );
@@ -586,15 +255,13 @@ impl RemoteClient {
warn!( warn!(
addr = %self.addr, addr = %self.addr,
op, op,
timeout_ms = deadline.as_millis(), timeout_ms = lock_timeout.as_millis(),
resource_summary, resource_summary,
"Remote lock RPC timed out" "Remote lock RPC timed out"
); );
} }
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline) self.evict_connection(op, &reason, resource_summary).await;
.await; Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), lock_timeout))
self.detach_timed_out_rpc(op, resource_summary, handle, late);
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
} }
} }
} }
@@ -687,18 +354,8 @@ impl LockClient for RemoteClient {
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?, .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
}); });
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
let late = self.late_release_hook(request.lock_id.clone());
let resp = match self let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
.execute_rpc(
"lock",
&resource_summary,
Self::rpc_timeout(),
async move { client.lock(req).await },
late,
)
.await
{
Ok(resp) => resp.into_inner(), Ok(resp) => resp.into_inner(),
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)), Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
Err(err) => return Ok(Self::rpc_failure_response(request, &err)), Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
@@ -736,16 +393,9 @@ impl LockClient for RemoteClient {
.collect::<Result<Vec<_>>>()?, .collect::<Result<Vec<_>>>()?,
}); });
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
let resp = match self let resp = match self
.execute_rpc( .execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
"lock_batch",
&resource_summary,
Self::rpc_timeout(),
async move { client.lock_batch(req).await },
late,
)
.await .await
{ {
Ok(resp) => resp.into_inner(), Ok(resp) => resp.into_inner(),
@@ -786,13 +436,7 @@ impl LockClient for RemoteClient {
let mut req = Request::new(GenerallyLockRequest { args: request_string }); let mut req = Request::new(GenerallyLockRequest { args: request_string });
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc( .execute_rpc("release", &resource_summary, client.un_lock(req))
"release",
&resource_summary,
Self::rpc_timeout(),
async move { client.un_lock(req).await },
None,
)
.await? .await?
.into_inner(); .into_inner();
if let Some(error_info) = resp.error_info { if let Some(error_info) = resp.error_info {
@@ -820,13 +464,7 @@ impl LockClient for RemoteClient {
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc( .execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
"release_batch",
&resource_summary,
Self::rpc_timeout(),
async move { client.un_lock_batch(req).await },
None,
)
.await? .await?
.into_inner(); .into_inner();
@@ -848,13 +486,7 @@ impl LockClient for RemoteClient {
}); });
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc( .execute_rpc("refresh", &resource_summary, client.refresh(req))
"refresh",
&resource_summary,
Self::rpc_timeout(),
async move { client.refresh(req).await },
None,
)
.await? .await?
.into_inner(); .into_inner();
if let Some(error_info) = resp.error_info { if let Some(error_info) = resp.error_info {
@@ -874,13 +506,7 @@ impl LockClient for RemoteClient {
}); });
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc( .execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
"force_release",
&resource_summary,
Self::rpc_timeout(),
async move { client.force_un_lock(req).await },
None,
)
.await? .await?
.into_inner(); .into_inner();
if let Some(error_info) = resp.error_info { if let Some(error_info) = resp.error_info {
@@ -897,26 +523,16 @@ impl LockClient for RemoteClient {
let status_request = Self::create_unlock_request(lock_id); let status_request = Self::create_unlock_request(lock_id);
let resource_summary = status_request.resource.to_string(); let resource_summary = status_request.resource.to_string();
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let args = serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
// Try to acquire a very short-lived lock to test availability // Try to acquire a very short-lived lock to test availability
let mut req = Request::new(GenerallyLockRequest { args: args.clone() }); let mut req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
attach_lock_mutation_body_digest(&mut req)?; attach_lock_mutation_body_digest(&mut req)?;
// A probe lock granted after the deadline must not linger on the peer.
let late = self.late_release_hook(lock_id.clone());
// Try exclusive lock first with very short timeout // Try exclusive lock first with very short timeout
let resp = match self let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
.execute_rpc(
"check_status",
&resource_summary,
Self::rpc_timeout(),
async move { client.lock(req).await },
late,
)
.await
{
Ok(response) => response.into_inner(), Ok(response) => response.into_inner(),
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))), Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
}; };
@@ -924,19 +540,14 @@ impl LockClient for RemoteClient {
if resp.success { if resp.success {
// If we successfully acquired the lock, the resource was free. // If we successfully acquired the lock, the resource was free.
// Immediately release it on a best-effort basis. // Immediately release it on a best-effort basis.
let mut release_req = Request::new(GenerallyLockRequest { args }); let mut release_req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
});
attach_lock_mutation_body_digest(&mut release_req)?; attach_lock_mutation_body_digest(&mut release_req)?;
if let Ok(mut client) = self.get_client().await { let _ = self
let _ = self .execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
.execute_rpc( .await;
"check_status_release",
&resource_summary,
Self::rpc_timeout(),
async move { client.un_lock(release_req).await },
None,
)
.await;
}
Ok(None) Ok(None)
} else { } else {
@@ -971,8 +582,19 @@ impl LockClient for RemoteClient {
async fn is_online(&self) -> bool { async fn is_online(&self) -> bool {
let online_timeout = Self::online_check_timeout(); let online_timeout = Self::online_check_timeout();
let mut client = match timeout(online_timeout, self.get_client()).await { match timeout(online_timeout, async {
Ok(Ok(client)) => client, let mut client = self.get_client().await?;
let ping_req = Request::new(Self::build_ping_request());
self.execute_rpc("ping", Self::ONLINE_CHECK_RESOURCE, client.ping(ping_req))
.await?;
Ok::<(), LockError>(())
})
.await
{
Ok(Ok(())) => {
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
true
}
Ok(Err(err)) => { Ok(Err(err)) => {
debug!( debug!(
addr = %self.addr, addr = %self.addr,
@@ -980,39 +602,16 @@ impl LockClient for RemoteClient {
error = %err, error = %err,
"remote lock client online check failed" "remote lock client online check failed"
); );
return false; false
} }
Err(_) => { Err(_) => {
let reason = format!("online check timed out after {:?}", online_timeout);
warn!( warn!(
addr = %self.addr, addr = %self.addr,
timeout_ms = online_timeout.as_millis(), timeout_ms = online_timeout.as_millis(),
"remote lock client online check timed out while dialing" "remote lock client online check timed out"
);
return false;
}
};
let ping_req = Request::new(Self::build_ping_request());
match self
.execute_rpc(
"ping",
Self::ONLINE_CHECK_RESOURCE,
online_timeout,
async move { client.ping(ping_req).await },
None,
)
.await
{
Ok(_) => {
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
true
}
Err(err) => {
debug!(
addr = %self.addr,
timeout_ms = online_timeout.as_millis(),
error = %err,
"remote lock client online check failed"
); );
self.evict_connection("ping", &reason, Self::ONLINE_CHECK_RESOURCE).await;
false false
} }
} }
@@ -1074,232 +673,6 @@ mod tests {
.with_priority(LockPriority::Normal) .with_priority(LockPriority::Normal)
} }
#[test]
fn eviction_verdict_distinguishes_slow_peers_from_dead_channels() {
let now = Instant::now() + Duration::from_secs(3600);
let window = Duration::from_secs(6);
let cooldown = Duration::from_secs(5);
let idle = LockPeerChannelHealth::default();
assert_eq!(
eviction_verdict(&idle, now, EvictionTrigger::Timeout, window, cooldown),
EvictionVerdict::Evict
);
let serving = LockPeerChannelHealth {
last_success: Some(now - Duration::from_secs(1)),
..Default::default()
};
assert_eq!(
eviction_verdict(&serving, now, EvictionTrigger::Timeout, window, cooldown),
EvictionVerdict::PeerRecentlyServed,
"a timeout on a peer that just answered is load, not a dead channel"
);
assert_eq!(
eviction_verdict(&serving, now, EvictionTrigger::Transport, window, cooldown),
EvictionVerdict::Evict,
"a transport failure is reported by the channel itself and still evicts"
);
let quiet = LockPeerChannelHealth {
last_success: Some(now - Duration::from_secs(30)),
..Default::default()
};
assert_eq!(
eviction_verdict(&quiet, now, EvictionTrigger::Timeout, window, cooldown),
EvictionVerdict::Evict
);
let just_evicted = LockPeerChannelHealth {
last_eviction: Some(now - Duration::from_secs(1)),
..Default::default()
};
assert_eq!(
eviction_verdict(&just_evicted, now, EvictionTrigger::Timeout, window, cooldown),
EvictionVerdict::CoolingDown
);
assert_eq!(
eviction_verdict(&just_evicted, now, EvictionTrigger::Transport, window, cooldown),
EvictionVerdict::CoolingDown
);
let cooled = LockPeerChannelHealth {
last_eviction: Some(now - Duration::from_secs(10)),
..Default::default()
};
assert_eq!(
eviction_verdict(&cooled, now, EvictionTrigger::Timeout, window, cooldown),
EvictionVerdict::Evict
);
}
#[test]
fn acquired_lock_ids_picks_only_granted_batch_entries() {
let lock_ids = vec![
LockId::new_unique(&ObjectKey::new("bucket", "a")),
LockId::new_unique(&ObjectKey::new("bucket", "b")),
LockId::new_unique(&ObjectKey::new("bucket", "c")),
];
let results = vec![
GenerallyLockResult {
success: true,
..Default::default()
},
GenerallyLockResult {
success: false,
..Default::default()
},
];
let acquired = acquired_lock_ids(&lock_ids, &results);
assert_eq!(
acquired,
vec![lock_ids[0].clone()],
"only granted entries with a matching id are released"
);
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
ensure_test_rpc_secret();
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return;
};
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await;
with_lock_peer_health(&addr, |health| health.last_success = Some(Instant::now()));
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
let client = RemoteClient::new(addr.clone());
let response = client
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
.await
.unwrap();
assert!(!response.success, "timed out lock acquisition should fail");
assert!(
runtime_sources::test_node_channel_is_cached(&addr).await,
"a peer that served a lock RPC within the liveness window is slow, not gone"
);
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 1);
})
.await;
accept_task.abort();
reset_lock_peer_health_for_test(&addr);
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_repeated_timeouts_evict_at_most_once_per_cooldown() {
ensure_test_rpc_secret();
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return;
};
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await;
temp_env::async_with_vars(
[
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
(rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS, Some("60000")),
],
async {
let client = RemoteClient::new(addr.clone());
let request = test_lock_request(Duration::from_millis(5));
let _ = client.acquire_lock(&request).await.unwrap();
assert!(
!runtime_sources::test_node_channel_is_cached(&addr).await,
"the first timeout on a quiet peer evicts the cached channel"
);
cache_lazy_channel(&addr).await;
let _ = client.acquire_lock(&request).await.unwrap();
assert!(
runtime_sources::test_node_channel_is_cached(&addr).await,
"a second timeout inside the cooldown must not tear the fresh channel down again"
);
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 2);
},
)
.await;
accept_task.abort();
reset_lock_peer_health_for_test(&addr);
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_detaches_timed_out_rpc_and_reclaims_its_slot() {
ensure_test_rpc_secret();
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return;
};
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await;
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
let client = RemoteClient::new(addr.clone());
let _ = client
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
.await
.unwrap();
assert_eq!(
lock_peer_health_for_test(&addr).detached_rpcs,
1,
"the timed-out stream keeps running instead of being reset"
);
// The hanging listener drops its socket after two seconds; the detached
// task then observes the transport failure and frees its slot.
let deadline = Instant::now() + Duration::from_secs(10);
while lock_peer_health_for_test(&addr).detached_rpcs != 0 {
assert!(Instant::now() < deadline, "detached RPC slot must be reclaimed once the stream ends");
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await;
accept_task.abort();
reset_lock_peer_health_for_test(&addr);
}
#[tokio::test]
#[serial_test::serial]
async fn test_remote_client_cancels_timed_out_rpc_when_detached_budget_is_exhausted() {
ensure_test_rpc_secret();
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return;
};
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await;
temp_env::async_with_vars(
[
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
(rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT, Some("0")),
],
async {
let client = RemoteClient::new(addr.clone());
let response = client
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
.await
.unwrap();
assert!(!response.success);
assert_eq!(
lock_peer_health_for_test(&addr).detached_rpcs,
0,
"an exhausted detached budget falls back to cancelling the stream"
);
},
)
.await;
accept_task.abort();
reset_lock_peer_health_for_test(&addr);
}
#[test] #[test]
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() { fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
let mut single = Request::new(GenerallyLockRequest { let mut single = Request::new(GenerallyLockRequest {
@@ -1341,7 +714,6 @@ mod tests {
let Some((addr, accept_task)) = spawn_hanging_listener().await else { let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return; return;
}; };
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await; cache_lazy_channel(&addr).await;
assert!(runtime_sources::test_node_channel_is_cached(&addr).await); assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
@@ -1387,7 +759,6 @@ mod tests {
let Some((addr, accept_task)) = spawn_hanging_listener().await else { let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return; return;
}; };
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await; cache_lazy_channel(&addr).await;
assert!(runtime_sources::test_node_channel_is_cached(&addr).await); assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
@@ -1434,7 +805,6 @@ mod tests {
let Some((addr, accept_task)) = spawn_hanging_listener().await else { let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return; return;
}; };
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await; cache_lazy_channel(&addr).await;
assert!(runtime_sources::test_node_channel_is_cached(&addr).await); assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
@@ -1472,7 +842,6 @@ mod tests {
let Some((addr, accept_task)) = spawn_hanging_listener().await else { let Some((addr, accept_task)) = spawn_hanging_listener().await else {
return; return;
}; };
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await; cache_lazy_channel(&addr).await;
assert!(runtime_sources::test_node_channel_is_cached(&addr).await); assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
@@ -1515,7 +884,6 @@ mod tests {
let Some(addr) = closed_listener_addr().await else { let Some(addr) = closed_listener_addr().await else {
return; return;
}; };
reset_lock_peer_health_for_test(&addr);
cache_lazy_channel(&addr).await; cache_lazy_channel(&addr).await;
assert!(runtime_sources::test_node_channel_is_cached(&addr).await); assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
+10 -19
View File
@@ -747,27 +747,18 @@ mod tests {
let mut kvs = KVS::new(); let mut kvs = KVS::new();
kvs.insert(CLASS_STANDARD.to_string(), "EC:2".to_string()); kvs.insert(CLASS_STANDARD.to_string(), "EC:2".to_string());
for drives in [2, 3] { let err = lookup_config_for_pools_with_env(&kvs, &[4, 2], no_env_overrides())
let err = lookup_config_for_pools_with_env(&kvs, &[4, drives], no_env_overrides()) .expect_err("EC:2 must be rejected by the two-drive pool");
.expect_err("EC:2 must be rejected by a pool with fewer than four drives per set"); assert!(
assert!( err.to_string().contains("pool 1") && err.to_string().contains("2 drives"),
err.to_string().contains("pool 1") && err.to_string().contains(&format!("{drives} drives")), "error must identify the rejecting pool: {err}"
"error must identify the rejecting pool: {err}" );
);
}
let cfg =
lookup_config_for_pools_with_env(&kvs, &[4, 4], no_env_overrides()).expect("EC:2 is valid for both four-drive pools");
assert_eq!(cfg.parities_for_sc(STANDARD), Some(vec![2, 2]));
kvs.insert(CLASS_STANDARD.to_string(), "EC:1".to_string()); kvs.insert(CLASS_STANDARD.to_string(), "EC:1".to_string());
for drives in [2, 3, 4] { let cfg = lookup_config_for_pools_with_env(&kvs, &[4, 2], no_env_overrides()).expect("EC:1 is valid for both pools");
let cfg = assert_eq!(cfg.parity_for_sc(STANDARD, 4), Some(1));
lookup_config_for_pools_with_env(&kvs, &[4, drives], no_env_overrides()).expect("EC:1 is valid for both pools"); assert_eq!(cfg.parity_for_sc(STANDARD, 2), Some(1));
assert_eq!(cfg.parity_for_sc(STANDARD, 4), Some(1)); assert_eq!(cfg.get_parity_for_sc(STANDARD), Some(1));
assert_eq!(cfg.parity_for_sc(STANDARD, drives), Some(1));
assert_eq!(cfg.get_parity_for_sc(STANDARD), Some(1));
}
} }
#[test] #[test]
File diff suppressed because it is too large Load Diff
-926
View File
@@ -39,7 +39,6 @@ mod capacity_dedup_tests {
..Default::default() ..Default::default()
}, },
disks: disks.clone(), disks: disks.clone(),
..Default::default()
}; };
let total = get_total_usable_capacity(&disks, &info); let total = get_total_usable_capacity(&disks, &info);
@@ -74,7 +73,6 @@ mod capacity_dedup_tests {
..Default::default() ..Default::default()
}, },
disks: disks.clone(), disks: disks.clone(),
..Default::default()
}; };
let total = get_total_usable_capacity(&disks, &info); let total = get_total_usable_capacity(&disks, &info);
@@ -152,7 +150,6 @@ mod capacity_dedup_tests {
..Default::default() ..Default::default()
}, },
disks: disks.clone(), disks: disks.clone(),
..Default::default()
}; };
let total = get_total_usable_capacity(&disks, &info); let total = get_total_usable_capacity(&disks, &info);
@@ -570,465 +567,6 @@ mod decommission_lock_order_tests {
.expect("decommission activation should commit after the probe release"); .expect("decommission activation should commit after the probe release");
} }
#[test]
#[serial_test::serial]
fn staged_external_put_rechecks_retiring_source_on_another_node() {
run_large_stack_current_thread_async_test("staged-retiring-source", || async {
let (_temp_dirs, store, other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
let bucket = test_bucket("staged-source");
let object = "selected-before-retirement.bin";
let original = b"original source object";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create staged source bucket");
store.pools[0]
.put_object(&bucket, object, &mut PutObjReader::from_vec(original.to_vec()), &ObjectOptions::default())
.await
.expect("seed the source selected before retirement");
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
other_store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
]],
);
let barrier = DecommissionCapacityLockOrderBarrier::install(store.id, store.id);
barrier.pause_external_object_commit_phase();
let put_store = Arc::clone(&store);
let put_bucket = bucket.clone();
let put = tokio::spawn(async move {
put_store
.put_object(
&put_bucket,
object,
&mut PutObjReader::from_vec(b"must not replace a retiring source".to_vec()),
&ObjectOptions::default(),
)
.await
});
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_external_object_commit_phase_started())
.await
.expect("public PUT must stage before its decommission commit probe");
assert!(!store.pool_meta.read().await.is_suspended(0));
other_store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("the other node should activate retirement before the staged PUT commits");
assert!(other_store.pool_meta.read().await.is_suspended(0));
assert!(
!store.pool_meta.read().await.is_suspended(0),
"the writer's local snapshot must remain stale to exercise the durable admission probe"
);
barrier.release_external_object_commit_phase();
let result = tokio::time::timeout(Duration::from_secs(30), put)
.await
.expect("staged PUT must finish after the commit probe is released")
.expect("staged PUT must not panic");
assert!(
matches!(result, Err(crate::error::Error::SlowDown)),
"a staged PUT must retry pool selection instead of committing to a newly retiring source: {result:?}"
);
let mut reader = store.pools[0]
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the original source must remain readable after admission rejects the replacement");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read the full retained source body");
assert_eq!(body, original);
});
}
#[test]
#[serial_test::serial]
fn reserved_target_shares_business_io_and_retains_source_after_capacity_loss() {
run_large_stack_current_thread_async_test("shared-decommission-capacity", || async {
for lose_capacity in [false, true] {
let (_temp_dirs, store, other_store) =
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
let bucket = test_bucket("shared-capacity");
let object = "migrating-source.bin";
let business_object = "business-write.bin";
let multipart_object = "business-multipart.bin";
let source_body = vec![0x35; 256 * 1024];
let business_body = vec![0x57; 64 * 1024];
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create shared-capacity bucket");
store.pools[0]
.put_object(
&bucket,
object,
&mut PutObjReader::from_vec(source_body.clone()),
&ObjectOptions::default(),
)
.await
.expect("seed the retiring source");
store.pools[2]
.put_object(
&bucket,
business_object,
&mut PutObjReader::from_vec(b"previous business value".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("pin the public overwrite to the migration target");
let multipart_opts = ObjectOptions {
expected_bucket_incarnation_id: Some(
store
.bucket_incarnation_id(&bucket)
.await
.expect("load the multipart bucket identity"),
),
..Default::default()
};
let routing_upload = new_multipart_upload(&store, 2, &bucket, multipart_object, multipart_opts.clone())
.await
.expect("pin subsequent public multipart creation to the migration target");
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
let target_total = source_body.len() * 8;
let capacities = vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, source_body.len() * 2, source_body.len() * 2),
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
];
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("activate source retirement");
*other_store.pool_meta.write().await = store.pool_meta.read().await.clone();
let before = other_store.pool_meta.read().await.clone();
let reservation = before.pools[0]
.decommission
.as_ref()
.expect("active source")
.capacity_reservation
.as_ref()
.expect("durable reservation");
assert_eq!(
reservation.model_version, 2,
"exercise migration I/O outside the global metadata write lock"
);
assert_eq!(reservation.targets[0].pool_index, 2);
let barrier =
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
let migration_store = Arc::clone(&store);
let migration_bucket = bucket.clone();
let migration = tokio::spawn(async move {
migration_store
.decommission_entry_for_test_with_bucket_incarnation(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
migration_bucket,
migration_store.pools[0].get_disks_by_key(object),
)
.await
});
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
.await
.expect("migration must reach target publication");
assert!(!migration.is_finished());
let mut pending = crate::core::pools::PoolMeta::default();
pending
.load_no_lock_from_replicas(other_store.pools.clone())
.await
.expect("read migration intent from the other node");
let pending_reservation = pending.pools[0]
.decommission
.as_ref()
.expect("active source")
.capacity_reservation
.as_ref()
.expect("pending reservation")
.clone();
assert_eq!(pending_reservation.pending_target_physical_bytes, source_body.len());
assert_eq!(pending_reservation.consumed_target_physical_bytes, 0);
tokio::time::timeout(
Duration::from_secs(30),
other_store.put_object(
&bucket,
business_object,
&mut PutObjReader::from_vec(business_body.clone()),
&ObjectOptions::default(),
),
)
.await
.expect("business PUT must finish without waiting for the migration target gate")
.expect("a reserved healthy pool must accept ordinary PUT");
tokio::time::timeout(Duration::from_secs(30), async {
let upload = other_store
.new_multipart_upload(&bucket, multipart_object, &ObjectOptions::default())
.await
.expect("the reserved target must accept public multipart creation");
assert_ne!(upload.upload_id, routing_upload.upload_id);
let lifecycle_guard = other_store
.acquire_bucket_lifecycle_read_lock(&bucket)
.await
.expect("fence the exact-pool multipart placement check");
let mut lookup_opts = multipart_opts.clone();
lookup_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
other_store.pools[2]
.get_multipart_info(&bucket, multipart_object, &upload.upload_id, &lookup_opts)
.await
.expect("public multipart creation must actually select the reserved target");
drop(lifecycle_guard);
let mut final_part = None;
for payload in [vec![0x18; business_body.len()], business_body.clone()] {
final_part = Some(
other_store
.put_object_part(
&bucket,
multipart_object,
&upload.upload_id,
1,
&mut PutObjReader::from_vec(payload),
&ObjectOptions::default(),
)
.await
.expect("the reserved target must accept UploadPart and replacement of the same part"),
);
}
let part = final_part.expect("the replacement part must be present");
Arc::clone(&other_store)
.complete_multipart_upload(
&bucket,
multipart_object,
&upload.upload_id,
vec![crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect("the reserved target must accept multipart completion");
other_store
.abort_multipart_upload(&bucket, multipart_object, &routing_upload.upload_id, &ObjectOptions::default())
.await
.expect("ordinary multipart cleanup must not consume the migration's pending intent");
})
.await
.expect("business multipart operations must finish while migration I/O is paused");
assert!(!migration.is_finished(), "business publication must overlap paused migration I/O");
let mut after_business = crate::core::pools::PoolMeta::default();
after_business
.load_no_lock_from_replicas(other_store.pools.clone())
.await
.expect("reload the shared-capacity ledger");
assert_eq!(
after_business.pools[0]
.decommission
.as_ref()
.expect("active source")
.capacity_reservation
.as_ref(),
Some(&pending_reservation),
"ordinary PUT and multipart operations must not settle or consume the migration's pending identity"
);
let mut after_capacity = capacities;
// Capacity injection is deterministic; the object I/O and durable metadata use real temporary disks.
let free = if lose_capacity {
0
} else {
target_total - source_body.len() - business_body.len() * 2
};
after_capacity[2] = DecommissionPoolCapacityInfo::for_test(2, layout, free, target_total, target_total - free);
set_decommission_capacity_info_overrides_for_test(store.id, vec![after_capacity]);
barrier.release();
drop(barrier);
let migrated = tokio::time::timeout(Duration::from_secs(30), migration)
.await
.expect("migration must finish after publication resumes")
.expect("migration task must not panic");
if lose_capacity {
let err =
migrated.expect_err("capacity loss must prevent source cleanup, even after the target write commits");
assert!(err.to_string().contains("capacity"), "unexpected migration error: {err}");
} else {
migrated.expect("shared-capacity migration should finish when space remains sufficient");
}
let mut persisted = crate::core::pools::PoolMeta::default();
persisted
.load_no_lock_from_replicas(other_store.pools.clone())
.await
.expect("reload finalized migration state");
let info = persisted.pools[0].decommission.as_ref().expect("source state");
let reservation = info.capacity_reservation.as_ref().expect("migration ledger");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(
reservation.consumed_target_physical_bytes,
source_body.len(),
"foreign writes must not count as committed source bytes"
);
assert_eq!(reservation.committed_data_bytes, source_body.len());
assert_eq!(info.capacity_blocked_reason.is_some(), lose_capacity);
for (pool, key, expected) in [
(2, business_object, &business_body),
(2, multipart_object, &business_body),
(2, object, &source_body),
] {
let mut reader = other_store.pools[pool]
.get_object_reader(&bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("all acknowledged target objects must remain readable");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read the complete target body");
assert_eq!(&actual, expected);
}
if lose_capacity {
let mut source = other_store.pools[0]
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("capacity-blocked migration must retain its source");
let mut actual = Vec::new();
source
.read_to_end(&mut actual)
.await
.expect("read the complete retained source");
assert_eq!(actual, source_body);
other_store
.put_object(
&bucket,
business_object,
&mut PutObjReader::from_vec(business_body.clone()),
&ObjectOptions::default(),
)
.await
.expect("a capacity-blocked migration must not itself make the healthy target read-only");
} else {
let err = other_store.pools[0]
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect_err("successful migration must clean the exact source");
assert!(crate::error::is_err_object_not_found(&err));
}
}
});
}
#[test]
#[serial_test::serial]
fn mixed_batch_delete_admits_only_marker_destinations_during_retirement() {
run_large_stack_current_thread_async_test("batch-marker-admission", || async {
use crate::storage_api_contracts::object::ObjectToDelete;
for marker_target in [1, 2] {
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
let bucket = test_bucket("batch-marker");
store
.make_bucket(
&bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create a versioned batch-delete bucket");
let source_version = uuid::Uuid::new_v4();
for (pool, object, version) in [
(0, "purge-source", source_version),
(marker_target, "mark-active", uuid::Uuid::new_v4()),
] {
store.pools[pool]
.put_object(
&bucket,
object,
&mut PutObjReader::from_vec(b"version to delete".to_vec()),
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
..Default::default()
},
)
.await
.expect("seed each exact batch-delete destination");
}
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
]],
);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("reserve pool 1 while pool 0 retires and pool 2 remains unreserved");
let (deleted, errors) = store
.delete_objects(
&bucket,
vec![
ObjectToDelete {
object_name: "mark-active".to_string(),
..Default::default()
},
ObjectToDelete {
object_name: "purge-source".to_string(),
version_id: Some(source_version),
..Default::default()
},
],
ObjectOptions::default(),
)
.await;
assert_eq!(errors.len(), 2);
assert!(
errors.iter().all(Option::is_none),
"the unrelated retiring/reserved pools must not reject marker admission: {errors:?}"
);
assert_eq!(deleted.len(), 2);
assert_eq!(deleted[0].object_name, "mark-active");
assert!(deleted[0].delete_marker);
assert!(
deleted[0].version_id.is_none(),
"a latest-version delete does not request an explicit version"
);
assert!(
deleted[0].delete_marker_version_id.is_some(),
"the newly created marker must have its own version identity"
);
assert_eq!(deleted[1].object_name, "purge-source");
assert!(!deleted[1].delete_marker);
assert_eq!(deleted[1].version_id, Some(source_version));
assert!(
matches!(
store.pools[0]
.get_object_info(
&bucket,
"purge-source",
&ObjectOptions {
version_id: Some(source_version.to_string()),
..Default::default()
},
)
.await,
Err(crate::error::Error::ObjectNotFound(..) | crate::error::Error::VersionNotFound(..))
),
"an exact source deletion must retain its capacity-release path"
);
}
});
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn public_upload_part_holds_decommission_capacity_until_rename() { async fn public_upload_part_holds_decommission_capacity_until_rename() {
@@ -4958,470 +4496,6 @@ mod decommission_lock_order_tests {
} }
} }
#[test]
#[serial_test::serial]
fn scanner_backlog_cas_keeps_fences_after_waiter_cancellation_until_rename_drains() {
run_large_stack_current_thread_async_test("scanner-backlog-canceled-waiter", async || {
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
let (_temp_dirs, writer, other) =
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
let object = "buckets/.scanner-pause-backlog.json";
let set_index = 1;
let body = vec![0x37; 1024];
assert!(
!writer.pools[0].disk_set[0]
.shares_namespace_lock_domain(&writer.pools[0].disk_set[set_index])
.await
);
let rename_tasks = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
let tail =
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
let put_store = Arc::clone(&writer);
let put_body = body.clone();
let mut put = tokio::spawn(async move {
put_store
.save_scanner_pause_backlog_replica(0, set_index, put_body, Default::default())
.await
});
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
.await
.expect("the native write must reach its held rename");
tokio::time::timeout(Duration::from_secs(30), async {
while rename_tasks.running() != 1 {
tokio::task::yield_now().await;
}
})
.await
.expect("the other disks must reach quorum before canceling the waiter");
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut put).await.is_err(),
"native replica publication must await the entire rename tail"
);
put.abort();
assert!(put.await.expect_err("the scanner waiter must be canceled").is_cancelled());
let capacity_lock = other
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("capacity lock probe");
let object_lock = other
.new_ns_lock(RUSTFS_META_BUCKET, object)
.await
.expect("fixed object lock probe");
let mut capacity_probe = tokio::spawn(async move { capacity_lock.get_write_lock(Duration::from_secs(30)).await });
let mut object_probe = tokio::spawn(async move { object_lock.get_write_lock(Duration::from_secs(30)).await });
for (label, probe) in [("capacity", &mut capacity_probe), ("fixed object", &mut object_probe)] {
assert!(
tokio::time::timeout(Duration::from_millis(100), probe).await.is_err(),
"canceling the scanner waiter must retain its {label} fence while rename is pending"
);
}
tail.release();
drop(tail);
for probe in [capacity_probe, object_probe] {
drop(
tokio::time::timeout(Duration::from_secs(30), probe)
.await
.expect("publication fence must drain after rename")
.expect("lock probe must not panic")
.expect("publication fence must eventually be released"),
);
}
let mut reader = writer.pools[0].disk_set[set_index]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("canceled waiter must leave the committed replica readable");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read the full native replica after tail drain");
assert_eq!(actual, body);
})
.await;
});
}
#[test]
#[serial_test::serial]
fn scanner_backlog_cas_rejects_lost_capacity_lease_before_publication() {
run_large_stack_current_thread_async_test("scanner-backlog-lease-loss", async || {
let (_temp_dirs, writer, other) = test_three_pool_stores_with_isolated_node_contexts(None).await;
let object = "buckets/.scanner-pause-backlog.json";
let body = b"native source before lease loss".to_vec();
let original = writer
.save_scanner_pause_backlog_replica(2, 1, body.clone(), Default::default())
.await
.expect("seed the exact native replica set");
let (lossy, refresh_calls) = store_with_capacity_lease_loss(&other).await;
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, object, PutObjectCommitPause::BeforeQuotaRename);
let put = tokio::spawn(async move {
lossy
.save_scanner_pause_backlog_replica(
2,
1,
b"must not commit after lease loss".to_vec(),
crate::storage_api_contracts::object::HTTPPreconditions {
if_match: original.etag,
..Default::default()
},
)
.await
});
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
.await
.expect("native CAS must reach its commit barrier");
tokio::time::pause();
tokio::task::yield_now().await;
refresh_calls.arm();
tokio::time::advance(Duration::from_secs(11)).await;
tokio::task::yield_now().await;
assert!(
refresh_calls.load(Ordering::Acquire) > 0,
"the durable metadata lease must lose refresh quorum"
);
barrier.release();
tokio::time::resume();
let err = tokio::time::timeout(Duration::from_secs(30), put)
.await
.expect("native CAS must finish after the barrier release")
.expect("native CAS task must not panic")
.expect_err("a lost outer capacity lease must reject native publication");
assert!(
matches!(err, crate::error::Error::NamespaceLockQuorumUnavailable { .. }),
"unexpected lease error: {err}"
);
drop(barrier);
let mut reader = writer.pools[2].disk_set[1]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the preexisting replica must survive lease loss");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read the full retained replica");
assert_eq!(actual, body);
});
}
#[test]
#[serial_test::serial]
fn scanner_backlog_cas_rejects_a_retiring_source_on_a_stale_node() {
run_large_stack_current_thread_async_test("scanner-backlog-source-fence", async || {
let (_temp_dirs, store, writer) = test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
let object = "buckets/.scanner-pause-backlog.json";
let body = b"frozen native scanner replica".to_vec();
let source_set_index = (writer.pools[0].get_disks_by_key(object).set_index + 1) % writer.pools[0].disk_set.len();
assert_ne!(
source_set_index,
writer.pools[0].get_disks_by_key(object).set_index,
"exercise a non-routed native set"
);
let original = writer
.save_scanner_pause_backlog_replica(
0,
source_set_index,
body.clone(),
crate::storage_api_contracts::object::HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
},
)
.await
.expect("seed a native scanner replica before retirement");
assert!(
writer
.scanner_pause_backlog_writable_set_disks()
.await
.iter()
.any(|set| set.pool_index == 0)
);
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
let target_total = body.len() * 8;
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
]],
);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("another node durably retires the selected source");
assert!(
writer.pool_meta.read().await.pools[0].decommission.is_none(),
"the writer must retain a stale snapshot"
);
assert!(
writer
.scanner_pause_backlog_writable_set_disks()
.await
.iter()
.any(|set| set.pool_index == 0)
);
let result = writer
.save_scanner_pause_backlog_replica(
0,
source_set_index,
b"late native scanner update".to_vec(),
crate::storage_api_contracts::object::HTTPPreconditions {
if_match: original.etag.clone(),
..Default::default()
},
)
.await;
assert!(
matches!(result, Err(crate::error::Error::SlowDown)),
"late native source publication must fail: {result:?}"
);
let mut source = writer.pools[0].disk_set[source_set_index]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the original source must remain readable");
assert_eq!(source.object_info.etag, original.etag);
let mut actual = Vec::new();
source
.read_to_end(&mut actual)
.await
.expect("read the entire retained source");
assert_eq!(actual, body);
for set in &writer.pools[2].disk_set {
let target_body = format!("surviving native scanner set {}", set.set_index).into_bytes();
let committed = writer
.save_scanner_pause_backlog_replica(
2,
set.set_index,
target_body.clone(),
crate::storage_api_contracts::object::HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
},
)
.await
.expect("every reserved healthy target set must still accept scanner replicas");
let conflict = writer
.save_scanner_pause_backlog_replica(
2,
set.set_index,
b"must not bypass CAS".to_vec(),
crate::storage_api_contracts::object::HTTPPreconditions {
if_match: Some("stale-native-revision".to_string()),
..Default::default()
},
)
.await
.expect_err("capacity admission must retain the native writer's CAS");
assert!(matches!(conflict, crate::error::Error::PreconditionFailed));
let mut target = set
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("read the actual replica set, not the hash-routed set");
assert_eq!(target.object_info.etag, committed.etag);
let mut actual = Vec::new();
target
.read_to_end(&mut actual)
.await
.expect("read the complete native target");
assert_eq!(actual, target_body);
}
for (pool_index, set_index) in [(writer.pools.len(), 0), (0, writer.pools[0].disk_set.len())] {
assert!(matches!(
writer
.save_scanner_pause_backlog_replica(pool_index, set_index, Vec::new(), Default::default())
.await,
Err(crate::error::Error::InvalidArgument(_, _, _))
));
}
store
.decommission_cancel(0)
.await
.expect("cancel retirement before restoring native membership");
writer
.save_scanner_pause_backlog_replica(
0,
source_set_index,
b"canceled source membership repair".to_vec(),
crate::storage_api_contracts::object::HTTPPreconditions {
if_match: original.etag,
..Default::default()
},
)
.await
.expect("cancel must retain scanner's existing native membership repair contract");
});
}
#[test]
#[serial_test::serial]
fn scanner_backlog_native_replica_reconciles_capacity_and_cleans_source() {
run_large_stack_current_thread_async_test("scanner-backlog-reconcile", async || {
let (_temp_dirs, store, other_store) =
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
let object = "buckets/.scanner-pause-backlog.json";
let body = br#"{"schemaVersion":1,"generation":2}"#.to_vec();
let old_body = br#"{"schemaVersion":1,"generation":1}"#.to_vec();
let source_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20);
let target_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10);
for (pool_index, payload, mod_time) in [(0, body.clone(), source_time), (2, old_body, target_time)] {
store.pools[pool_index]
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(payload),
&ObjectOptions {
max_parity: true,
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed native scanner replicas with independent write times");
}
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
let target_total = body.len() * 8;
let capacities = vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
];
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("activate the source reservation");
let owner = decommission_capacity_owner(&*store.pool_meta.read().await);
let source_reader = store.pools[0]
.get_object_reader(
RUSTFS_META_BUCKET,
object,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
data_movement: true,
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("read the frozen source replica");
let conflict = data_movement::migrate_decommission_object(
Arc::clone(&store),
0,
RUSTFS_META_BUCKET.to_string(),
source_reader,
None,
"scanner_backlog_conflict",
Some(owner),
)
.await
.expect_err("a different older native ledger must retain its source and capacity intent");
assert!(conflict.to_string().contains("Precondition failed"), "unexpected conflict: {conflict}");
let mut persisted = crate::core::pools::PoolMeta::default();
persisted
.load_no_lock_from_replicas(store.pools.clone())
.await
.expect("reload the unresolved intent");
assert_eq!(
persisted.pools[0]
.decommission
.as_ref()
.expect("source state")
.capacity_reservation
.as_ref()
.expect("durable capacity")
.pending_target_physical_bytes,
body.len()
);
let previous = store.pools[2]
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
.await
.expect("read the native writer's CAS revision");
let replacement = store.pools[2]
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(body.clone()),
&ObjectOptions {
max_parity: true,
mod_time: Some(target_time),
http_preconditions: Some(crate::storage_api_contracts::object::HTTPPreconditions {
if_match: previous.etag,
..Default::default()
}),
..Default::default()
},
)
.await
.expect("native scanner CAS converges the payload without a migration marker");
assert!(!data_movement::is_owned_data_movement_target(&replacement));
*other_store.pool_meta.write().await = persisted;
set_decommission_capacity_info_overrides_for_test(other_store.id, vec![capacities]);
tokio::time::timeout(
Duration::from_secs(30),
other_store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
other_store.pools[0].get_disks_by_key(object),
),
)
.await
.expect("replica conflict recovery must be bounded")
.expect("identical native replica should finish migration on the reloaded node");
let mut reconciled = crate::core::pools::PoolMeta::default();
reconciled
.load_no_lock_from_replicas(other_store.pools.clone())
.await
.expect("reload reconciled capacity");
let reservation = reconciled.pools[0]
.decommission
.as_ref()
.expect("source state")
.capacity_reservation
.as_ref()
.expect("reconciled capacity");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(reservation.committed_data_bytes, body.len());
assert_eq!(reservation.consumed_target_physical_bytes, body.len());
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
assert_eq!(
other_store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("worker progress")
.items_decommission_failed,
0
);
let missing = other_store.pools[0]
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
.await
.expect_err("the source should be cleaned only after equivalent-target capacity reconciliation");
assert!(crate::error::is_err_object_not_found(&missing));
let mut target_reader = other_store.pools[2]
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("the surviving replica should remain readable");
assert_eq!(
target_reader.object_info.mod_time,
Some(target_time),
"recovery must not overwrite the native target"
);
let mut actual = Vec::new();
target_reader
.read_to_end(&mut actual)
.await
.expect("read surviving ledger bytes");
assert_eq!(actual, body);
});
}
#[test] #[test]
#[serial_test::serial] #[serial_test::serial]
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() { fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
+1 -147
View File
@@ -984,24 +984,6 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
.is_some_and(|(source_time, target_time)| target_time > source_time) .is_some_and(|(source_time, target_time)| target_time > source_time)
} }
fn is_equivalent_scanner_backlog_replica(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
// Scanner publishes this exact payload to surviving sets with CAS. Each
// set assigns its own write time; that timestamp is not a ledger generation.
// Accept only an identical, known unversioned identity, never a different
// record based on timestamp ordering or a similarly named user object.
source.bucket == crate::disk::RUSTFS_META_BUCKET
&& target.bucket == source.bucket
&& source.name == "buckets/.scanner-pause-backlog.json"
&& target.name == source.name
&& is_unversioned_data_movement_object(source)
&& is_unversioned_data_movement_object(target)
&& !source.delete_marker
&& source.mod_time.is_some()
&& target.mod_time.is_some()
&& source.etag.as_ref().is_some_and(|etag| !etag.is_empty())
&& is_equivalent_data_movement_object_identity(source, target, false, compare_part_checksums)
}
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool { fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
let identity = data_movement_upload_identity(source); let identity = data_movement_upload_identity(source);
source.mod_time.is_some() source.mod_time.is_some()
@@ -1471,9 +1453,7 @@ fn resolve_data_movement_overwrite_resume_result_for(
return Ok(true); return Ok(true);
} }
Ok(matches!(err, Error::PreconditionFailed) Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
&& (is_equivalent_scanner_backlog_replica(source, &target, compare_part_checksums)
|| is_superseding_unversioned_data_movement_object(source, &target)))
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -3308,132 +3288,6 @@ mod tests {
assert!(overwrite_resume_for_target(&source, source.clone())); assert!(overwrite_resume_for_target(&source, source.clone()));
} }
fn scanner_backlog_replica_pair() -> (ObjectInfo, ObjectInfo) {
let source = ObjectInfo {
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
name: "buckets/.scanner-pause-backlog.json".to_string(),
version_id: None,
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND),
..overwrite_equivalence_source()
};
let target = ObjectInfo {
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
..source.clone()
};
(source, target)
}
fn scanner_backlog_precondition_resumes(source: &ObjectInfo, target: ObjectInfo) -> bool {
resolve_data_movement_overwrite_resume_result_for(&Error::PreconditionFailed, Ok(Some(target)), source, 0, 1, true)
.expect("scanner replica conflict should be adjudicated")
}
#[test]
fn test_scanner_backlog_resume_accepts_identical_native_replica_with_older_write_time() {
let (source, target) = scanner_backlog_replica_pair();
assert!(!is_owned_data_movement_target(&target), "native scanner writes are not migration copies");
assert!(!is_equivalent_data_movement_object(&source, &target));
assert!(
scanner_backlog_precondition_resumes(&source, target),
"identical ledger payloads have replica-local write times, not distinct committed generations"
);
}
#[test]
fn test_scanner_backlog_resume_rejects_changed_payload_or_metadata() {
let (source, target) = scanner_backlog_replica_pair();
let mut different_etag = target.clone();
different_etag.etag = Some("different-ledger-generation".to_string());
let mut different_size = target.clone();
different_size.size += 1;
let mut different_checksum = target.clone();
different_checksum.checksum = Some(Bytes::from_static(b"different-checksum"));
let mut different_metadata = target.clone();
Arc::make_mut(&mut different_metadata.user_defined).insert("x-amz-meta-key".to_string(), "different".to_string());
let mut different_tags = target.clone();
different_tags.user_tags = Arc::new("tag=changed".to_string());
let mut different_parts = target.clone();
Arc::make_mut(&mut different_parts.parts)[0].etag = "different-part".to_string();
let mut different_tier = target;
different_tier.transitioned_object.tier = "different-tier".to_string();
for (label, different) in [
("etag", different_etag),
("size", different_size),
("checksum", different_checksum),
("metadata", different_metadata),
("tags", different_tags),
("parts", different_parts),
("tier", different_tier),
] {
assert!(
!scanner_backlog_precondition_resumes(&source, different),
"replica-local timestamps do not authorize a changed {label}"
);
}
}
#[test]
fn test_scanner_backlog_resume_rejects_other_namespaces_and_incomplete_identity() {
let (source, target) = scanner_backlog_replica_pair();
for (bucket, name) in [
("user-bucket", "buckets/.scanner-pause-backlog.json"),
(crate::disk::RUSTFS_META_BUCKET, "buckets/.scanner-pause-backlog.json.bkp"),
(crate::disk::RUSTFS_META_BUCKET, "buckets/.usage-cache.bin"),
] {
let mut source = source.clone();
let mut target = target.clone();
for replica in [&mut source, &mut target] {
replica.bucket = bucket.to_string();
replica.name = name.to_string();
}
assert!(!scanner_backlog_precondition_resumes(&source, target), "out-of-scope key {bucket}/{name}");
}
for missing in ["etag", "empty-etag", "source-time", "target-time", "version", "delete-marker"] {
let mut source = source.clone();
let mut target = target.clone();
match missing {
"etag" => {
source.etag = None;
target.etag = None;
}
"empty-etag" => {
source.etag = Some(String::new());
target.etag = Some(String::new());
}
"source-time" => source.mod_time = None,
"target-time" => target.mod_time = None,
"version" => {
source.version_id = Some(Uuid::from_u128(1));
target.version_id = source.version_id;
}
"delete-marker" => {
source.delete_marker = true;
target.delete_marker = true;
}
_ => unreachable!("all identity variants are enumerated above"),
}
assert!(!scanner_backlog_precondition_resumes(&source, target), "unsupported identity: {missing}");
}
}
#[test]
fn test_scanner_backlog_resume_requires_a_cross_pool_precondition_conflict() {
let (source, target) = scanner_backlog_replica_pair();
for (err, target_pool) in [
(Error::PreconditionFailed, 0),
(Error::SlowDown, 1),
(
Error::InvalidUploadID(source.bucket.clone(), source.name.clone(), "upload".to_string()),
1,
),
] {
assert!(
!resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target.clone())), &source, 0, target_pool, true)
.expect("non-resumable conflict should return false")
);
}
}
#[test] #[test]
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() { fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
let source = overwrite_equivalence_source(); let source = overwrite_equivalence_source();
-1
View File
@@ -425,7 +425,6 @@ impl From<rustfs_filemeta::Error> for DiskError {
rustfs_filemeta::Error::FileVersionNotFound => DiskError::FileVersionNotFound, rustfs_filemeta::Error::FileVersionNotFound => DiskError::FileVersionNotFound,
rustfs_filemeta::Error::FileCorrupt => DiskError::FileCorrupt, rustfs_filemeta::Error::FileCorrupt => DiskError::FileCorrupt,
rustfs_filemeta::Error::MethodNotAllowed => DiskError::MethodNotAllowed, rustfs_filemeta::Error::MethodNotAllowed => DiskError::MethodNotAllowed,
rustfs_filemeta::Error::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
e => DiskError::other(e), e => DiskError::other(e),
} }
} }
-80
View File
@@ -22463,86 +22463,6 @@ mod test {
); );
} }
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_dir_fsync_failure_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_COMMIT_MANIFEST_SLOT_1: &str = ".heal-mrf-commit.1.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
.await
.expect("previous MRF manifest should commit"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
assert!(
os::fsync_dir_recorder::was_fsynced(parent),
"system metadata MRF manifest publication must fsync the metadata directory even under relaxed durability"
);
os::fsync_dir_recorder::set_failure(parent, ErrorKind::Other);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_0,
Some(previous_manifest.clone()),
Some(successor_manifest),
)
.await
.expect_err("directory fsync failure must fail the MRF manifest successor commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("previous committed MRF manifest should remain readable after rollback"),
previous_manifest
);
os::fsync_dir_recorder::set_failure(parent, ErrorKind::Other);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_1,
None,
Some(Bytes::from_static(b"first-successor-manifest")),
)
.await
.expect_err("directory fsync failure must fail first MRF manifest commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
assert!(
matches!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_1).await,
Err(DiskError::FileNotFound)
),
"uncommitted first MRF manifest must be removed when no committed anchor exists"
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after failed manifest publication"),
legacy_journal
);
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() { async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
+4 -52
View File
@@ -263,7 +263,7 @@ pub(crate) mod fsync_dir_recorder {
} }
/// Pause a real namespace mutation inside its physical executor. /// Pause a real namespace mutation inside its physical executor.
#[cfg(all(any(test, feature = "test-util"), not(windows)))] #[cfg(all(test, not(windows)))]
pub(crate) mod prepared_publication_test_hooks { pub(crate) mod prepared_publication_test_hooks {
use super::*; use super::*;
@@ -272,9 +272,7 @@ pub(crate) mod prepared_publication_test_hooks {
PreparedRename, PreparedRename,
Rename, Rename,
Remove, Remove,
#[cfg(test)]
Rollback, Rollback,
#[cfg(test)]
DirFsync, DirFsync,
} }
@@ -290,7 +288,6 @@ pub(crate) mod prepared_publication_test_hooks {
} }
} }
#[cfg(test)]
pub(crate) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) -> Guard { pub(crate) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) -> Guard {
install_at(Stage::PreparedRename, path, hook) install_at(Stage::PreparedRename, path, hook)
} }
@@ -349,51 +346,6 @@ pub(crate) mod prepared_publication_test_hooks {
} }
} }
/// Controlled application-test pause at an existing physical executor boundary.
#[cfg(all(feature = "test-util", not(windows)))]
pub struct LocalPublicationPause {
_hook: prepared_publication_test_hooks::Guard,
entered: oneshot::Receiver<()>,
_release: std::sync::mpsc::Sender<()>,
}
#[cfg(all(feature = "test-util", not(windows)))]
#[derive(Clone, Copy)]
pub enum LocalPublicationStage {
PreparedRename,
Rename,
Remove,
}
#[cfg(all(feature = "test-util", not(windows)))]
impl LocalPublicationPause {
pub fn install(disk: &crate::disk::Disk, volume: &str, path: &str, stage: LocalPublicationStage) -> Result<Self> {
let path = disk
.get_object_path_for_io_if_local(volume, path)
.ok_or(DiskError::DiskNotFound)??;
let stage = match stage {
LocalPublicationStage::PreparedRename => prepared_publication_test_hooks::Stage::PreparedRename,
LocalPublicationStage::Rename => prepared_publication_test_hooks::Stage::Rename,
LocalPublicationStage::Remove => prepared_publication_test_hooks::Stage::Remove,
};
let (entered_tx, entered) = oneshot::channel();
let (release, release_rx) = std::sync::mpsc::channel::<()>();
let hook = prepared_publication_test_hooks::install_at(stage, &path, move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
Ok(Self {
_hook: hook,
entered,
_release: release,
})
}
pub async fn entered(&mut self) -> std::result::Result<(), oneshot::error::RecvError> {
(&mut self.entered).await
}
}
#[cfg(all(test, windows))] #[cfg(all(test, windows))]
pub(crate) mod windows_rename_test_hooks { pub(crate) mod windows_rename_test_hooks {
use super::*; use super::*;
@@ -2024,7 +1976,7 @@ pub(crate) async fn remove_file_with_owner(
let path = path.as_ref().to_path_buf(); let path = path.as_ref().to_path_buf();
let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await; let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
run_blocking_namespace_operation(lease, move || { run_blocking_namespace_operation(lease, move || {
#[cfg(all(any(test, feature = "test-util"), not(windows)))] #[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Remove, &path); prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Remove, &path);
std::fs::remove_file(path) std::fs::remove_file(path)
}) })
@@ -2284,7 +2236,7 @@ pub(crate) async fn rename_all_with_prepared_source(
move || { move || {
validate_prepared_rename_source(&prepared_source, &src_file_path)?; validate_prepared_rename_source(&prepared_source, &src_file_path)?;
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?; let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
#[cfg(any(test, feature = "test-util"))] #[cfg(test)]
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::PreparedRename, &dst_file_path); prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::PreparedRename, &dst_file_path);
rename_prepared(&src_file_path, &dst_file_path, &preparation) rename_prepared(&src_file_path, &dst_file_path, &preparation)
} }
@@ -2417,7 +2369,7 @@ async fn reliable_rename_inner_with_lease(
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?; let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
#[cfg(all(test, not(windows)))] #[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run_rename_destination(&src_file_path, &dst_file_path); prepared_publication_test_hooks::run_rename_destination(&src_file_path, &dst_file_path);
#[cfg(all(any(test, feature = "test-util"), not(windows)))] #[cfg(all(test, not(windows)))]
{ {
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &src_file_path); prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &src_file_path);
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &dst_file_path); prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &dst_file_path);
+34 -108
View File
@@ -431,7 +431,7 @@ impl<'a> MultiWriter<'a> {
errs = ?self.errs, errs = ?self.errs,
"Erasure encode write quorum unavailable: {summary_text}" "Erasure encode write quorum unavailable: {summary_text}"
); );
Err(write_err.into()) Err(std::io::Error::other(format!("Failed to write data: {summary_text}")))
} }
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) { async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
@@ -503,7 +503,7 @@ impl<'a> MultiWriter<'a> {
errs = ?self.errs, errs = ?self.errs,
"Erasure encode shutdown quorum unavailable: {summary_text}" "Erasure encode shutdown quorum unavailable: {summary_text}"
); );
Err(write_err.into()) Err(std::io::Error::other(format!("Failed to shutdown writers: {summary_text}")))
} }
} }
@@ -1002,7 +1002,6 @@ impl Erasure {
mod tests { mod tests {
use super::*; use super::*;
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter}; use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
use crate::error::StorageError;
use rustfs_rio::HardLimitReader; use rustfs_rio::HardLimitReader;
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use std::future::Future; use std::future::Future;
@@ -1452,14 +1451,7 @@ mod tests {
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"), Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
Err(err) => err, Err(err) => err,
}; };
let err = StorageError::from(err); assert!(err.to_string().contains("Failed to write data"));
assert!(matches!(
&err,
StorageError::Io(source)
if source.kind() == std::io::ErrorKind::Other
&& source.to_string() == "injected write failure after producer blocks"
));
assert!(!err.is_quorum_error());
tokio::time::timeout(Duration::from_secs(1), reader_dropped) tokio::time::timeout(Duration::from_secs(1), reader_dropped)
.await .await
.expect("writer failure should abort the blocked producer") .expect("writer failure should abort the blocked producer")
@@ -1652,7 +1644,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn multi_writer_short_write_fails_before_shutdown() { async fn multi_writer_short_write_fails_before_shutdown() {
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 32))]; let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 16))];
let err = { let err = {
let mut writer = MultiWriter::new(&mut writers, 1); let mut writer = MultiWriter::new(&mut writers, 1);
writer writer
@@ -1661,93 +1653,63 @@ mod tests {
.expect_err("short writes must fail the shard writer") .expect_err("short writes must fail the shard writer")
}; };
let err = StorageError::from(err); assert!(err.to_string().contains("Failed to write data"));
assert!(matches!(&err, StorageError::Io(source) if source.kind() == std::io::ErrorKind::WriteZero));
assert!(!err.is_quorum_error());
assert!(writers[0].is_none(), "short-write shard must be removed before commit"); assert!(writers[0].is_none(), "short-write shard must be removed before commit");
} }
#[tokio::test] #[tokio::test]
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() { async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
let mut writers = vec![None, None]; let mut writers = vec![None, None];
let (err, summary) = { let err = {
let mut writer = MultiWriter::new(&mut writers, 1); let mut writer = MultiWriter::new(&mut writers, 1);
let err = writer writer
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")]) .write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
.await .await
.expect_err("offline writers cannot satisfy write quorum"); .expect_err("offline writers cannot satisfy write quorum")
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
(err, format_write_quorum_failure(&summary))
}; };
assert_eq!( let err = err.to_string();
err.get_ref().and_then(|source| source.downcast_ref::<Error>()), assert!(err.contains("Failed to write data"));
Some(&Error::ErasureWriteQuorum), assert!(err.contains("offline-disks=2/2"));
); assert!(err.contains("required=1"));
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
assert!(summary.contains("offline-disks=2/2"));
assert!(summary.contains("required=1"));
let (shutdown_err, summary) = { let shutdown_err = {
let mut writer = MultiWriter::new(&mut writers, 1); let mut writer = MultiWriter::new(&mut writers, 1);
let err = writer writer
.shutdown() .shutdown()
.await .await
.expect_err("offline writers cannot satisfy shutdown quorum"); .expect_err("offline writers cannot satisfy shutdown quorum")
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
(err, format_write_quorum_failure(&summary))
}; };
assert_eq!( let shutdown_err = shutdown_err.to_string();
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()), assert!(shutdown_err.contains("Failed to shutdown writers"));
Some(&Error::ErasureWriteQuorum), assert!(shutdown_err.contains("offline-disks=2/2"));
); assert!(shutdown_err.contains("required=1"));
let shutdown_err = StorageError::from(shutdown_err);
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
assert!(shutdown_err.is_quorum_error());
assert!(summary.contains("offline-disks=2/2"));
assert!(summary.contains("required=1"));
} }
#[tokio::test] #[tokio::test]
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() { async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
let committed = Arc::new(Mutex::new(Vec::new())); let committed = Arc::new(Mutex::new(Vec::new()));
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 32))]; let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 16))];
let mut writer = MultiWriter::new(&mut writers, 2); let mut writer = MultiWriter::new(&mut writers, 2);
let err = writer let err = writer
.write(vec![Bytes::from_static(b"quorum impossible")]) .write(vec![Bytes::from_static(b"quorum impossible")])
.await .await
.expect_err("write quorum above writer count must fail"); .expect_err("write quorum above writer count must fail");
assert_eq!( let err = err.to_string();
err.get_ref().and_then(|source| source.downcast_ref::<Error>()), assert!(err.contains("Failed to write data"));
Some(&Error::ErasureWriteQuorum), assert!(err.contains("required=2"));
); assert!(err.contains("erasure write quorum"));
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
let summary = format_write_quorum_failure(&summary);
assert!(summary.contains("required=2"));
assert!(summary.contains("erasure write quorum"));
let shutdown_err = writer let shutdown_err = writer
.shutdown() .shutdown()
.await .await
.expect_err("shutdown quorum above writer count must fail"); .expect_err("shutdown quorum above writer count must fail");
assert_eq!( let shutdown_err = shutdown_err.to_string();
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()), assert!(shutdown_err.contains("Failed to shutdown writers"));
Some(&Error::ErasureWriteQuorum), assert!(shutdown_err.contains("required=2"));
); assert!(shutdown_err.contains("erasure write quorum"));
let shutdown_err = StorageError::from(shutdown_err);
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
assert!(shutdown_err.is_quorum_error());
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
let summary = format_write_quorum_failure(&summary);
assert!(summary.contains("required=2"));
assert!(summary.contains("erasure write quorum"));
} }
// The production wiring (`MultiWriter::new`) must arm a real deadline by // The production wiring (`MultiWriter::new`) must arm a real deadline by
@@ -1832,13 +1794,7 @@ mod tests {
.write(four_shards()) .write(four_shards())
.await .await
.expect_err("two stalled writers must fail the write quorum instead of hanging"); .expect_err("two stalled writers must fail the write quorum instead of hanging");
assert_eq!( assert!(err.to_string().contains("Failed to write data"));
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
Some(&Error::ErasureWriteQuorum),
);
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
} }
// A small object whose bytes were fully buffered leaves `write` succeeding // A small object whose bytes were fully buffered leaves `write` succeeding
@@ -1883,13 +1839,7 @@ mod tests {
.shutdown() .shutdown()
.await .await
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging"); .expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
assert_eq!( assert!(err.to_string().contains("Failed to shutdown writers"));
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
Some(&Error::ErasureWriteQuorum),
);
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
} }
// A slow-but-honest writer that keeps completing shards (delay < stall // A slow-but-honest writer that keeps completing shards (delay < stall
@@ -2171,13 +2121,7 @@ mod tests {
.await .await
.expect_err("streaming encode must fail when write quorum is unavailable"); .expect_err("streaming encode must fail when write quorum is unavailable");
assert_eq!( assert!(err.to_string().contains("Failed to write data"));
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
Some(&Error::ErasureWriteQuorum),
);
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
} }
#[tokio::test] #[tokio::test]
@@ -2201,13 +2145,7 @@ mod tests {
.await .await
.expect_err("write quorum failure must fail the inline encode"); .expect_err("write quorum failure must fail the inline encode");
assert_eq!( assert!(err.to_string().contains("Failed to write data"));
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
Some(&Error::ErasureWriteQuorum),
);
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
assert!( assert!(
committed.lock().expect("committed buffer should be lockable").is_empty(), committed.lock().expect("committed buffer should be lockable").is_empty(),
"successful writer must not be committed when write quorum fails before shutdown" "successful writer must not be committed when write quorum fails before shutdown"
@@ -2235,13 +2173,7 @@ mod tests {
.await .await
.expect_err("shutdown quorum failure must fail the inline encode"); .expect_err("shutdown quorum failure must fail the inline encode");
let err = StorageError::from(err); assert!(err.to_string().contains("Failed to shutdown writers"));
assert!(matches!(
&err,
StorageError::Io(source)
if source.kind() == std::io::ErrorKind::Other && source.to_string() == "injected shutdown failure"
));
assert!(!err.is_quorum_error());
assert!( assert!(
!committed.lock().expect("committed buffer should be lockable").is_empty(), !committed.lock().expect("committed buffer should be lockable").is_empty(),
"the successful writer should have committed before shutdown quorum failure was reported" "the successful writer should have committed before shutdown quorum failure was reported"
@@ -2463,13 +2395,7 @@ mod tests {
.await .await
.expect_err("batched encode must fail when write quorum is unavailable"); .expect_err("batched encode must fail when write quorum is unavailable");
assert_eq!( assert!(err.to_string().contains("Failed to write data"));
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
Some(&Error::ErasureWriteQuorum),
);
let err = StorageError::from(err);
assert_eq!(err, StorageError::ErasureWriteQuorum);
assert!(err.is_quorum_error());
} }
#[tokio::test] #[tokio::test]
+5 -132
View File
@@ -23,59 +23,17 @@ use s3s::S3ErrorCode;
pub type Error = StorageError; pub type Error = StorageError;
pub type Result<T> = core::result::Result<T, Error>; pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PoolMetadataFailure {
ReadUnavailable,
RecoveryRequired,
TransactionUnknown,
FenceLost,
}
impl PoolMetadataFailure {
fn recovery_hint(self) -> &'static str {
match self {
Self::ReadUnavailable => "read unavailable; retry after the replicas are readable",
Self::TransactionUnknown => "writes remain blocked pending fenced transaction recovery",
Self::RecoveryRequired | Self::FenceLost => {
"writes remain blocked after a recovery-required replica state; restart after all replicas are readable and consistent, with compatible formats"
}
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::ReadUnavailable => "read_unavailable",
Self::RecoveryRequired => "recovery_required",
Self::TransactionUnknown => "transaction_unknown",
Self::FenceLost => "fence_lost",
}
}
}
/// Local control-plane context. Keep the existing storage error wire codes;
/// the HTTP boundary recognizes this typed source, not an error-message prefix.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{operation}: pool metadata {hint} ({reason}, {phase}): {detail}", hint = kind.recovery_hint(), reason = kind.as_str(), detail = source.as_ref().map(ToString::to_string).unwrap_or_default())]
pub struct PoolMetadataError {
pub kind: PoolMetadataFailure,
pub operation: String,
pub phase: &'static str,
pub since: time::OffsetDateTime,
#[source]
pub source: Option<std::sync::Arc<StorageError>>,
}
/// Keeps high-cardinality diagnostic detail in the error source while making /// Keeps high-cardinality diagnostic detail in the error source while making
/// the rendered `io::Error` stable for quorum aggregation. /// the rendered `io::Error` stable for quorum aggregation.
#[derive(Debug)] #[derive(Debug)]
struct StableIoContextError { struct StableIoContextError {
message: std::borrow::Cow<'static, str>, message: &'static str,
source: Box<dyn std::error::Error + Send + Sync>, source: Box<dyn std::error::Error + Send + Sync>,
} }
impl std::fmt::Display for StableIoContextError { impl std::fmt::Display for StableIoContextError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message) formatter.write_str(self.message)
} }
} }
@@ -90,7 +48,7 @@ where
E: Into<Box<dyn std::error::Error + Send + Sync>>, E: Into<Box<dyn std::error::Error + Send + Sync>>,
{ {
std::io::Error::other(StableIoContextError { std::io::Error::other(StableIoContextError {
message: message.into(), message,
source: source.into(), source: source.into(),
}) })
} }
@@ -245,19 +203,6 @@ pub enum StorageError {
NotFirstDisk, NotFirstDisk,
#[error("first disk wait")] #[error("first disk wait")]
FirstDiskWait, FirstDiskWait,
#[error(
"unsupported pool expansion: an existing single-node single-drive (SNSD) deployment cannot be expanded in place (configured {configured_drives} drive endpoints); restart with the original single local path, or create a new multi-drive deployment and migrate data through S3"
)]
UnsupportedSnsdExpansion { configured_drives: usize },
#[error(
"pool topology mismatch: stored {stored_drives} drives with {stored_set_drive_count} drives per erasure set, configured {configured_drives} drives with {configured_set_drive_count} drives per erasure set; an existing pool's drive count and erasure set width cannot be changed in place; restore its original endpoints and RUSTFS_ERASURE_SET_DRIVE_COUNT setting; to expand a multi-drive deployment, append a new pool with at least 2 drive endpoints"
)]
PoolTopologyMismatch {
stored_drives: usize,
stored_set_drive_count: usize,
configured_drives: usize,
configured_set_drive_count: usize,
},
// ── Operational ────────────────────────────────────────────────── // ── Operational ──────────────────────────────────────────────────
#[error("Storage reached its minimum free drive threshold.")] #[error("Storage reached its minimum free drive threshold.")]
@@ -342,22 +287,6 @@ impl From<crate::erasure::coding::ErasureConstructionError> for StorageError {
} }
impl StorageError { impl StorageError {
pub fn pool_metadata_failure(&self) -> Option<&PoolMetadataError> {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self);
while let Some(error) = current {
if let Some(context) = error.downcast_ref::<PoolMetadataError>() {
return Some(context);
}
// io::Error::source skips its boxed context itself.
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
} else {
error.source()
};
}
None
}
pub fn other<E>(error: E) -> Self pub fn other<E>(error: E) -> Self
where where
E: Into<Box<dyn std::error::Error + Send + Sync>>, E: Into<Box<dyn std::error::Error + Send + Sync>>,
@@ -588,7 +517,6 @@ impl From<rustfs_filemeta::Error> for StorageError {
rustfs_filemeta::Error::FileVersionNotFound => StorageError::FileVersionNotFound, rustfs_filemeta::Error::FileVersionNotFound => StorageError::FileVersionNotFound,
rustfs_filemeta::Error::FileCorrupt => StorageError::FileCorrupt, rustfs_filemeta::Error::FileCorrupt => StorageError::FileCorrupt,
rustfs_filemeta::Error::Unexpected => StorageError::Unexpected, rustfs_filemeta::Error::Unexpected => StorageError::Unexpected,
rustfs_filemeta::Error::MaxVersionsExceeded => StorageError::MaxVersionsExceeded,
rustfs_filemeta::Error::Io(io_error) => io_error.into(), rustfs_filemeta::Error::Io(io_error) => io_error.into(),
_ => StorageError::Io(std::io::Error::other(e)), _ => StorageError::Io(std::io::Error::other(e)),
} }
@@ -607,19 +535,7 @@ impl PartialEq for StorageError {
impl Clone for StorageError { impl Clone for StorageError {
fn clone(&self) -> Self { fn clone(&self) -> Self {
match self { match self {
StorageError::Io(e) => { StorageError::Io(e) => StorageError::Io(std::io::Error::new(e.kind(), e.to_string())),
if let Some(context) = self.pool_metadata_failure() {
Self::Io(std::io::Error::new(
e.kind(),
StableIoContextError {
message: e.to_string().into(),
source: Box::new(context.clone()),
},
))
} else {
StorageError::Io(std::io::Error::new(e.kind(), e.to_string()))
}
}
StorageError::FaultyDisk => StorageError::FaultyDisk, StorageError::FaultyDisk => StorageError::FaultyDisk,
StorageError::DiskFull => StorageError::DiskFull, StorageError::DiskFull => StorageError::DiskFull,
StorageError::VolumeNotFound => StorageError::VolumeNotFound, StorageError::VolumeNotFound => StorageError::VolumeNotFound,
@@ -713,20 +629,6 @@ impl Clone for StorageError {
StorageError::ErasureWriteQuorum => StorageError::ErasureWriteQuorum, StorageError::ErasureWriteQuorum => StorageError::ErasureWriteQuorum,
StorageError::NotFirstDisk => StorageError::NotFirstDisk, StorageError::NotFirstDisk => StorageError::NotFirstDisk,
StorageError::FirstDiskWait => StorageError::FirstDiskWait, StorageError::FirstDiskWait => StorageError::FirstDiskWait,
StorageError::UnsupportedSnsdExpansion { configured_drives } => StorageError::UnsupportedSnsdExpansion {
configured_drives: *configured_drives,
},
StorageError::PoolTopologyMismatch {
stored_drives,
stored_set_drive_count,
configured_drives,
configured_set_drive_count,
} => StorageError::PoolTopologyMismatch {
stored_drives: *stored_drives,
stored_set_drive_count: *stored_set_drive_count,
configured_drives: *configured_drives,
configured_set_drive_count: *configured_set_drive_count,
},
StorageError::TooManyOpenFiles => StorageError::TooManyOpenFiles, StorageError::TooManyOpenFiles => StorageError::TooManyOpenFiles,
StorageError::NoHealRequired => StorageError::NoHealRequired, StorageError::NoHealRequired => StorageError::NoHealRequired,
StorageError::Lock(e) => StorageError::Lock(e.clone()), StorageError::Lock(e) => StorageError::Lock(e.clone()),
@@ -760,8 +662,7 @@ impl Clone for StorageError {
} }
impl StorageError { impl StorageError {
/// Stable classification without error payloads or storage paths. fn code(&self) -> StorageErrorCode {
pub fn code(&self) -> StorageErrorCode {
match self { match self {
StorageError::Io(_) => StorageErrorCode::Io, StorageError::Io(_) => StorageErrorCode::Io,
StorageError::FaultyDisk => StorageErrorCode::FaultyDisk, StorageError::FaultyDisk => StorageErrorCode::FaultyDisk,
@@ -834,11 +735,6 @@ impl StorageError {
StorageError::ErasureWriteQuorum => StorageErrorCode::ErasureWriteQuorum, StorageError::ErasureWriteQuorum => StorageErrorCode::ErasureWriteQuorum,
StorageError::NotFirstDisk => StorageErrorCode::NotFirstDisk, StorageError::NotFirstDisk => StorageErrorCode::NotFirstDisk,
StorageError::FirstDiskWait => StorageErrorCode::FirstDiskWait, StorageError::FirstDiskWait => StorageErrorCode::FirstDiskWait,
// Topology diagnostics reuse the existing wire code; they are
// not disk errors and must retain their local identity for retry classification.
StorageError::UnsupportedSnsdExpansion { .. } | StorageError::PoolTopologyMismatch { .. } => {
StorageErrorCode::InvalidArgument
}
StorageError::ConfigNotFound => StorageErrorCode::ConfigNotFound, StorageError::ConfigNotFound => StorageErrorCode::ConfigNotFound,
StorageError::TooManyOpenFiles => StorageErrorCode::TooManyOpenFiles, StorageError::TooManyOpenFiles => StorageErrorCode::TooManyOpenFiles,
StorageError::NoHealRequired => StorageErrorCode::NoHealRequired, StorageError::NoHealRequired => StorageErrorCode::NoHealRequired,
@@ -1319,29 +1215,6 @@ mod tests {
use super::*; use super::*;
use std::io::{Error as IoError, ErrorKind}; use std::io::{Error as IoError, ErrorKind};
#[test]
fn startup_topology_errors_preserve_identity_and_guidance() {
for error in [
StorageError::UnsupportedSnsdExpansion { configured_drives: 4 },
StorageError::PoolTopologyMismatch {
stored_drives: 4,
stored_set_drive_count: 4,
configured_drives: 8,
configured_set_drive_count: 8,
},
] {
let io_error: IoError = error.clone().into();
let restored = StorageError::from(io_error);
assert_eq!(std::mem::discriminant(&restored), std::mem::discriminant(&error));
assert_eq!(restored.to_string(), error.to_string());
assert_eq!(restored.code(), StorageErrorCode::InvalidArgument);
assert!(
restored.narrow_to_disk().is_err(),
"startup diagnostics must not become disk/quorum errors"
);
}
}
#[test] #[test]
fn other_preserves_erasure_construction_source_chain() { fn other_preserves_erasure_construction_source_chain() {
use crate::erasure::coding::ErasureConstructionError; use crate::erasure::coding::ErasureConstructionError;
+5 -156
View File
@@ -25,20 +25,6 @@ pub(crate) const MAX_ERASURE_SET_DRIVE_COUNT: usize = 16;
const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, MAX_ERASURE_SET_DRIVE_COUNT]; const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, MAX_ERASURE_SET_DRIVE_COUNT];
const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT"; const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT";
#[derive(Debug, thiserror::Error)]
enum PoolDriveCountError {
#[error(
"Incorrect number of endpoints provided, size {size}; an erasure pool requires at least {} drive endpoints on one or more nodes; for a standalone single-drive deployment, use a single local path without ellipses",
SET_SIZES[0]
)]
BelowMinimum { size: usize },
#[error(
"Incorrect number of endpoints provided, size {size}; {}={set_drive_count} requires at least {set_drive_count} drive endpoints per pool",
ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT
)]
BelowSetWidth { size: usize, set_drive_count: usize },
}
#[derive(Deserialize, Debug, Default)] #[derive(Deserialize, Debug, Default)]
pub struct PoolDisksLayout { pub struct PoolDisksLayout {
cmd_line: String, cmd_line: String,
@@ -146,7 +132,7 @@ impl DisksLayout {
for arg in args.iter() { for arg in args.iter() {
if !has_ellipses(&[arg]) && args.len() > 1 { if !has_ellipses(&[arg]) && args.len() > 1 {
return Err(Error::other( return Err(Error::other(
"all args must have ellipses for pool expansion (Invalid arguments specified); each pool must expand to at least 2 drive endpoints on one or more nodes; a single-drive pool cannot be added to a multi-pool deployment", "all args must have ellipses for pool expansion (Invalid arguments specified)",
)); ));
} }
@@ -410,11 +396,9 @@ fn get_set_indexes<T: AsRef<str>>(
} }
for &size in total_sizes { for &size in total_sizes {
if size < SET_SIZES[0] { // Check if total_sizes has minimum range upto set_size
return Err(Error::other(PoolDriveCountError::BelowMinimum { size })); if size < SET_SIZES[0] || size < set_drive_count {
} return Err(Error::other(format!("Incorrect number of endpoints provided, size {size}")));
if size < set_drive_count {
return Err(Error::other(PoolDriveCountError::BelowSetWidth { size, set_drive_count }));
} }
} }
@@ -723,7 +707,7 @@ mod test {
arg: "http://rustfs{2...3}/export/set{1...0}", arg: "http://rustfs{2...3}/export/set{1...0}",
..Default::default() ..Default::default()
}, },
// Ranges must use three dots. // Range cannot be smaller than 4 minimum.
TestCase { TestCase {
num: 4, num: 4,
arg: "/export{1..2}", arg: "/export{1..2}",
@@ -942,146 +926,11 @@ mod test {
} }
} }
#[test]
fn pool_expansion_accepts_single_node_multi_drive_pools() {
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
for (volumes, drives) in [
(["http://node1:9000/data{1...2}", "http://node2:9000/data{1...2}"], 2),
(["http://node1:9000/data{1...4}", "http://node2:9000/data{1...4}"], 4),
(["http://node{1...4}:9000/data", "http://node5:9000/data{1...4}"], 4),
(["http://node5:9000/data{1...4}", "http://node{1...4}:9000/data"], 4),
] {
let layout = DisksLayout::from_volumes(&volumes).expect("single-node multi-drive pools are valid");
assert!(!layout.legacy);
assert_eq!(layout.pools.len(), 2);
for (index, volume) in volumes.iter().enumerate() {
assert_eq!(layout.get_set_count(index), 1);
assert_eq!(layout.get_drives_per_set(index), drives);
assert_eq!(layout.get_cmd_line(index), *volume);
}
}
});
}
#[test]
fn pool_expansion_accepts_multi_node_single_drive_pools() {
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
for nodes in [2, 3, 4] {
let volumes = [
format!("http://pool1-node{{1...{nodes}}}:9000/data"),
format!("http://pool2-node{{1...{nodes}}}:9000/data"),
];
let layout = DisksLayout::from_volumes(&volumes).expect("each node may contribute one drive to a pool");
assert_eq!(layout.pools.len(), 2);
for pool in 0..2 {
assert_eq!(layout.get_set_count(pool), 1);
assert_eq!(layout.get_drives_per_set(pool), nodes);
let expected = (1..=nodes)
.map(|node| format!("http://pool{}-node{node}:9000/data", pool + 1))
.collect::<Vec<_>>();
assert_eq!(layout.pools[pool].layout, vec![expected]);
}
}
});
}
#[test]
fn explicit_endpoints_without_ellipses_form_one_pool() {
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
let volumes = ["http://node1:9000/data", "http://node2:9000/data"];
let layout = DisksLayout::from_volumes(&volumes).expect("explicit endpoints form one legacy pool");
assert!(layout.legacy);
assert_eq!(layout.pools.len(), 1);
assert_eq!(layout.pools[0].layout, vec![volumes.to_vec()]);
});
}
#[test]
fn standalone_single_drive_path_remains_supported() {
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
let layout = DisksLayout::from_volumes(&["/data"]).expect("standalone single-drive deployment is valid");
assert!(layout.is_single_drive_layout());
assert_eq!(layout.get_single_drive_layout(), "/data");
});
}
#[test]
fn pool_expansion_rejects_plain_single_drive_pool_with_notice() {
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
for volumes in [
["http://node{1...2}:9000/data", "http://node3:9000/data"],
["http://node3:9000/data", "http://node{1...2}:9000/data"],
] {
let err = DisksLayout::from_volumes(&volumes).expect_err("a plain endpoint cannot be an expansion pool");
let message = err.to_string();
assert!(message.contains("all args must have ellipses for pool expansion"), "{message}");
assert!(message.contains("at least 2 drive endpoints"), "{message}");
}
});
}
#[test]
fn pool_expansion_rejects_singleton_ellipsis_pool_with_notice() {
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
for singleton in ["http://node{3...3}:9000/data", "http://node3:9000/data{1...1}"] {
for volumes in [
vec!["http://node{1...2}:9000/data", singleton],
vec![singleton, "http://node{1...2}:9000/data"],
vec![singleton],
] {
let err = DisksLayout::from_volumes(&volumes).expect_err("a singleton range still contains one drive");
let message = err.to_string();
assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert!(matches!(
err.get_ref().and_then(|source| source.downcast_ref::<PoolDriveCountError>()),
Some(PoolDriveCountError::BelowMinimum { size: 1 })
));
assert!(message.contains("at least 2 drive endpoints"), "{message}");
assert!(message.contains("single local path without ellipses"), "{message}");
}
}
});
}
#[test]
fn explicit_set_size_counts_drives_not_nodes() {
for volume in ["http://node1:9000/data{1...4}", "http://node{1...4}:9000/data"] {
let sets = get_all_sets(2, true, &[volume]).expect("four endpoints can form two two-drive sets");
assert_eq!(sets.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 2]);
}
}
#[test]
fn undersized_pool_error_identifies_requested_set_size() {
let err =
get_all_sets(4, true, &["http://node{1...2}:9000/data"]).expect_err("two endpoints cannot fill a four-drive set");
let message = err.to_string();
assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert!(matches!(
err.get_ref().and_then(|source| source.downcast_ref::<PoolDriveCountError>()),
Some(PoolDriveCountError::BelowSetWidth {
size: 2,
set_drive_count: 4
})
));
assert!(message.contains("size 2"), "{message}");
assert!(message.contains("RUSTFS_ERASURE_SET_DRIVE_COUNT=4"), "{message}");
}
#[test] #[test]
fn layout_errors_do_not_echo_url_credentials() { fn layout_errors_do_not_echo_url_credentials() {
for volumes in [ for volumes in [
vec!["http://:duplicate-secret@server/path", "http://:duplicate-secret@server/path"], vec!["http://:duplicate-secret@server/path", "http://:duplicate-secret@server/path"],
vec!["http://:ellipsis...secret@server/path"], vec!["http://:ellipsis...secret@server/path"],
vec!["http://server{1...2}/data", "http://:plain-secret@server3/data"],
vec!["http://server{1...2}/data", "http://:singleton-secret@server{3...3}/data"],
] { ] {
let err = DisksLayout::from_volumes(&volumes).unwrap_err(); let err = DisksLayout::from_volumes(&volumes).unwrap_err();
assert!(!err.to_string().contains("secret"), "layout error leaked endpoint credentials: {err}"); assert!(!err.to_string().contains("secret"), "layout error leaked endpoint credentials: {err}");
-35
View File
@@ -2432,41 +2432,6 @@ mod test {
assert_eq!(local_endpoints[0].pool_idx, 1); assert_eq!(local_endpoints[0].pool_idx, 1);
} }
#[tokio::test]
async fn pool_expansion_resolves_single_node_multi_drive_and_multi_node_single_drive_pools() {
for (additional_pool, expected_nodes) in [
("http://rustfs-5.example.invalid:9000/data{1...4}", 5),
("http://rustfs-{5...8}.example.invalid:9000/data", 8),
] {
let layout = temp_env::with_var("RUSTFS_ERASURE_SET_DRIVE_COUNT", Some("0"), || {
DisksLayout::from_volumes(&["http://rustfs-{1...4}.example.invalid:9000/data", additional_pool])
})
.expect("both single-node multi-drive and multi-node single-drive pools should parse");
let (pools, setup_type) = EndpointServerPools::create_server_endpoints_with(
"0.0.0.0:9000",
&layout,
Some(orchestrated_test_policy()),
Some("rustfs-1.example.invalid"),
)
.await
.expect("pool admission must not impose a minimum node count or drives per node");
assert_eq!(setup_type, SetupType::DistErasure);
assert_eq!(pools.0.len(), 2);
assert_eq!(pools.get_nodes().len(), expected_nodes);
for (pool_index, pool) in (0_i32..).zip(&pools.0) {
assert_eq!((pool.set_count, pool.drives_per_set), (1, 4));
assert_eq!(pool.endpoints.as_ref().len(), 4);
for (disk_index, endpoint) in (0_i32..).zip(pool.endpoints.as_ref()) {
assert_eq!(endpoint.pool_idx, pool_index);
assert_eq!(endpoint.set_idx, 0);
assert_eq!(endpoint.disk_idx, disk_index);
}
}
}
}
#[tokio::test] #[tokio::test]
async fn explicit_local_endpoint_host_fails_closed_for_invalid_context_or_zero_match() { async fn explicit_local_endpoint_host_fails_closed_for_invalid_context_or_zero_match() {
let args = vec![ let args = vec![
-2
View File
@@ -55,8 +55,6 @@ mod set_disk;
mod storage_api_contracts; mod storage_api_contracts;
mod store; mod store;
pub use store::PoolMetaWriteGateStatus;
// pub mod checksum; // pub mod checksum;
mod event; mod event;
+26 -36
View File
@@ -75,7 +75,6 @@ pub(crate) const SCANNER_PUBLICATION_LEASE_TTL: std::time::Duration = std::time:
pub(crate) struct ScannerPublicationLeaseEntry { pub(crate) struct ScannerPublicationLeaseEntry {
pub(crate) expires_at: Instant, pub(crate) expires_at: Instant,
pub(crate) movement_generation: u64, pub(crate) movement_generation: u64,
pub(crate) namespace_generation: u64,
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>, pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
} }
@@ -306,7 +305,6 @@ impl InstanceContext {
token: Uuid, token: Uuid,
expires_at: Instant, expires_at: Instant,
movement_generation: u64, movement_generation: u64,
namespace_generation: u64,
operation_guard: OwnedRwLockReadGuard<()>, operation_guard: OwnedRwLockReadGuard<()>,
) -> bool { ) -> bool {
let mut leases = self.scanner_publication_leases.lock().await; let mut leases = self.scanner_publication_leases.lock().await;
@@ -318,7 +316,6 @@ impl InstanceContext {
ScannerPublicationLeaseEntry { ScannerPublicationLeaseEntry {
expires_at, expires_at,
movement_generation, movement_generation,
namespace_generation,
_operation_guard: operation_guard, _operation_guard: operation_guard,
}, },
); );
@@ -329,21 +326,39 @@ impl InstanceContext {
self.scanner_publication_leases.lock().await.remove(&token).is_some() self.scanner_publication_leases.lock().await.remove(&token).is_some()
} }
/// Return both generations from the same live lease while the caller holds /// Check a lease token while the caller holds the movement read guard.
/// the movement read guard. Namespace commits do not take that guard, so ///
/// the caller must compare the saved namespace generation after this await. /// The token table is deliberately process-owned and non-persistent: a
/// The process-owned table rejects tokens from a prior instance or expiry. /// restarted instance has no entries from the previous process, so an old
pub(crate) async fn scanner_publication_lease_generations(&self, token: Uuid) -> Option<(u64, u64)> { /// coordinator proof cannot become valid again merely because the
/// movement generation counter restarted at zero.
pub(crate) async fn scanner_publication_lease_is_active(&self, token: Uuid) -> bool {
let mut leases = self.scanner_publication_leases.lock().await; let mut leases = self.scanner_publication_leases.lock().await;
let now = Instant::now(); let now = Instant::now();
let (expires_at, movement_generation, namespace_generation) = leases let Some(expires_at) = leases.get(&token).map(|entry| entry.expires_at) else {
return false;
};
if expires_at <= now {
leases.remove(&token);
return false;
}
true
}
/// Return the generation bound to a live lease. The lease entry owns the
/// movement read guard, so a successful lookup remains valid for the
/// caller's guard-protected operation; expiry is still fail-closed.
pub(crate) async fn scanner_publication_lease_generation(&self, token: Uuid) -> Option<u64> {
let mut leases = self.scanner_publication_leases.lock().await;
let now = Instant::now();
let (expires_at, movement_generation) = leases
.get(&token) .get(&token)
.map(|entry| (entry.expires_at, entry.movement_generation, entry.namespace_generation))?; .map(|entry| (entry.expires_at, entry.movement_generation))?;
if expires_at <= now { if expires_at <= now {
leases.remove(&token); leases.remove(&token);
return None; return None;
} }
Some((movement_generation, namespace_generation)) Some(movement_generation)
} }
pub(crate) async fn expire_scanner_publication_lease(&self, token: Uuid, expires_at: Instant) { pub(crate) async fn expire_scanner_publication_lease(&self, token: Uuid, expires_at: Instant) {
@@ -501,11 +516,6 @@ impl InstanceContext {
.store(SCANNER_PUBLICATION_STATE_UNKNOWN, Ordering::Release); .store(SCANNER_PUBLICATION_STATE_UNKNOWN, Ordering::Release);
} }
#[cfg(test)]
pub(crate) fn set_namespace_commit_generation_for_test(&self, generation: u64) {
self.namespace_commit_generation.store(generation, Ordering::Release);
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn set_data_movement_generation_for_test(&self, generation: u64) { pub(crate) fn set_data_movement_generation_for_test(&self, generation: u64) {
self.data_movement_generation.store(generation, Ordering::Release); self.data_movement_generation.store(generation, Ordering::Release);
@@ -852,26 +862,6 @@ mod tests {
} }
} }
#[tokio::test(start_paused = true)]
async fn scanner_lease_generations_remain_bound_until_expiry() {
let ctx = Arc::new(InstanceContext::new());
let token = Uuid::new_v4();
let gate = ctx.data_movement_operation_gate();
let permit = gate.clone().read_owned().await;
assert!(
ctx.install_scanner_publication_lease(token, Instant::now() + SCANNER_PUBLICATION_LEASE_TTL, 7, 11, permit)
.await
);
drop(ctx.begin_namespace_commit());
assert_eq!(ctx.namespace_commit_generation(), 2);
assert_eq!(ctx.scanner_publication_lease_generations(token).await, Some((7, 11)));
assert!(gate.clone().try_write_owned().is_err(), "lookup must retain the stored permit");
tokio::time::advance(SCANNER_PUBLICATION_LEASE_TTL).await;
assert_eq!(ctx.scanner_publication_lease_generations(token).await, None);
assert!(!ctx.remove_scanner_publication_lease(token).await);
assert!(gate.try_write_owned().is_ok(), "expiry releases the stored permit");
}
// The SetupType inputs must derive the exact (is_erasure, // The SetupType inputs must derive the exact (is_erasure,
// is_dist_erasure, is_erasure_sd) triples that the original three // is_dist_erasure, is_erasure_sd) triples that the original three
// process-global erasure bools produced via update_erasure_type(). // process-global erasure bools produced via update_erasure_type().
+2 -16
View File
@@ -570,25 +570,11 @@ pub(crate) async fn initialize_local_disk_maps(
Ok(()) Ok(())
} }
#[cfg(test)]
tokio::task_local! {
pub(crate) static TEST_TIER_CONFIG_MGR: Arc<tokio::sync::RwLock<TierConfigMgr>>;
}
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> { pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
#[cfg(not(test))]
let handle = get_global_tier_config_mgr(); let handle = get_global_tier_config_mgr();
#[cfg(test)] TierConfigMgr::reload_handle(&handle, store.clone()).await?;
let handle = TEST_TIER_CONFIG_MGR
.try_with(Arc::clone)
.unwrap_or_else(|_| get_global_tier_config_mgr());
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
if initial_reload.is_err() {
// Keep local recovery active when the initial snapshot cannot be loaded.
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
}
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store)); tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
initial_reload.map_err(Error::from) Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -20,10 +20,9 @@ use chrono::Utc;
use jiff::Timestamp; use jiff::Timestamp;
use rustfs_heal_contracts::heal_channel::DriveState; use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_io_metrics::s3_http_metrics::s3_http_metrics_snapshot;
use rustfs_madmin::metrics::{ use rustfs_madmin::metrics::{
DiskIOStats, DiskMetric, HttpMetrics, HttpRequestMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics,
RealtimeMetrics, ScannerCheckpointReport as MadminScannerCheckpointReport, ScannerCheckpointReport as MadminScannerCheckpointReport,
ScannerLifecycleExpirySnapshot as MadminScannerLifecycleExpirySnapshot, ScannerLifecycleExpirySnapshot as MadminScannerLifecycleExpirySnapshot,
ScannerLifecycleTransitionSnapshot as MadminScannerLifecycleTransitionSnapshot, ScannerLifecycleTransitionSnapshot as MadminScannerLifecycleTransitionSnapshot,
ScannerMaintenanceControlSnapshot as MadminScannerMaintenanceControlSnapshot, ScannerMaintenanceControlSnapshot as MadminScannerMaintenanceControlSnapshot,
@@ -62,10 +61,9 @@ impl MetricType {
pub const MEM: MetricType = MetricType(1 << 6); pub const MEM: MetricType = MetricType(1 << 6);
pub const CPU: MetricType = MetricType(1 << 7); pub const CPU: MetricType = MetricType(1 << 7);
pub const RPC: MetricType = MetricType(1 << 8); pub const RPC: MetricType = MetricType(1 << 8);
pub const HTTP: MetricType = MetricType(1 << 9);
// MetricsAll must be last. // MetricsAll must be last.
pub const ALL: MetricType = MetricType((1 << 10) - 1); pub const ALL: MetricType = MetricType((1 << 9) - 1);
pub fn new(t: u32) -> Self { pub fn new(t: u32) -> Self {
Self(t) Self(t)
@@ -412,21 +410,6 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
by_host_name = local_node_name; by_host_name = local_node_name;
} }
if types.contains(&MetricType::HTTP) {
real_time_metrics.aggregated.http = Some(HttpMetrics {
collected_at: Timestamp::now(),
requests: s3_http_metrics_snapshot()
.into_iter()
.map(|series| HttpRequestMetric {
method: series.method.to_string(),
operation: series.operation.to_string(),
outcome: series.outcome.to_string(),
total: series.total,
})
.collect(),
});
}
if types.contains(&MetricType::DISK) { if types.contains(&MetricType::DISK) {
debug!("start get disk metrics"); debug!("start get disk metrics");
let mut aggr = DiskMetric { let mut aggr = DiskMetric {
@@ -602,47 +585,11 @@ mod test {
assert!(t.contains(&MetricType::MEM)); assert!(t.contains(&MetricType::MEM));
assert!(t.contains(&MetricType::CPU)); assert!(t.contains(&MetricType::CPU));
assert!(t.contains(&MetricType::RPC)); assert!(t.contains(&MetricType::RPC));
assert!(t.contains(&MetricType::HTTP));
let disk = MetricType::new(1 << 1); let disk = MetricType::new(1 << 1);
assert!(disk.contains(&MetricType::DISK)); assert!(disk.contains(&MetricType::DISK));
} }
#[tokio::test]
async fn collect_local_metrics_reports_the_same_http_outcome_counters() {
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("PUT");
request.response(503);
drop(request);
let snapshot = s3_http_metrics_snapshot();
let realtime = collect_local_metrics(MetricType::HTTP, &CollectMetricsOpts::default()).await;
let http = realtime.aggregated.http.as_ref().expect("HTTP selection must report support");
assert_eq!(http.requests.len(), snapshot.len());
for (actual, expected) in http.requests.iter().zip(&snapshot) {
assert_eq!(actual.method, expected.method);
assert_eq!(actual.operation, expected.operation);
assert_eq!(actual.outcome, expected.outcome);
assert_eq!(actual.total, expected.total);
}
assert_eq!(realtime.by_host.len(), 1);
assert_eq!(
realtime
.by_host
.values()
.next()
.expect("local host")
.http
.as_ref()
.expect("host HTTP")
.requests,
http.requests
);
let encoded = rmp_serde::to_vec_named(&realtime).expect("RPC metric map");
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("RPC metric roundtrip");
assert_eq!(decoded.aggregated.http.expect("HTTP field survives RPC").requests, http.requests);
let excluded = collect_local_metrics(MetricType::NET, &CollectMetricsOpts::default()).await;
assert!(excluded.aggregated.http.is_none());
}
#[tokio::test] #[tokio::test]
async fn collect_local_metrics_reports_internode_net_and_rpc() { async fn collect_local_metrics_reports_internode_net_and_rpc() {
let metrics = global_internode_metrics(); let metrics = global_internode_metrics();
+72 -240
View File
@@ -31,7 +31,7 @@ use lazy_static::lazy_static;
use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices}; use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices};
use rustfs_madmin::metrics::RealtimeMetrics; use rustfs_madmin::metrics::RealtimeMetrics;
use rustfs_madmin::net::NetInfo; use rustfs_madmin::net::NetInfo;
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo, StorageInfoObservation, StorageInfoProbeStatus}; use rustfs_madmin::{ItemState, ServerProperties, StorageInfo};
use rustfs_utils::XHost; use rustfs_utils::XHost;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap, hash_map::DefaultHasher}; use std::collections::{BTreeMap, BTreeSet, HashMap, hash_map::DefaultHasher};
@@ -53,7 +53,6 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification"; const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation"; const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe"; const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
const EVENT_STORAGE_INFO_PROBE: &str = "storage_info_probe";
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const TIER_DAILY_STATS_PROBE_TIMEOUT: Duration = Duration::from_secs(5); const TIER_DAILY_STATS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100); const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
@@ -141,8 +140,6 @@ pub struct ScannerPublicationLeaseGrant {
/// Cached result from the last successful admin call to a peer. /// Cached result from the last successful admin call to a peer.
struct PeerAdminCache { struct PeerAdminCache {
last_storage_info: Option<StorageInfo>, last_storage_info: Option<StorageInfo>,
/// Wall time is for operators; the monotonic clock bounds cache reuse.
last_storage_success: Option<(SystemTime, Instant)>,
last_server_info: Option<ServerProperties>, last_server_info: Option<ServerProperties>,
storage_failures: u32, storage_failures: u32,
server_failures: u32, server_failures: u32,
@@ -166,7 +163,6 @@ impl PeerAdminCache {
fn new() -> Self { fn new() -> Self {
Self { Self {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: None, last_server_info: None,
storage_failures: 0, storage_failures: 0,
server_failures: 0, server_failures: 0,
@@ -179,9 +175,6 @@ impl PeerAdminCache {
/// failure: rather than reporting a stale `online`, the member falls through to /// failure: rather than reporting a stale `online`, the member falls through to
/// the live unknown/degraded/offline classification (rustfs/backlog#1049 P2). /// the live unknown/degraded/offline classification (rustfs/backlog#1049 P2).
const SERVER_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60); const SERVER_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60);
// Diagnostic inventory may bridge a short probe interruption, but never more
// than one minute. Failed probes are marked unknown even within this budget.
const STORAGE_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60);
lazy_static! { lazy_static! {
pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock<Arc<NotificationSys>> = OnceLock::new(); pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock<Arc<NotificationSys>> = OnceLock::new();
@@ -1913,7 +1906,6 @@ impl NotificationSys {
for (idx, client) in self.peer_clients.iter().enumerate() { for (idx, client) in self.peer_clients.iter().enumerate() {
let endpoints = endpoints.clone(); let endpoints = endpoints.clone();
let cache = self.peer_admin_caches.get(idx); let cache = self.peer_admin_caches.get(idx);
let topology_host = self.peer_topology_hosts.get(idx);
futures.push(async move { futures.push(async move {
if let Some(client) = client { if let Some(client) = client {
let host = client.host.to_string(); let host = client.host.to_string();
@@ -1924,46 +1916,32 @@ impl NotificationSys {
normalize_and_cache_peer_storage_info(cache, &host, &mut info); normalize_and_cache_peer_storage_info(cache, &host, &mut info);
Some(info) Some(info)
} }
Ok(Err(err)) => handle_peer_failure(cache, &host, &endpoints, &err), Ok(Err(err)) => {
Err(_) => handle_peer_failure(cache, &host, &endpoints, &Error::Timeout), warn!("peer {} storage_info failed: {}", host, err);
handle_peer_failure(cache, &host, &endpoints)
}
Err(_) => {
warn!("peer {} storage_info timed out after {:?}", host, peer_timeout);
handle_peer_failure(cache, &host, &endpoints)
}
} }
} else { } else {
topology_host.and_then(|host| { None
handle_peer_failure(
cache,
host,
&endpoints,
&Error::RemoteClientUnavailable("storage inventory client is unavailable".to_string()),
)
})
} }
}); });
} }
let mut replies = join_all(futures).await; let mut replies = join_all(futures).await;
let mut local = StorageAdminApi::local_storage_info(api).await; replies.push(Some(StorageAdminApi::local_storage_info(api).await));
local.observations = vec![storage_info_observation(
&runtime_sources::local_node_name().await,
StorageInfoProbeStatus::Succeeded,
false,
Some((SystemTime::now(), Instant::now())),
)];
replies.push(Some(local));
let mut disks = Vec::new(); let mut disks = Vec::new();
let mut observations = Vec::new();
for info in replies.into_iter().flatten() { for info in replies.into_iter().flatten() {
disks.extend(info.disks); disks.extend(info.disks);
observations.extend(info.observations);
} }
let backend = StorageAdminApi::backend_info(api).await; let backend = StorageAdminApi::backend_info(api).await;
rustfs_madmin::StorageInfo { rustfs_madmin::StorageInfo { disks, backend }
disks,
backend,
observations,
}
} }
pub async fn server_info(&self) -> Vec<ServerProperties> { pub async fn server_info(&self) -> Vec<ServerProperties> {
@@ -3361,80 +3339,56 @@ where
} }
} }
fn storage_info_observation( /// Handle a peer failure for storage_info: return cached data if available,
host: &str, /// or mark offline only after consecutive failures exceed the threshold.
status: StorageInfoProbeStatus,
cached: bool,
last_success: Option<(SystemTime, Instant)>,
) -> StorageInfoObservation {
StorageInfoObservation {
endpoint: host.to_string(),
status,
cached,
last_success_unix_millis: last_success
.and_then(|(wall, _)| wall.duration_since(SystemTime::UNIX_EPOCH).ok())
.and_then(|age| u64::try_from(age.as_millis()).ok()),
snapshot_age_seconds: last_success.map(|(_, monotonic)| monotonic.elapsed().as_secs()),
error_code: None,
}
}
/// An admin RPC failure is missing evidence, not evidence of failed drives.
/// Preserve bounded historical inventory without presenting its states as live.
fn handle_peer_failure( fn handle_peer_failure(
cache: Option<&Mutex<PeerAdminCache>>, cache: Option<&Mutex<PeerAdminCache>>,
host: &str, host: &str,
endpoints: &EndpointServerPools, endpoints: &EndpointServerPools,
error: &Error,
) -> Option<StorageInfo> { ) -> Option<StorageInfo> {
let mut cache = cache.map(|cache| cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner())); let cache = cache?;
let last_success = cache.as_ref().and_then(|cache| cache.last_storage_success);
let historical = cache let mut c = match cache.lock() {
.as_ref() Ok(cache) => cache,
.filter(|_| last_success.is_some_and(|(_, when)| when.elapsed() < STORAGE_INFO_CACHE_MAX_AGE)) Err(poisoned) => {
.and_then(|cache| cache.last_storage_info.clone()); warn!("peer {host} storage_info cache mutex poisoned");
let cached = historical.is_some(); poisoned.into_inner()
let mut info = historical.unwrap_or_else(|| StorageInfo { }
disks: synthesized_disks(host, endpoints, ItemState::Unknown), };
..Default::default() c.storage_failures += 1;
});
if let Some(cache) = &mut cache { if let Some(ref cached) = c.last_storage_info
cache.storage_failures = cache.storage_failures.saturating_add(1); && c.storage_failures < CONSECUTIVE_FAILURE_THRESHOLD
if cache.storage_failures == 1 { {
debug!(
event = "peer_probe_failure",
peer = host,
probe = "storage_info",
consecutive_failures = c.storage_failures,
threshold = CONSECUTIVE_FAILURE_THRESHOLD,
"peer storage_info probe failed; returning cached state until the offline threshold is reached"
);
return Some(cached.clone());
}
if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD {
if c.storage_failures == CONSECUTIVE_FAILURE_THRESHOLD {
warn!( warn!(
event = EVENT_STORAGE_INFO_PROBE, event = "peer_marked_offline",
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
state = "failed",
peer = host, peer = host,
error_code = ?error.code(), probe = "storage_info",
cached, consecutive_failures = c.storage_failures,
"Storage inventory probe failed; current drive health is unknown" threshold = CONSECUTIVE_FAILURE_THRESHOLD,
"reporting peer disks offline after consecutive storage_info failures"
); );
} }
return Some(StorageInfo {
disks: synthesized_disks(host, endpoints, ItemState::Offline),
..Default::default()
});
} }
for disk in &mut info.disks {
disk.state = rustfs_madmin::ITEM_UNKNOWN.to_string(); None
disk.runtime_state = Some(rustfs_madmin::ITEM_UNKNOWN.to_string());
disk.offline_duration_seconds = None;
disk.capacity_observation_source = Some(if cached { "snapshot" } else { "missing" }.to_string());
disk.capacity_observation_age_seconds = if cached {
disk.capacity_observation_age_seconds
.zip(last_success)
.map(|(age, (_, when))| age.saturating_add(when.elapsed().as_secs()))
} else {
None
};
disk.local = false;
}
info.observations = vec![storage_info_observation(
host,
StorageInfoProbeStatus::Failed,
cached,
last_success,
)];
info.observations[0].error_code = Some(format!("{:?}", error.code()));
Some(info)
} }
fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>, host: &str, info: &mut StorageInfo) { fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>, host: &str, info: &mut StorageInfo) {
@@ -3443,15 +3397,6 @@ fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>,
for disk in &mut info.disks { for disk in &mut info.disks {
disk.local = false; disk.local = false;
} }
let last_success = (SystemTime::now(), Instant::now());
// The aggregator owns probe provenance, including when an older peer
// returns no observation or a peer sends its own observation fields.
info.observations = vec![storage_info_observation(
host,
StorageInfoProbeStatus::Succeeded,
false,
Some(last_success),
)];
let Some(cache) = cache else { let Some(cache) = cache else {
return; return;
@@ -3464,20 +3409,16 @@ fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>,
poisoned.into_inner() poisoned.into_inner()
} }
}; };
if c.storage_failures > 0 { if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD {
info!( info!(
event = EVENT_STORAGE_INFO_PROBE, event = "peer_recovered_online",
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
state = "succeeded",
peer = host, peer = host,
probe = "storage_info", probe = "storage_info",
consecutive_failures = c.storage_failures, consecutive_failures = c.storage_failures,
"Storage inventory probe recovered" "peer storage_info probe succeeded again; peer disks reported online"
); );
} }
c.last_storage_info = Some(info.clone()); c.last_storage_info = Some(info.clone());
c.last_storage_success = Some(last_success);
c.storage_failures = 0; c.storage_failures = 0;
} }
@@ -4951,7 +4892,6 @@ mod tests {
server_failures: 1, server_failures: 1,
storage_failures: 0, storage_failures: 0,
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
}); });
let cache_b = Mutex::new(PeerAdminCache { let cache_b = Mutex::new(PeerAdminCache {
last_server_info: Some(build_props("cached-b")), last_server_info: Some(build_props("cached-b")),
@@ -4959,7 +4899,6 @@ mod tests {
server_failures: 1, server_failures: 1,
storage_failures: 0, storage_failures: 0,
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
}); });
let caches = [cache_a, cache_b]; let caches = [cache_a, cache_b];
let endpoints = EndpointServerPools::from(Vec::new()); let endpoints = EndpointServerPools::from(Vec::new());
@@ -5358,78 +5297,13 @@ mod tests {
// --- Tests for handle_peer_failure / handle_server_info_failure caching --- // --- Tests for handle_peer_failure / handle_server_info_failure caching ---
#[tokio::test]
async fn storage_info_preserves_failed_members_when_no_rpc_client_exists() {
#[derive(Debug)]
struct LocalInventory;
#[async_trait::async_trait]
impl StorageAdminApi for LocalInventory {
type BackendInfo = rustfs_madmin::BackendInfo;
type StorageInfo = StorageInfo;
type Disk = ();
type Error = Error;
async fn backend_info(&self) -> Self::BackendInfo {
Self::BackendInfo::default()
}
async fn storage_info(&self) -> StorageInfo {
panic!("aggregation must query local inventory only")
}
async fn local_storage_info(&self) -> StorageInfo {
StorageInfo::default()
}
async fn disk_set_inventory(
&self,
_: crate::storage_api_contracts::admin::DiskSetSelector,
) -> Result<Vec<Option<Self::Disk>>> {
panic!("admin probe must not access the data plane")
}
fn set_drive_counts(&self) -> Vec<usize> {
Vec::new()
}
}
let sys = NotificationSys {
peer_clients: vec![None],
all_peer_clients: vec![None, None],
peer_topology_hosts: vec!["peer-unavailable".to_string()],
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
tier_config_reload_workers: Default::default(),
};
let info = sys.storage_info(&LocalInventory).await;
let peer = info
.observations
.iter()
.find(|observation| observation.endpoint == "peer-unavailable")
.expect("failed topology member remains visible");
assert_eq!(peer.status, StorageInfoProbeStatus::Failed);
assert!(!peer.cached);
assert_eq!(peer.error_code.as_deref(), Some("RemoteClientUnavailable"));
assert!(
info.observations
.iter()
.any(|observation| observation.status == StorageInfoProbeStatus::Succeeded)
);
}
#[test] #[test]
fn handle_peer_failure_first_failure_reports_unknown_inventory_without_cache() { fn handle_peer_failure_first_failure_returns_none_when_no_cache() {
let cache = Mutex::new(PeerAdminCache::new()); let cache = Mutex::new(PeerAdminCache::new());
let endpoints = EndpointServerPools::default(); let endpoints = EndpointServerPools::default();
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout); let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
let info = result.expect("failed peer must remain visible without cached disks"); assert!(result.is_none());
assert!(info.disks.is_empty());
assert_eq!(info.observations[0].status, StorageInfoProbeStatus::Failed);
assert!(!info.observations[0].cached);
assert_eq!(info.observations[0].last_success_unix_millis, None);
assert_eq!(info.observations[0].snapshot_age_seconds, None);
assert_eq!(info.observations[0].error_code.as_deref(), Some("Timeout"));
assert_eq!(cache.lock().unwrap().storage_failures, 1); assert_eq!(cache.lock().unwrap().storage_failures, 1);
} }
@@ -5446,7 +5320,6 @@ mod tests {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: Some(cached_info), last_storage_info: Some(cached_info),
last_storage_success: Some((SystemTime::now(), Instant::now())),
last_server_info: None, last_server_info: None,
storage_failures: 0, storage_failures: 0,
server_failures: 0, server_failures: 0,
@@ -5454,17 +5327,11 @@ mod tests {
}); });
let endpoints = EndpointServerPools::default(); let endpoints = EndpointServerPools::default();
// Historical inventory is available, but its health is not live. // First failure: should return cached data
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout); let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
let info = result.unwrap(); let info = result.unwrap();
assert_eq!(info.disks.len(), 1); assert_eq!(info.disks.len(), 1);
assert_eq!(info.disks[0].state, "unknown"); assert_eq!(info.disks[0].state, "ok");
assert_eq!(info.disks[0].runtime_state.as_deref(), Some("unknown"));
assert_eq!(info.disks[0].capacity_observation_source.as_deref(), Some("snapshot"));
assert_eq!(info.disks[0].capacity_observation_age_seconds, None);
assert!(info.observations[0].cached);
assert_eq!(info.observations[0].status, StorageInfoProbeStatus::Failed);
assert!(info.observations[0].last_success_unix_millis.is_some());
assert_eq!(cache.lock().unwrap().storage_failures, 1); assert_eq!(cache.lock().unwrap().storage_failures, 1);
} }
@@ -5510,13 +5377,13 @@ mod tests {
); );
drop(cached); drop(cached);
let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default(), &Error::Timeout) let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default())
.expect("first peer failure must return the cached snapshot"); .expect("first peer failure must return the cached snapshot");
assert!(degraded.disks.iter().all(|disk| !disk.local)); assert!(degraded.disks.iter().all(|disk| !disk.local));
} }
#[test] #[test]
fn handle_peer_failure_cache_age_does_not_depend_on_poll_count() { fn handle_peer_failure_returns_offline_after_threshold_exceeded() {
let cached_info = StorageInfo { let cached_info = StorageInfo {
disks: vec![rustfs_madmin::Disk { disks: vec![rustfs_madmin::Disk {
endpoint: "disk-0".to_string(), endpoint: "disk-0".to_string(),
@@ -5528,7 +5395,6 @@ mod tests {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: Some(cached_info), last_storage_info: Some(cached_info),
last_storage_success: Some((SystemTime::now(), Instant::now())),
last_server_info: None, last_server_info: None,
storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
server_failures: 0, server_failures: 0,
@@ -5536,31 +5402,10 @@ mod tests {
}); });
let endpoints = EndpointServerPools::default(); let endpoints = EndpointServerPools::default();
for _ in 0..10 { // This failure pushes us to the threshold => offline
let info = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout).expect("failed probe"); let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
assert_eq!(info.disks.len(), 1); assert!(result.is_some());
assert_eq!(info.disks[0].state, "unknown"); assert_eq!(cache.lock().unwrap().storage_failures, CONSECUTIVE_FAILURE_THRESHOLD);
assert!(info.observations[0].cached);
}
cache.lock().expect("age cache").last_storage_success =
Some((SystemTime::now() - Duration::from_secs(61), Instant::now() - Duration::from_secs(61)));
let info = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout).expect("expired probe");
assert!(!info.observations[0].cached);
assert!(info.observations[0].snapshot_age_seconds.expect("known last success") >= 61);
assert!(info.disks.is_empty(), "expired inventory must not be reused");
let mut recovered = StorageInfo {
disks: vec![rustfs_madmin::Disk {
state: "ok".into(),
..Default::default()
}],
..Default::default()
};
normalize_and_cache_peer_storage_info(Some(&cache), "peer-1", &mut recovered);
assert_eq!(recovered.disks[0].state, "ok");
assert_eq!(recovered.observations[0].status, StorageInfoProbeStatus::Succeeded);
assert!(!recovered.observations[0].cached);
assert_eq!(cache.lock().expect("recovered cache").storage_failures, 0);
} }
#[test] #[test]
@@ -5573,7 +5418,6 @@ mod tests {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: Some(cached_props), last_server_info: Some(cached_props),
storage_failures: 0, storage_failures: 0,
server_failures: 0, server_failures: 0,
@@ -5604,7 +5448,6 @@ mod tests {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: Some(cached_props), last_server_info: Some(cached_props),
storage_failures: 0, storage_failures: 0,
server_failures: 0, server_failures: 0,
@@ -5732,7 +5575,6 @@ mod tests {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: Some(cached_props), last_server_info: Some(cached_props),
storage_failures: 0, storage_failures: 0,
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
@@ -5752,7 +5594,6 @@ mod tests {
// the real per-drive health), not offline (rustfs/backlog#1049 P0-B). // the real per-drive health), not offline (rustfs/backlog#1049 P0-B).
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: None, last_server_info: None,
storage_failures: 0, storage_failures: 0,
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
@@ -5781,7 +5622,6 @@ mod tests {
// this is a genuine offline, degraded must not mask it. // this is a genuine offline, degraded must not mask it.
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: None, last_server_info: None,
storage_failures: 0, storage_failures: 0,
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
@@ -5805,7 +5645,6 @@ mod tests {
fn success_resets_failure_counters_independently() { fn success_resets_failure_counters_independently() {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: None, last_server_info: None,
storage_failures: 2, storage_failures: 2,
server_failures: 2, server_failures: 2,
@@ -5827,7 +5666,6 @@ mod tests {
fn storage_failures_do_not_affect_server_failures() { fn storage_failures_do_not_affect_server_failures() {
let cache = Mutex::new(PeerAdminCache { let cache = Mutex::new(PeerAdminCache {
last_storage_info: Some(StorageInfo::default()), last_storage_info: Some(StorageInfo::default()),
last_storage_success: None,
last_server_info: Some(ServerProperties { last_server_info: Some(ServerProperties {
endpoint: "peer-1".to_string(), endpoint: "peer-1".to_string(),
state: "online".to_string(), state: "online".to_string(),
@@ -5839,7 +5677,7 @@ mod tests {
}); });
let endpoints = EndpointServerPools::default(); let endpoints = EndpointServerPools::default();
let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout); let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
assert!(storage_result.is_some()); assert!(storage_result.is_some());
let server_result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None); let server_result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None);
@@ -5862,10 +5700,8 @@ mod tests {
panic!("poison server cache mutex"); panic!("poison server cache mutex");
}); });
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints, &Error::Timeout); let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints);
let storage = storage_result.expect("poisoned cache must still report the failed peer"); assert!(storage_result.is_none());
assert_eq!(storage.observations[0].status, StorageInfoProbeStatus::Failed);
assert!(!storage.observations[0].cached);
let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None); let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None);
assert_eq!(server_result.endpoint, "peer-1"); assert_eq!(server_result.endpoint, "peer-1");
@@ -5876,7 +5712,6 @@ mod tests {
fn poisoned_admin_cache_recovers_on_success_and_resets_failures() { fn poisoned_admin_cache_recovers_on_success_and_resets_failures() {
let storage_cache = Mutex::new(PeerAdminCache { let storage_cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: None, last_server_info: None,
storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
server_failures: 0, server_failures: 0,
@@ -5884,7 +5719,6 @@ mod tests {
}); });
let server_cache = Mutex::new(PeerAdminCache { let server_cache = Mutex::new(PeerAdminCache {
last_storage_info: None, last_storage_info: None,
last_storage_success: None,
last_server_info: None, last_server_info: None,
storage_failures: 0, storage_failures: 0,
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1, server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
@@ -5923,11 +5757,9 @@ mod tests {
}, },
); );
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints, &Error::Timeout); let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints);
assert!(storage_result.is_some()); assert!(storage_result.is_some());
let storage = storage_result.expect("failed probe after recovery"); assert_eq!(storage_result.unwrap().disks[0].state, "ok");
assert_eq!(storage.disks[0].state, "unknown");
assert!(storage.observations[0].cached);
let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None); let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None);
assert_eq!(server_result.state, "online"); assert_eq!(server_result.state, "online");
@@ -300,13 +300,11 @@ pub(super) fn ensure_rebalance_worker_active(meta: Option<&RebalanceMeta>, expec
let Some(meta) = meta else { let Some(meta) = meta else {
return Err(rebalance_metadata_not_initialized_error(stage)); return Err(rebalance_metadata_not_initialized_error(stage));
}; };
if meta.stopped_at.is_some() || meta.stop_requested { if meta.stopped_at.is_some()
return Err(Error::OperationCanceled); || meta
} .cancel
if meta .as_ref()
.cancel .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
.as_ref()
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
|| !is_rebalance_conflicting_with_decommission(meta) || !is_rebalance_conflicting_with_decommission(meta)
{ {
return Err(Error::other(format!("inactive rebalance worker rejected during {stage}: {expected_id}"))); return Err(Error::other(format!("inactive rebalance worker rejected during {stage}: {expected_id}")));
@@ -631,7 +629,6 @@ impl ECStore {
if let Some(meta) = rebalance_meta.as_mut() if let Some(meta) = rebalance_meta.as_mut()
&& is_rebalance_conflicting_with_decommission(meta) && is_rebalance_conflicting_with_decommission(meta)
{ {
meta.stop_requested = true;
meta.cancel meta.cancel
.get_or_insert_with(tokio_util::sync::CancellationToken::new) .get_or_insert_with(tokio_util::sync::CancellationToken::new)
.cancel(); .cancel();
@@ -646,13 +643,12 @@ impl ECStore {
let Some(meta) = rebalance_meta.as_mut() else { let Some(meta) = rebalance_meta.as_mut() else {
return Ok(None); return Ok(None);
}; };
if meta.stopped_at.is_some() || (!is_rebalance_conflicting_with_decommission(meta) && !meta.stop_requested) { if !is_rebalance_conflicting_with_decommission(meta) {
return Ok(None); return Ok(None);
} }
if meta.id.is_empty() { if meta.id.is_empty() {
return Err(Error::other("active rebalance metadata has no activation id")); return Err(Error::other("active rebalance metadata has no activation id"));
} }
meta.stop_requested = true;
meta.cancel meta.cancel
.get_or_insert_with(tokio_util::sync::CancellationToken::new) .get_or_insert_with(tokio_util::sync::CancellationToken::new)
.cancel(); .cancel();
@@ -677,13 +673,7 @@ impl ECStore {
let movement_changed = rebalance_movement_snapshot_changed(self.rebalance_meta.read().await.as_ref(), &meta); let movement_changed = rebalance_movement_snapshot_changed(self.rebalance_meta.read().await.as_ref(), &meta);
{ {
let mut rebalance_meta = self.rebalance_meta.write().await; let mut rebalance_meta = self.rebalance_meta.write().await;
if let Some(current) = rebalance_meta.as_ref()
&& current.id == meta.id
{
meta.cancel = current.cancel.clone();
meta.activation_gate = Arc::clone(&current.activation_gate);
meta.stop_requested = current.stop_requested;
}
*rebalance_meta = Some(meta); *rebalance_meta = Some(meta);
drop(rebalance_meta); drop(rebalance_meta);
@@ -1198,12 +1188,11 @@ impl ECStore {
let meta = rebalance_meta let meta = rebalance_meta
.as_mut() .as_mut()
.ok_or_else(|| rebalance_metadata_not_initialized_error("cancel rebalance admission"))?; .ok_or_else(|| rebalance_metadata_not_initialized_error("cancel rebalance admission"))?;
if meta.stopped_at.is_some() || (!is_rebalance_conflicting_with_decommission(meta) && !meta.stop_requested) { if meta.stopped_at.is_some() || !is_rebalance_conflicting_with_decommission(meta) {
return Err(Error::other(format!( return Err(Error::other(format!(
"inactive rebalance rejected while cancelling admission: {expected_id}" "inactive rebalance rejected while cancelling admission: {expected_id}"
))); )));
} }
meta.stop_requested = true;
meta.cancel meta.cancel
.get_or_insert_with(tokio_util::sync::CancellationToken::new) .get_or_insert_with(tokio_util::sync::CancellationToken::new)
.cancel(); .cancel();
@@ -1224,7 +1213,6 @@ impl ECStore {
ensure_rebalance_run_id(rebalance_meta.as_ref(), expected_id, "stop rebalance")?; ensure_rebalance_run_id(rebalance_meta.as_ref(), expected_id, "stop rebalance")?;
} }
rebalance_meta.as_mut().map(|meta| { rebalance_meta.as_mut().map(|meta| {
meta.stop_requested |= is_rebalance_conflicting_with_decommission(meta);
let cancel = meta.cancel.get_or_insert_with(tokio_util::sync::CancellationToken::new); let cancel = meta.cancel.get_or_insert_with(tokio_util::sync::CancellationToken::new);
cancel.cancel(); cancel.cancel();
Arc::clone(&meta.activation_gate) Arc::clone(&meta.activation_gate)
@@ -1389,79 +1377,6 @@ mod tests {
probe.wait_until_attempted().await; probe.wait_until_attempted().await;
} }
#[test]
fn rebalance_stop_classification_checks_identity_and_explicit_intent() {
let mut meta = RebalanceMeta {
id: "current".to_string(),
cancel: Some(tokio_util::sync::CancellationToken::new()),
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Started,
..Default::default()
},
..Default::default()
}],
..Default::default()
};
ensure_rebalance_worker_active(Some(&meta), "current", "test").expect("active worker");
meta.cancel.as_ref().unwrap().cancel();
assert!(
!matches!(
ensure_rebalance_worker_active(Some(&meta), "current", "test"),
Err(Error::OperationCanceled)
),
"a sibling failure is not an operator stop"
);
meta.stop_requested = true;
assert!(matches!(
ensure_rebalance_worker_active(Some(&meta), "current", "test"),
Err(Error::OperationCanceled)
));
assert!(
!matches!(ensure_rebalance_worker_active(Some(&meta), "old", "test"), Err(Error::OperationCanceled)),
"stale identity remains a failure even during stop"
);
assert!(!matches!(
ensure_rebalance_worker_active(None, "current", "test"),
Err(Error::OperationCanceled)
));
}
#[tokio::test]
async fn rebalance_stop_intent_does_not_survive_replacement_run_reload() {
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
id: "replacement".to_string(),
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Completed,
..Default::default()
},
..Default::default()
}],
..Default::default()
})
.await;
let previous_gate = {
let mut meta = store.rebalance_meta.write().await;
let meta = meta.as_mut().unwrap();
meta.id = "previous".to_string();
meta.stop_requested = true;
let cancel = tokio_util::sync::CancellationToken::new();
cancel.cancel();
meta.cancel = Some(cancel);
Arc::clone(&meta.activation_gate)
};
store.load_rebalance_meta().await.expect("reload replacement run");
let meta = store.rebalance_meta.read().await;
let meta = meta.as_ref().unwrap();
assert_eq!(meta.id, "replacement");
assert!(!meta.stop_requested);
assert!(meta.cancel.is_none());
assert!(!Arc::ptr_eq(&previous_gate, &meta.activation_gate));
}
#[tokio::test] #[tokio::test]
async fn cancel_rebalance_admission_is_id_checked_and_idempotent() { async fn cancel_rebalance_admission_is_id_checked_and_idempotent() {
let rebalance_id = "rebalance-admission-current"; let rebalance_id = "rebalance-admission-current";
@@ -1500,59 +1415,6 @@ mod tests {
.await .await
.expect("retrying admission cancellation should be idempotent"); .expect("retrying admission cancellation should be idempotent");
assert!(cancel.is_cancelled()); assert!(cancel.is_cancelled());
let err = store
.update_pool_stats_batch_for_rebalance(0, "bucket".to_string(), &[&FileInfo::default()], rebalance_id)
.await
.expect_err("stop racing with a final stats update must cancel that update");
assert!(matches!(err, Error::OperationCanceled), "operator stop lost its cancellation type: {err}");
}
#[tokio::test]
async fn prepare_rebalance_stop_preserves_intent_when_worker_stops_before_reload() {
let id = "stop-worker-before-reload";
let cancel = tokio_util::sync::CancellationToken::new();
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
id: id.to_string(),
cancel: Some(cancel.clone()),
pool_stats: vec![RebalanceStats {
participating: true,
info: RebalanceInfo {
status: RebalStatus::Stopped,
..Default::default()
},
..Default::default()
}],
..Default::default()
})
.await;
let gate = {
let mut meta = store.rebalance_meta.write().await;
let meta = meta.as_mut().expect("local rebalance metadata");
meta.pool_stats[0].info.status = RebalStatus::Started;
Arc::clone(&meta.activation_gate)
};
assert_eq!(
store.prepare_rebalance_stop().await.expect("prepare the same run stop"),
Some(id.to_string())
);
{
let meta = store.rebalance_meta.read().await;
let meta = meta.as_ref().expect("reloaded stop target");
assert!(Arc::ptr_eq(&gate, &meta.activation_gate), "reload must retain the drained run's gate");
assert!(meta.cancel.as_ref().is_some_and(|token| token.is_cancelled()));
}
store
.stop_rebalance_for_id(Some(id))
.await
.expect("finish the stop after the worker's terminal event");
store
.load_rebalance_meta()
.await
.expect("reload the acknowledged durable stop");
let meta = store.rebalance_meta.read().await;
let meta = meta.as_ref().expect("durable stopped metadata");
assert!(meta.stopped_at.is_some(), "a successful stop must retain its durable timestamp");
assert_eq!(meta.pool_stats[0].info.status, RebalStatus::Stopped);
} }
#[tokio::test] #[tokio::test]
@@ -2169,10 +2031,7 @@ mod tests {
let err = acquire_persisted_rebalance_run_guard(set_disks, active.id.as_str(), "cross-node stale snapshot") let err = acquire_persisted_rebalance_run_guard(set_disks, active.id.as_str(), "cross-node stale snapshot")
.await .await
.expect_err("persisted stop must fence a node that missed stop propagation"); .expect_err("persisted stop must fence a node that missed stop propagation");
assert!( assert!(err.to_string().contains("inactive rebalance worker rejected"));
matches!(err, Error::OperationCanceled),
"a durable remote stop cancels the same run: {err}"
);
} }
#[test] #[test]

Some files were not shown because too many files have changed in this diff Show More